Skip to content

fix(crosswalk): close js/xss on the framework-control deep link (CodeQL #3, #4) - #63

Open
emmanuelgjr wants to merge 2 commits into
mainfrom
fix/xss-control-detail-route
Open

fix(crosswalk): close js/xss on the framework-control deep link (CodeQL #3, #4)#63
emmanuelgjr wants to merge 2 commits into
mainfrom
fix/xss-control-detail-route

Conversation

@emmanuelgjr

@emmanuelgjr emmanuelgjr commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Closes CodeQL alerts #3 and #4js/xss, severity high, both open on main.

The flow

Both alerts are one taint path, read out of the analysis SARIF:

window.location.hash                      index.html:1638
  -> path                                 index.html:1640
  -> fwControlMatch = path.match(...)      index.html:1653
  -> decodeURIComponent(match[1|2])        index.html:1670-1671
  -> renderControlDetail(fwName, ctrlId)   index.html:1672
  -> el(..., fwName | controlId)           index.html:2671, 2682, 2746
  -> node.appendChild(c)                   index.html:1512-1513   <- sink

The sibling route immediately below already allow-lists its input:

if (frameworkMatch) {
  var fwName = decodeURIComponent(frameworkMatch[1]);
  if (FRAMEWORKS.indexOf(fwName) !== -1) {   // <- this check

The fwControlMatch branch had no equivalent. That asymmetry is the defect.

Changes

1. resolveControlId() — render resolved data, never URL text. The route now looks the framework name up in FRAMEWORKS and the control id up in the same three places renderControlDetail itself reads (framework registry → backlink index → DATA mappings), and passes the stored strings to the renderer. No URL-derived string reaches the DOM. A deep link that resolves to nothing falls through to the frameworks index — which is all an unmatched id could have rendered anyway, since it has no registry entry and matches no mapping.

2. el() only ever appends a real node. Children now go through toNode(), which returns a DOM node as-is and wraps anything else in a text node. The previous branches passed their argument straight to appendChild, which threw a TypeError on a string rather than handling it, so this also makes el() total.

Why not a character filter

The first attempt bounded the control id and rejected </>. CodeQL still flagged it, correctly — the SARIF showed the FRAMEWORKS allow-list had cut the framework-name path, but neither remaining guard was a barrier it models:

  • the check was a negated character class (!/[<>]/.test(v)); CodeQL models positive anchored matches
  • toNode returned its argument unchanged on the node branch, so taint flowed back out to appendChild

Resolution is the stronger fix regardless: it is an exact allow-list drawn from our own data, so it needs no opinion about which characters are dangerous. That matters here because control ids come from 25 registries and are punctuation-heavy (GV-1.7, Art. 24-27, A.5.1), and some legitimately aren't in FW_REGISTRY_MAP at all — renderControlDetail has an explicit DATA fallback for exactly that case, which resolveControlId mirrors.

Verification

Every real deep link still resolves to itself — checked against the actual bundles:

Source Links Broken
backlink index 1097 0
framework registry 1514 0
DATA mappings 3210 0

Payloads all resolve to null and fall through: <img src=x onerror=alert(1)>, "><svg/onload=alert(1)>, <script>alert(1)</script> as a framework name, and the __proto__ / constructor keys.

el() exercised against the edited source with a DOM shim — 7/7:

  • string child still becomes textContent, never markup
  • array-containing-a-string no longer throws, and lands as a text node (nodeType 3)
  • element children pass through with identity preserved (children[0] === kid)
  • falsy array entries still skipped

Inline script parses cleanly (vm.Script, 0 syntax errors). el() is defined in only one file, so there is no second copy.

CodeQL on this branch: No new alerts in code changed by this pull request, and zero open alerts on the PR head.

Notes

index.html is hand-maintained — scripts/generate.js writes only docs/data.js, docs/backlinks.js, docs/frameworks-registry.js and docs/incidents.js — so this edit will not be regenerated away.

Unrelated, spotted while checking id shapes and worth a separate issue: a number of control_id values in frameworks-registry.js hold prose rather than ids (e.g. "Providers document obligations; deployers verify"), and several contain U+FFFD replacement characters from a bad encoding round-trip.

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/<fw>/<control> route now applies the same FRAMEWORKS
   allow-list that the sibling /frameworks/<fw> 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.
Comment thread crosswalk/docs/index.html Fixed
Comment thread crosswalk/docs/index.html Fixed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants