Skip to content

fix(security): strip DOCTYPEs whose external identifier contains a bracket - #1024

Closed
hivecommons-hive[bot] wants to merge 1 commit into
mainfrom
sec/fix-doctype-external-id
Closed

hivecommons-hive[bot] wants to merge 1 commit into
mainfrom
sec/fix-doctype-external-id

Conversation

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Security Fix

stripDoctype() in scripts/lib/svg-active-content.mjs read [ as the
unambiguous start of a DOCTYPE's internal subset. The XML grammar does not
allow that: an ExternalID carries quoted literals, which may themselves
contain [, ] or >. So

<!DOCTYPE svg SYSTEM "https://host.example/x[.dtd">

has no internal subset at all, but the old pattern saw one that never closes.
That failed two different ways, and both survived the callers' post-strip
rescans because the result is inert either way:

  • No later ]-then-> - neither alternative matched, the declaration came
    back unchanged, and an attacker-chosen external DTD reference survived into
    published content while the run reported it removed.
  • A later ]-then-> - and every <![CDATA[...]]> section supplies one,
    which is routine in the draw.io and Illustrator exports these pipelines
    mirror - the lazy subset match deleted every byte in between, taking the
    <svg> root element with it.

Neither mirrorLandscapeLogo() (scripts/lib/enduser-collector.mjs) nor
sanitizeArchitectureAssets() (scripts/import-architectures.mjs) has a
DOCTYPE gate of its own, so both published the file believing it was cleaned.

Changes

scripts/lib/svg-active-content.mjs - match each part of the declaration with
a quote-aware alternation, so a quoted literal always consumes through its
closing mate. This also fixes > and ]> inside a quoted system identifier,
which failed for the same reason. The documented behaviour for a subset that
genuinely never closes is preserved: it still matches nothing and is left in
place for the <!ENTITY check to flag. The alternation branches are disjoint
on their first character, so matching stays linear - a regression test pins a
200k-character unterminated literal under a second (it completes in ~0ms).

scripts/validate-architecture-assets.mjs - make the auto-fix fail closed.
It previously pushed removed DOCTYPE without checking that the removal
happened, and for the five quality: false asset roots it wrote the result at
the early return with no rescan at all. It now records an error and leaves the
file alone when a DOCTYPE survives the strip, which also keeps any future gap
in the pattern from being reported as a completed fix.

Verification

  • Reproduced before the fix with the real tool: validate-architecture-assets --fix exited 0 reporting removed DOCTYPE for two fixtures, leaving one
    with its DOCTYPE intact and the other truncated to </style><rect/></svg>.
    After the fix both are stripped correctly with their CDATA preserved.
  • 9 new regression tests across tests/svg-active-content.test.mjs and
    tests/validate-architecture-assets.test.mjs.
  • Full unit suite green (1882 tests before the new ones; 135/135 in the two
    touched files).
  • npm run test:unit:coverage:check passes; npm run check:format clean.
  • validate-architecture-assets over the real 172-asset corpus is unchanged -
    same single pre-existing foreignObject warning, no new findings.

Scope: DOCTYPE_PATTERN/stripDoctype in scripts/lib/svg-active-content.mjs
and the DOCTYPE branch of validateSvg() in
scripts/validate-architecture-assets.mjs, plus their two test files. No
overlap with #1018 (contributor template / PR template links).

Closes #1023


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

…acket

stripDoctype() read '[' as the unambiguous start of a DOCTYPE's internal
subset, but an ExternalID carries quoted literals that may contain '[', ']'
or '>'. A declaration such as

    <!DOCTYPE svg SYSTEM "https://host.example/x[.dtd">

has no internal subset, yet the old pattern saw one that never closes. With
no later ']'-then-'>' the declaration was returned unchanged, so the strip
silently did nothing and an external DTD reference survived into published
content; with one -- every <![CDATA[...]]> section supplies it -- the lazy
subset match deleted every byte in between, destroying live markup. Both
outcomes are inert, so both passed the callers' post-strip rescans in
mirrorLandscapeLogo() and sanitizeArchitectureAssets(), neither of which has
a DOCTYPE gate of its own.

Match each part with a quote-aware alternation so a quoted literal always
consumes through its closing mate. This also fixes '>' and ']>' inside a
quoted system identifier, and preserves the documented behaviour for a subset
that genuinely never closes: it still matches nothing and is left for the
<!ENTITY> check to flag. The branches are disjoint on their first character,
so matching stays linear.

Make the auto-fix in validateSvg() fail closed as well, recording an error
rather than reporting 'removed DOCTYPE' for a declaration still present. That
also covers the early-return write, which persisted the result with no rescan.

Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
@hivecommons-hive hivecommons-hive Bot added the hold label Oct 3, 2026
@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 automatically remove the hold label once current policy no longer requires a level hold for "sec-check". If this is an outreach PR, a human must review it and remove the label.

mrbobbytables added a commit to mrbobbytables/endusers that referenced this pull request Oct 3, 2026
The alternation branches of DOCTYPE_PATTERN are disjoint on their first
character, so a 200k-character unterminated literal cannot trigger
combinatorial backtracking. Regression test adapted from PR cncf#1024.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Killen <bkillen@linuxfoundation.org>
@mrbobbytables

Copy link
Copy Markdown
Member

Superseded by #1025, which applies the same quote-aware DOCTYPE_PATTERN and fail-closed validateSvg() guard, and additionally gates the two publishing paths this PR's description notes have no DOCTYPE check of their own: mirrorLandscapeLogo() and sanitizeArchitectureAssets(). An unclosed subset without an <!ENTITY declaration passes the entity check, so those sites need their own hasDoctype() gate. #1025 also carries this PR's linear-time regression test. Thanks — the analysis in #1023 and here was spot on.

mrbobbytables added a commit to mrbobbytables/endusers that referenced this pull request Oct 3, 2026
…cf#1025)

* fix(security): make DOCTYPE stripping quote-aware and fail closed

stripDoctype() read a bare "[" as the start of a DOCTYPE's internal
subset, but XML's doctypedecl grammar allows "[", "]" and ">" inside
an ExternalID's quoted literals. A system identifier containing "["
therefore either matched nothing -- the declaration survived while
validateSvg() reported "removed DOCTYPE" -- or, when an unrelated
"]>" appeared later (every CDATA close supplies one), deleted all
markup in between. Both outcomes passed the post-strip rescans because
the result is inert, so the sanitizer certified bytes it never cleaned.

Make DOCTYPE_PATTERN consume quoted literals through their closing
mate before giving the structural characters any meaning. Branches are
disjoint on their first character, so matching stays linear. A
declaration whose subset or literal never closes still matches nothing
and is left in place.

That leftover is now a hard stop at every publishing call site instead
of an assumed success: validateSvg() records an error rather than
reporting a removal that did not happen (covering the quality:false
early-return write, which has no rescan), mirrorLandscapeLogo()
refuses the logo, and sanitizeArchitectureAssets() fails the import.

Closes cncf#1023

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Killen <bkillen@linuxfoundation.org>

* test: pin linear-time matching of an unterminated DOCTYPE literal

The alternation branches of DOCTYPE_PATTERN are disjoint on their first
character, so a 200k-character unterminated literal cannot trigger
combinatorial backtracking. Regression test adapted from PR cncf#1024.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Killen <bkillen@linuxfoundation.org>

---------

Signed-off-by: Bob Killen <bkillen@linuxfoundation.org>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mrbobbytables
mrbobbytables deleted the sec/fix-doctype-external-id branch October 7, 2026 15:20
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] SVG DOCTYPE strip fails open on an external identifier containing "[", leaving it in place or deleting live markup

1 participant