fix(crosswalk): close js/xss on the framework-control deep link (CodeQL #3, #4) - #63
Open
emmanuelgjr wants to merge 2 commits into
Open
fix(crosswalk): close js/xss on the framework-control deep link (CodeQL #3, #4)#63emmanuelgjr wants to merge 2 commits into
emmanuelgjr wants to merge 2 commits into
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes CodeQL alerts #3 and #4 —
js/xss, severity high, both open onmain.The flow
Both alerts are one taint path, read out of the analysis SARIF:
The sibling route immediately below already allow-lists its input:
The
fwControlMatchbranch 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 inFRAMEWORKSand the control id up in the same three placesrenderControlDetailitself reads (framework registry → backlink index →DATAmappings), 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 throughtoNode(), which returns a DOM node as-is and wraps anything else in a text node. The previous branches passed their argument straight toappendChild, which threw aTypeErroron a string rather than handling it, so this also makesel()total.Why not a character filter
The first attempt bounded the control id and rejected
</>. CodeQL still flagged it, correctly — the SARIF showed theFRAMEWORKSallow-list had cut the framework-name path, but neither remaining guard was a barrier it models:!/[<>]/.test(v)); CodeQL models positive anchored matchestoNodereturned its argument unchanged on the node branch, so taint flowed back out toappendChildResolution 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 inFW_REGISTRY_MAPat all —renderControlDetailhas an explicitDATAfallback for exactly that case, whichresolveControlIdmirrors.Verification
Every real deep link still resolves to itself — checked against the actual bundles:
DATAmappingsPayloads all resolve to
nulland fall through:<img src=x onerror=alert(1)>,"><svg/onload=alert(1)>,<script>alert(1)</script>as a framework name, and the__proto__/constructorkeys.el()exercised against the edited source with a DOM shim — 7/7:textContent, never markupnodeType 3)children[0] === kid)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.htmlis hand-maintained —scripts/generate.jswrites onlydocs/data.js,docs/backlinks.js,docs/frameworks-registry.jsanddocs/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_idvalues inframeworks-registry.jshold prose rather than ids (e.g."Providers document obligations; deployers verify"), and several contain U+FFFD replacement characters from a bad encoding round-trip.