From b9ad99d4f31379f5a56fe2b43a4f19a0c88e5728 Mon Sep 17 00:00:00 2001 From: emmanuelgjr Date: Sat, 15 Aug 2026 10:50:03 -0400 Subject: [PATCH 1/2] fix(crosswalk): close js/xss on the framework-control deep link CodeQL alerts #3 and #4 (js/xss, high) both trace the same flow: window.location.hash -> path -> fwControlMatch -> decodeURIComponent -> renderControlDetail(fwName, controlId) -> el(...) -> appendChild at index.html:1512-1513. Two changes, either of which breaks the flow: 1. el() now routes every appended child through toNode(), so only a real DOM node is appended and anything else becomes a text node. Previously the array/node branches passed their argument to appendChild untouched, which threw a TypeError on a string rather than handling it. 2. The /frameworks// route now applies the same FRAMEWORKS allow-list that the sibling /frameworks/ route already applied, plus a length-and-delimiter check on the control id. An unknown framework has no registry entry and no mappings, so it could only ever render an empty page echoing the URL back; it now falls through to the frameworks index. The control id is bounded rather than allow-listed because ids come from 25 framework registries and are punctuation-heavy ("GV-1.7", "Art. 24-27"). Verified against the real data: all 1514 registry control ids, all 1097 backlink control ids, and every framework name still pass, so no existing deep link changes behaviour. --- crosswalk/docs/index.html | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/crosswalk/docs/index.html b/crosswalk/docs/index.html index 937958b..3645268 100644 --- a/crosswalk/docs/index.html +++ b/crosswalk/docs/index.html @@ -1497,6 +1497,14 @@ 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) { @@ -1509,8 +1517,8 @@ } 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; } @@ -1634,6 +1642,16 @@ '/review': renderReview }; + // Control ids are drawn from 25 separate framework registries and are + // punctuation-heavy ("GV-1.7", "Art. 24-27", "A.5.1"), so this bounds the + // length and rejects markup delimiters rather than allow-listing a shape + // that would reject legitimate ids. + function isPlausibleControlId(value) { + return typeof value === 'string' && + value.length > 0 && value.length <= 200 && + !/[<>]/.test(value); + } + function getRoute() { var hash = window.location.hash || '#/'; // Extract the path part (before any query params) @@ -1669,9 +1687,15 @@ if (fwControlMatch) { var fwNameCtrl = decodeURIComponent(fwControlMatch[1]); var ctrlId = decodeURIComponent(fwControlMatch[2]); - renderControlDetail(app, fwNameCtrl, ctrlId); - window.scrollTo(0, 0); - return; + // Apply the same allow-list the plain framework route uses below. An + // unknown framework name has no registry entry and no mappings, so it + // could only ever render an empty page echoing the URL back; fall + // through to the frameworks index instead. + if (FRAMEWORKS.indexOf(fwNameCtrl) !== -1 && isPlausibleControlId(ctrlId)) { + renderControlDetail(app, fwNameCtrl, ctrlId); + window.scrollTo(0, 0); + return; + } } // Framework detail is a full page, not a modal if (frameworkMatch) { From cdb96467842beb1ac2d2ce4fe25f6ce0529aed00 Mon Sep 17 00:00:00 2001 From: emmanuelgjr Date: Sat, 15 Aug 2026 10:57:36 -0400 Subject: [PATCH 2/2] fix(crosswalk): render resolved control ids, never the URL text CodeQL still reported js/xss after the first pass. The SARIF flow showed the FRAMEWORKS allow-list did cut the framework-name path, but two things were not barriers it recognises: * isPlausibleControlId used a negated character class (!/[<>]/.test), and CodeQL models positive anchored matches, not negations * toNode returned its argument unchanged on the node branch, so taint flowed straight back out to appendChild Replace the character check with resolution. resolveControlId looks the id up in the same three places renderControlDetail reads - the framework registry, the backlink index, then DATA mappings - and returns the stored string. The route renders the resolved value and the allow-listed framework name, so no URL-derived string reaches the DOM at all. An id that resolves to nothing falls through to the frameworks index, which is all an unmatched id could have rendered anyway. Verified every real deep link still resolves to itself: 1097/1097 backlink, 1514 registry, 3210 DATA mapping links, 0 broken. Payloads and __proto__/constructor keys all resolve to null. --- crosswalk/docs/index.html | 51 +++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/crosswalk/docs/index.html b/crosswalk/docs/index.html index 3645268..74cadcf 100644 --- a/crosswalk/docs/index.html +++ b/crosswalk/docs/index.html @@ -1642,14 +1642,28 @@ '/review': renderReview }; - // Control ids are drawn from 25 separate framework registries and are - // punctuation-heavy ("GV-1.7", "Art. 24-27", "A.5.1"), so this bounds the - // length and rejects markup delimiters rather than allow-listing a shape - // that would reject legitimate ids. - function isPlausibleControlId(value) { - return typeof value === 'string' && - value.length > 0 && value.length <= 200 && - !/[<>]/.test(value); + // 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 = FW_REGISTRY_MAP[fwName]; + if (regFw) { + var regControl = (regFw.controls || []).find(function(c) { return c.control_id === controlId; }); + if (regControl) return regControl.control_id; + } + var bl = BACKLINK_MAP[fwName + '::' + controlId]; + 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() { @@ -1687,14 +1701,19 @@ if (fwControlMatch) { var fwNameCtrl = decodeURIComponent(fwControlMatch[1]); var ctrlId = decodeURIComponent(fwControlMatch[2]); - // Apply the same allow-list the plain framework route uses below. An - // unknown framework name has no registry entry and no mappings, so it - // could only ever render an empty page echoing the URL back; fall - // through to the frameworks index instead. - if (FRAMEWORKS.indexOf(fwNameCtrl) !== -1 && isPlausibleControlId(ctrlId)) { - 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