From d95d4366d7835c63f21c553332fbcaf08d493fc9 Mon Sep 17 00:00:00 2001 From: emmanuelgjr Date: Mon, 14 Sep 2026 15:14:10 -0400 Subject: [PATCH] fix(webapp): close js/xss on the framework-control deep link Port of GenAI-Security-Project/GenAI-Data-Security-Initiative#63 (squash 17f37f5), which fixed the same code in that repo's former crosswalk/ copy. The #/frameworks// hash route (fwControlMatch) passed URL-decoded text straight into renderControlDetail, which hands it to el() and on to appendChild. The sibling frameworkMatch route already allow-lists its input against FRAMEWORKS; this one did not. - resolveControlId() resolves the control id against the same three sources renderControlDetail reads (framework registry, backlink index, DATA mappings) and returns the stored string. The route checks the framework name against FRAMEWORKS and renders only the resolved values, never URL text. Unresolved links fall through to the frameworks index. Map lookups use hasOwnProperty so prototype keys never match. - el() routes children through toNode(), so anything that is not a DOM node is appended as a text node and can never be parsed as markup. Verified: every deep link the app builds from backlinks.js (1159), frameworks-registry.js (1514), data.js mappings (1159) and classifier-predictions.js (199) still resolves to itself; XSS payloads and __proto__/constructor resolve to null; validate, generate (no drift beyond date headers), stats:check and 50/50 unit tests match main. Co-authored-by: emmanuelgjr <129134995+emmanuelgjr@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KJVKF6raJCNKBWjTkDSqR4 --- docs/index.html | 54 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/index.html b/docs/index.html index fee36a6..d0f12f6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1537,6 +1537,14 @@

AI Security Standards Crosswalk

return div.innerHTML; } + // Only a real DOM node may be appended. Anything else — most importantly a + // string that reached here from the URL — becomes a text node, so it is + // rendered as text and can never be parsed as markup. + function toNode(value) { + if (value && typeof value.nodeType === 'number') return value; + return document.createTextNode(String(value)); + } + function el(tag, attrs, children) { var node = document.createElement(tag); if (attrs) { @@ -1549,8 +1557,8 @@

AI Security Standards Crosswalk

} if (children) { if (typeof children === 'string') node.textContent = children; - else if (Array.isArray(children)) children.forEach(function(c) { if (c) node.appendChild(c); }); - else node.appendChild(children); + else if (Array.isArray(children)) children.forEach(function(c) { if (c) node.appendChild(toNode(c)); }); + else node.appendChild(toNode(children)); } return node; } @@ -1674,6 +1682,31 @@

AI Security Standards Crosswalk

'/review': renderReview }; + // Resolve a control id supplied in the URL to the canonical string held in + // our own data, searching the same three places renderControlDetail reads: + // the framework registry, the backlink index, then the mappings in DATA. + // Returns null when nothing matches. Callers render the returned value + // rather than the URL, so nothing user-supplied is ever put on the page — + // a control id that matches nothing could only have produced an empty page + // echoing the URL back anyway. + function resolveControlId(fwName, controlId) { + var regFw = Object.prototype.hasOwnProperty.call(FW_REGISTRY_MAP, fwName) ? FW_REGISTRY_MAP[fwName] : null; + if (regFw) { + var regControl = (regFw.controls || []).find(function(c) { return c.control_id === controlId; }); + if (regControl) return regControl.control_id; + } + var blKey = fwName + '::' + controlId; + var bl = Object.prototype.hasOwnProperty.call(BACKLINK_MAP, blKey) ? BACKLINK_MAP[blKey] : null; + if (bl) return bl.control_id; + var found = null; + DATA.forEach(function(e) { + (e.mappings || []).forEach(function(m) { + if (found === null && m.framework === fwName && m.control_id === controlId) found = m.control_id; + }); + }); + return found; + } + function getRoute() { var hash = window.location.hash || '#/'; // Extract the path part (before any query params) @@ -1709,9 +1742,20 @@

AI Security Standards Crosswalk

if (fwControlMatch) { var fwNameCtrl = decodeURIComponent(fwControlMatch[1]); var ctrlId = decodeURIComponent(fwControlMatch[2]); - renderControlDetail(app, fwNameCtrl, ctrlId); - window.scrollTo(0, 0); - return; + // Resolve both halves of the deep link against our own data and render + // the resolved values, never the URL text. The framework name gets the + // same allow-list the plain framework route uses below; an unknown + // framework or control falls through to the frameworks index. + var fwIdx = FRAMEWORKS.indexOf(fwNameCtrl); + if (fwIdx !== -1) { + var knownFw = FRAMEWORKS[fwIdx]; + var knownCtrl = resolveControlId(knownFw, ctrlId); + if (knownCtrl !== null) { + renderControlDetail(app, knownFw, knownCtrl); + window.scrollTo(0, 0); + return; + } + } } // Framework detail is a full page, not a modal if (frameworkMatch) {