Repository navigation
fix(ui): escape and validate miniMarkdown link destinations (#368) - #420
Conversation
miniMarkdown() in public/roles.js built its anchors from a '$2' replacement template, so the captured Markdown link destination went into a double-quoted href with neither attribute escaping nor any restriction on the URL scheme. The rest of the text was already & < > escaped, but the two quote characters were not, so a destination could close the attribute and add its own (including an event handler), and nothing stopped javascript: / data: / vbscript:. public/home.js renders Home step descriptions and FAQ answers through miniMarkdown() with innerHTML, and that text comes from site customization (SITE_CONFIG.home), which is importable and therefore untrusted. - escape the destination for attribute context with the same escapeAttr the Home sinks use, now defined once in roles.js and exported; home.js's local copy delegates to it instead of redefining it - reject a destination whose scheme is not http/https, after lowercasing and removing ASCII controls and whitespace, so case- and control-obfuscated schemes are rejected too. Schemeless destinations (relative paths, #fragments, ?query) are untouched - render a rejected link as its already-escaped label text - build the anchor from a replacement function, so a '$&' or "$'" in the destination is no longer re-expanded by String.replace Tests: test-issue-368-minimarkdown-links.js (registered in test-all.sh) and test-xss-368-home-links-e2e.js (registered in deploy.yml), which drives the real Home page against a local server with the hostile destinations injected into /api/config/theme. Relates to #368
Rapport — CS-Minimax PR#420 #368 — head 7002497Status: Done — every acceptance point implemented, each with a test red before and green after plus mutants; every CI job on this head is green and nothing is left unfinished. Evidence tags: [T] = I ran it. [A] = from reading the code. [K] = from CI. Rechecked against current master Requirements
Red-before was measured by restoring Mutants were applied to the fixed CI per job (head
|
| Job | Result |
|---|---|
| Go Build & Test | pass (24m06s) — test-all.sh 239 passed / 0 failed over 239 files, incl. test-issue-368-minimarkdown-links.js at 53/0; preflight XSS gate step green [K] |
| Playwright E2E Tests | pass (27m24s) — test-xss-368-home-links-e2e.js ran and reported 9 passed / 0 failed [K] |
| Build & Publish Docker Image | pass [K] |
| Release Artifacts | skipping (not a release ref) [K] |
| Publish Badges & Summary | skipping [K] |
| Deploy Staging | skipping [K] |
No job needed a re-run; the known flake #271 did not appear. [K]
Local verification
sh test-all.sh— 239 / 239 [T]node test-frontend-helpers.js— 713 / 0 [T]node test-preflight-xss-gate.js— all assertions passed [T]- Go: green in every module —
cmd/server,cmd/ingestor,cmd/migrate,cmd/decryptand allinternal/*. This repo is multi-module, sogo test ./...was run per module rather than once at the root [T] npx eslint@8 public/*.js— 0 errors (89 pre-existing warnings, none in the changed hunks) [T]bash scripts/check-xss-sinks.sh --diff origin/master— clean; also clean per-file onpublic/roles.jsandpublic/home.js[T]- The browser test ran against a locally built
corescope-serveron a private port, against a migrated copy oftest-fixtures/e2e-fixture.dbprepared as in CI; the server was stopped afterwards. No staging or production access, no server API key used [T]
Constraints held: cmd/server untouched, no new map[string]interface{}, no hardcoded colours (the anchor keeps var(--link-color)), fork guards unchanged at 9 in deploy.yml and 1 in release-fast-path.yml. [T]
Remainders — left for the owner
mailto:is not on the allowlist. Nothing in the repo emits one today, so adding it would widen behaviour rather than preserve it. If site owners are expected to put contact addresses in Home customization,mailtobelongs inSAFE_LINK_SCHEMES; a unit test currently pins the exclusion and would need updating with it. Product call, not a security one. [A]- Protocol-relative destinations (
//host/path) are still accepted. They carry no scheme, so they land in the "relative" bucket and render as an ordinary cross-origin link withtarget="_blank" rel="noopener". Blocking them is a policy choice about cross-origin links in customization content, beyond this issue. [A] public/path-inspector.jskeeps its ownescapeAttrwith different semantics (&,",<— no'). It is not on the Home path, so consolidating the two variants is a separate cleanup and was not folded in here. [A]- A rejected destination is dropped silently — the reader sees plain label text with no sign a link was removed. Warning the author belongs in the customizer's validation UI, not in the renderer. [A]
- security(ui): escape untrusted node, observer and route values in HTML rendering #333 scope is untouched. The other HTML sinks fed by node / observer / route data remain that issue's business. [A]
This is a source-code finding, not a claim of an observed production exploit, and no live-site change was made. The PR is left as a draft; no merge, no ready-for-review, no issue closed.
Review — CS-pve-agent1 PR#420 — head 7002497Dom: APPROVE med nits Independent, read-only review. Evidence tags: [T] = I ran it, [A] = from reading the code, [K] = from CI. Trees used: the head ( There is no earlier review on this PR and no comments on #368, so each point below is checked against the issue text. Findings
The issue, point by point
"Left for the owner" ( Security review (#368)
|
Relates to #368
Problem
miniMarkdown()inpublic/roles.jsbuilt its anchors from a'$2'replacement template:The surrounding text is
& < >escaped on the first pass, but the captured destination lands in a double-quotedhrefwith neither attribute escaping nor any restriction on the URL scheme. Two consequences:javascript:,data:text/html,vbscript:all become live links.public/home.jsrenders Home step descriptions and FAQ answers throughminiMarkdown()and assigns the result withinnerHTML. That text comes fromSITE_CONFIG.home(served by/api/config/theme, importable site customization), so the destination is attacker-reachable. Verified on this branch: with the fix reverted, the browser E2E below reportsinjected handler fired 1 time(s)on the real Home page.Source-code finding only. No live-site change is authorized by #368 and none was made.
Plan / what changed
public/roles.jsescapeAttrbefore it reaches thehref. Rather than add a third copy of that helper, it is defined once inroles.js(loaded beforehome.jsbyindex.html) and exported aswindow.escapeAttr;home.js's local copy now delegates to it, so the Markdown path and the Home attribute sinks cannot drift apart. The implementation is byte-for-byte the behaviour of the copy it replaces ("→",'→');&/</>are already entities by the time a destination is captured.safeLinkHref()lowercases the destination and strips every byte in the range U+0000 to U+0020 plus U+007F (ASCII controls, space and DEL) — the characters a browser ignores while resolving a scheme — then matches the RFC 3986 scheme grammar^[a-z][a-z0-9+.-]*:. A match outside{http, https}is rejected. A destination with no scheme (relative path,#fragment,?query) is untouched and still renders.function(_m, label, dest)callback, which also closes a latent second hole: a$&or$'in the destination was being re-expanded byString.replace.Everything else in
miniMarkdown(bold, italic, code, lists,<br>, the<ul>wrap) is unchanged and pinned by tests.scripts/collect-frontend-coverage.jsexercises both arms of the new branch so the instrumented coverage run does not report it as dead code.Acceptance → tests → mutants
Unit:
test-issue-368-minimarkdown-links.js(53 assertions, registered intest-all.sh) drives the realminiMarkdownfrompublic/roles.jsin a vm sandbox and parses the rendered anchor's attribute names, so the breakout checks are payload-independent.Browser:
test-xss-368-home-links-e2e.js(9 checks, registered in the Playwright step ofdeploy.yml) drives the real Home page against a local Go server withe2e-fixture.db, injecting the hostile customization by rewriting/api/config/themewithpage.route. The fixture DB is untouched and no payload leaves the machine. Checks are DOM-level: did a handler run, whatprotocoldid the browser resolve for each anchor, which attributes does each anchor actually carry.escapeAttron the Home pathRed-before was measured by restoring
public/roles.jsandpublic/home.jsfromorigin/master(4a5ec5e2) with the new tests in place: unit 24 passed / 29 failed, E2E 3 passed / 6 failed (including the liveinjected handler fired 1 time(s)). With the fix: unit 53/0, E2E 9/0.Mutants — each applied to the fixed
public/roles.js, both suites re-run, then reverted from a scratch snapshot:return escapeAttr(raw)→return rawprobe = raw.toLowerCase())if (false) return '')'$2'replacement templateVerification run locally
sh test-all.sh— 239 files, 239 passed, 0 failed (includes the root test-registry gate: both new files are registered in exactly one entry point, and the E2E is correctly indeploy.ymlrather thantest-all.sh).node test-frontend-helpers.js— 713 passed, 0 failed.node test-preflight-xss-gate.js— all assertions passed.cmd/server,cmd/ingestor,cmd/migrate,cmd/decryptand allinternal/*(go test ./...per module; this repo is multi-module, there is no rootgo.mod).npx eslint@8 public/*.js— 0 errors (89 pre-existing warnings, none in the changed hunks).bash scripts/check-xss-sinks.sh --diff origin/master— clean; also clean per-file onpublic/roles.jsandpublic/home.js.corescope-serveron a private port with a migrated copy oftest-fixtures/e2e-fixture.db, prepared as in CI. No staging or production access.Constraints held:
cmd/serveruntouched, no newmap[string]interface{}, no hardcoded colours (the anchor keepsvar(--link-color)), fork guards unchanged at 9 indeploy.ymland 1 inrelease-fast-path.yml.Left for the owner
mailto:is not on the allowlist. Nothing in the repo emits amailto:link today, so adding it would widen behaviour rather than preserve it. If site owners are expected to put contact addresses in Home customization,mailtoshould be added toSAFE_LINK_SCHEMES— a product call, not a security one. A unit test currently pins the exclusion, so it would need updating with it.//host/path) are still accepted. They carry no scheme, so they fall in the "relative" bucket and render as an ordinary cross-origin link withtarget="_blank" rel="noopener". Blocking them would be a policy choice about cross-origin links in customization content, beyond the scope of this issue.public/path-inspector.jskeeps its ownescapeAttrwith different semantics (&,",<— no'). It is not on the Home path and consolidating the two variants is a separate cleanup, not folded in here.