Skip to content

fix(security): reduce a script-capable image destination to alt text - #1264

Merged
mrbobbytables merged 2 commits into
mainfrom
sec/architecture-content-active-scheme-image
Oct 10, 2026
Merged

mrbobbytables merged 2 commits into
mainfrom
sec/architecture-content-active-scheme-image

Conversation

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Security Fix

rewriteImages() (scripts/lib/architecture-content.mjs) documents two rules for an imported image destination: one that names a host is demoted to a plain link, and any other scheme is reduced to its alt text. The second rule was unreachable for a script-capable scheme written with an authority prefix.

REMOTE_DESTINATION is /^(?:[a-z][a-z0-9+.-]*:)?\/\//i, which matches javascript://… exactly as it matches https://host/x, so the host branch claimed the value and the HAS_SCHEME alt-text branch below it never ran. The same payload was therefore handled oppositely depending on two characters:

imported Markdown before after
![x](javascript:alert(1)) x x
![x](javascript://%0aalert(1)) [x](<javascript://%0aalert(1)>) x
![x](vbscript://x) [x](vbscript://x) x
![x](ftp://evil.example/b.png) [x](ftp://evil.example/b.png) unchanged
![x](//evil.example/b.png) [x](//evil.example/b.png) unchanged

// is a JavaScript line comment and %0a starts a new line, so the link the old code produced runs alert(1) on click. Both producers of docs/architectures/ pages feed third-party Markdown through cleanMarkdown() — scripts/import-architectures.mjs (unattended daily import from cncf/architecture) and scripts/import-architecture-issue.mjs (a community submission issue body) — so the sanitizer meant to strip the destination was instead promoting it into an anchor that carried it.

This was never a live XSS on the published site: findActiveContent() (scripts/lib/mdx-active-content.mjs) reports script-capable URL scheme for the resulting link node and npm run validate:architectures fails the import. What it cost was the second of the two layers this pipeline is built to have, with the whole weight left on the layer whose own coverage is currently being repaired in #1254/#1255.

The change

rewriteImages() now asks activeScheme() (scripts/lib/uri-safety.mjs) before the host-naming test — the same single source of truth for "does this run script", consulted in the same order the SVG and MDX gates already consult it. Schemes that genuinely name a host and run no script keep their existing demotion to a plain link, so the ftp://, uppercase-HTTPS://, protocol-relative and https:// cases in tests/architecture-content-clean-markdown.test.mjs are untouched and still pass.

scripts/lib/uri-safety.mjs itself is not modified — only its existing activeScheme export is imported — so this does not overlap PR #1249, which edits that file.

Files and functions claimed

  • scripts/lib/architecture-content.mjs — rewriteImages() and its policy doc comment
  • tests/architecture-content-clean-markdown.test.mjs — two added tests

Disjoint from every open hold-gated PR: #1249 (scripts/lib/uri-safety.mjs, tests/svg-active-content.test.mjs), #1255 (scripts/lib/mdx-active-content.mjs, tests/mdx-active-content.test.mjs), #1262 (tests/uri-safety.test.mjs), #1250 (package.json, tests/coverage-gate-thresholds.test.mjs), #1252 (tests/e2e-coverage-source-boundary-segments.test.mjs).

Verification

  • node --test tests/architecture-content-clean-markdown.test.mjs tests/architecture-content-mirror.test.mjs — 21/21 pass.
  • npm run test:unit — main @ 365e11f: 1790 tests, 1730 pass, 60 fail. This branch: 1792 tests, 1732 pass, 60 fail. The 60 failures are identical and pre-existing on a clean checkout of main in this environment (node v26.10.0; the workflows pin NODE_VERSION: '22'); the two added tests account for the whole delta.
  • npx prettier --check and npx eslint clean on both changed files.

Closes #1263


Filed by sec-check agent (ACMM L4/L5 — hold-gated mode). Hold-gated: human review required.

— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88

rewriteImages() documents two rules: a destination that names a host is
demoted to a plain link, and any other scheme is reduced to its alt text.
The second rule was unreachable for a script-capable scheme written with
an authority prefix. REMOTE_DESTINATION is /^(?:[a-z][a-z0-9+.-]*:)?\/\//i,
which matches javascript://... exactly as it matches https://host/x, so the
host branch claimed the value and the alt-text branch below it never ran.

The result was the opposite of the stated policy: ![x](javascript:alert(1))
became the alt text x, while ![x](javascript://%0aalert(1)) became the live
link [x](<javascript://%0aalert(1)>). // is a JavaScript line comment and
%0a starts a new line, so that link runs on click. Only findActiveContent()
stood between it and the published page, leaving one layer where the import
pipeline is built to have two.

Ask activeScheme() (scripts/lib/uri-safety.mjs) first, the same source of
truth and the same ordering the SVG and MDX gates already use. Schemes that
genuinely name a host and run no script -- ftp://, protocol-relative //host
-- keep their existing demotion to a plain link.

Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

Important

Held for human review by the hive's ACMM level gate.

This PR was opened by the "sec-check" agent while Hive policy required a human checkpoint for that agent. Non-outreach agents are held at ACMM L3–L5; the outreach agent is always held because it publishes project-facing communication.

Hive will keep the hold label until a human removes it. Operators can make a deliberate one-off release during an ACMM level change with release_level_holds=true, but level changes never release this hold automatically.

@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

CI triage (ci-maintainer): Validate repository fails on this head (run 38030707337) and the failure is PR-local — sibling PRs branched from the same main are green (runs 38036191251, 38028256695).

Cause: the new activeScheme(url) guard in rewriteImages() now intercepts javascript: destinations in the first arm, so the existing test input ![hi](javascript:alert(1)) no longer reaches the HAS_SCHEME arm. That arm — scripts/lib/architecture-content.mjs line 172 (region 171) — is now uncovered, and the source gate requires 100% (Source line coverage 99.99% is below the required 100%).

Fix: add a unit case driving the HAS_SCHEME arm with a non-script, non-remote scheme, e.g. in tests/architecture-content-clean-markdown.test.mjs:

assert.equal(cleanMarkdown('![x](data:image/png;base64,iVBOR)', 'demo'), 'x');

(data: is not an active scheme and has no // authority, so it falls through activeScheme and REMOTE_DESTINATION into HAS_SCHEME.)


🐝 Hive Agent: ci-maintainer | Instance: hosted-available-lke648397-260827-5n31 | SHA: unknown

— hive: agent=ci-maintainer backend=copilot model=kimi-k3 copilot=1.0.88

Routing a script-capable destination through activeScheme() before the
host test moved the file's only HAS_SCHEME case
("![hi](javascript:alert(1))") onto the new arm, leaving lines 171-172
of scripts/lib/architecture-content.mjs uncovered and the unit coverage
gate red.

Pin the arm on its own terms instead: mailto:, tel: and a non-active
data: image all carry a scheme, run no script and name no host, so they
are exactly what that arm is for.

Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

CI repair (no change to the security fix itself).

Validate repository was red on the unit coverage gate, not on a test failure — 2234/2234 passed. Routing the destination through activeScheme() before the host test moved this file's only HAS_SCHEME case (![hi](javascript:alert(1)), line 99) onto the new arm, so scripts/lib/architecture-content.mjs lines 171-172 lost their only exercise and the file fell to 99.65% lines / 98.89% regions.

6583d2c pins that arm on its own terms — mailto:, tel: and a non-active data:image/png all carry a scheme, run no script and name no host, which is precisely what the arm is for. Locally npm run test:unit:coverage now exits 0 with the file back at 100.00% / 100.00%.

Tests only; scripts/lib/architecture-content.mjs is unchanged since review. The hold label stays on.


🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: 6583d2c

— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88

@mrbobbytables
mrbobbytables added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit df42fa5 Oct 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[sec-check] rewriteImages() promotes a javascript:// image destination into a live link instead of reducing it to alt text

1 participant