Skip to content

fix: re-scan the SVG stripActiveContent returns - #1516

Merged
mrbobbytables merged 2 commits into
mainfrom
sec/svg-strip-rescan
Oct 15, 2026
Merged

mrbobbytables merged 2 commits into
mainfrom
sec/svg-strip-rescan

Conversation

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Security Fix

Claims exactly two files: scripts/lib/svg-active-content.mjs
(stripActiveContent) and tests/svg-active-content.test.mjs.

stripActiveContent() is the sanitizer that makes a third-party SVG safe to
publish from static/. Its JSDoc states that "the result is re-scanned before
it is returned" and that it throws if "active content survives". Neither was
implemented: the only verification in the function, structuralFindings(),
runs on the input before the rebuild, and rebuild(..., { strip: true })
was returned unexamined.

That post-condition is load-bearing. architecture-content.mjs:268
(mirrorArtworkUrls, reached from a community-submitted reference-architecture
issue) and enduser-collector.mjs:164 (mirrorLandscapeLogo) both write the
returned string into static/ and gate the write on findRemoteReferences()
only — so for those two paths stripActiveContent() is the sole
active-content gate on bytes published at the site origin, where a
directly-opened SVG executes script in that origin.
import-architectures.mjs:253 re-scans, but its comment says it does so for
its own later text edit and names the premise outright: "stripActiveContent()
verifies the string it returns."

Not exploitable at 1ae3403: findings and removals both come from
classifyTag() over the same parse and the output is serialized from that
tree, so they agree by construction. This change makes that agreement a
property of the returned bytes rather than an argument about two code paths —
the file's eight fix(security) commits (#1019 #1025 #1044 #1068 #1090 #1168
#1176 #1245) are the record of that argument being revised.

  • scripts/lib/svg-active-content.mjs: after the rebuild, run
    findActiveContent() over the serialized output and throw when anything
    active survives, so a divergence between the removal set, the serialization
    and the detector fails closed instead of publishing a document that only
    looks sanitized.
  • tests/svg-active-content.test.mjs: 15 new tests asserting the
    post-condition on the returned bytes for every construct the module strips
    (each ACTIVE_ELEMENTS member, event-handler attributes, srcdoc, a
    javascript: href, a namespace-prefixed script, animate/set installing
    an href), plus one fixture combining all of them with an <?xml-stylesheet?>
    processing instruction. The existing test "throws rather than returning a
    source the detector still flags" only covers the structural refusal, which
    happens before the rebuild; this covers the other half of the same promise.

Full unit suite green: 2711/2711 (npm run test:unit). npm run check:format
clean.

Closes #1515


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

@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.

@netlify

netlify Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for endusers ready!

Name Link
🔨 Latest commit 46b50d7
🔍 Latest deploy log https://app.netlify.com/projects/endusers/deploys/6ad0e7345b36f8a8cf1390e4
😎 Deploy Preview https://deploy-preview-1516--endusers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

sec-check and others added 2 commits October 15, 2026 09:45
stripActiveContent() documents that it re-scans its result and throws if
active content survives, and two mirroring callers write that result into
static/ with no active-content check of their own. The re-scan was never
implemented. Add it, and cover the post-condition for every strippable
construct.

Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
The gate requires 100% source regions, and the re-scan throw is unreachable
with the default detector because it and the stripper agree by construction.
stripActiveContent() now accepts { scan } (default findActiveContent); the
test drives a detector that flags the rebuilt bytes and asserts the string
never reaches the caller. Production callers are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: mrbobbytables <mrbobbytables@users.noreply.github.com>

@mrbobbytables mrbobbytables left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a scan seam so the survival throw is covered; enqueuing.

@mrbobbytables
mrbobbytables added this pull request to the merge queue Oct 15, 2026
@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

🔒 Hold-gate integrity: branch changed while hold-gated — fresh review required

This PR's branch moved while it sat hold-gated: the head recorded when the hold was applied was fe0a88449cc4, but the branch now sits at 46b50d75b0fe. The diff a reviewer saw under the hold is no longer the diff that would merge, so auto-merge is blocked and the hold label has been re-applied.

Commits not present in the hold-time snapshot (2):

  • 89a6b947e271 by sec-check — [sec-check] fix: re-scan the SVG stripActiveContent returns
  • 46b50d75b0fe by mrbobbytables — test: exercise the survival throw through an injectable scan seam

Authors not in the hold-time author set: mrbobbytables

A human should review the FULL diff at the current head — a rebase or force-push renames every commit, so everything above needs eyes even if it looks familiar. Removing the hold label after that review is the fresh approval: the guard has re-pinned its snapshot to the current head, so a clean lift re-opens the merge lanes.

Merged via the queue into main with commit 2bd586c Oct 15, 2026
12 checks passed

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the diff against the tree at 46b50d7, including both mirroring callers. This looks correct to me.

Verified:

  • The claimed gap was real: before this change, stripActiveContent() ran findActiveContent()/structuralFindings() only on the input and returned rebuild(text, parsed.events, { strip: true }) unexamined, despite its JSDoc promising a re-scan.
  • Both callers the body names are as described: scripts/lib/architecture-content.mjs:268 and scripts/lib/enduser-collector.mjs:164 write the returned string into static/ gated only by findRemoteReferences(), so this function is the sole active-content gate on those publish paths. scripts/import-architectures.mjs:253 explicitly assumes "stripActiveContent() verifies the string it returns" — this PR makes that comment true.
  • The new re-scan (scripts/lib/svg-active-content.mjs:809-814) fails closed by throwing, which both callers already handle (architecture-content.mjs wraps the mirror in try/catch and warns; enduser-collector's caller likewise).
  • Ran node --test tests/svg-active-content.test.mjs at the PR head: 149/149 pass, including the 17 new post-condition tests.

One observation (info, not a defect): the scan injection seam exists only so the throw is reachable in tests, since the default detector and stripper agree by construction. No production caller passes options, so it can't weaken the gate; the JSDoc says this plainly. Fine as-is.

Note the early return at svg-active-content.mjs:786 (input with zero findings) skips the re-scan — trivially satisfied, since the same detector just reported the input clean.

Hold-gated per the PR body; a maintainer still needs to sign off, but I found nothing blocking.

— hive: agent=reviewer backend=copilot model=claude-fable-5 copilot=1.0.88

@mrbobbytables
mrbobbytables deleted the sec/svg-strip-rescan branch October 16, 2026 01:01
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.

[sec-check] stripActiveContent() never re-scans the SVG it returns, though its contract and two mirroring callers depend on it

1 participant