Skip to content

fix: switch SVG and MDX active-content gates to allowlists - #1730

Merged
mrbobbytables merged 2 commits into
mainfrom
sec/allowlist-active-content-gates
Oct 21, 2026
Merged

mrbobbytables merged 2 commits into
mainfrom
sec/allowlist-active-content-gates

Conversation

@mrbobbytables

Copy link
Copy Markdown
Member

Summary

Part of #1718 (item 3), part 1: the active-content gates become allowlists.

This eliminates the recurring Hive finding class "a new name, scheme or
syntax slips past the SVG/MDX denylist" (#1515, #1672, #1695, #1716 and the
fixing PRs #1516, #1673, #1696, #1713). Each of those was a construct the
denylist had not named yet. Under an allowlist the default answer for an
unnamed construct is "reject", so the next such finding becomes a request to
widen a list rather than a bypass.

Threat-model change

Before: the gates enumerated dangerous constructs (script, event handlers,
javascript:/vbscript:/script-capable data:, animation elements, a
growing set of URL-bearing attributes) and published everything else. Safety
depended on the list being complete for every current and future browser.

After: the gates enumerate what architecture diagrams and imported pages
actually use, and everything else fails:

  • SVG (scripts/lib/svg-active-content.mjs): every element, attribute,
    namespace declaration and URL (attribute values, url() in style
    attributes and <style> text) is checked against the lists below. On
    import, stripActiveContent removes anything off-list and then re-scans;
    any residue fails the import with the existing reportAndExit /
    Could not strip / Active content survived contract. The
    asset validator reports off-list content as errors (--fix strips).
    Namespace declarations are themselves allowlisted, so nothing can be
    moved into MathML, XSLT, XML Events or an unknown namespace. A malformed
    CSS url(...) token is rejected rather than skipped.
  • MDX (scripts/lib/mdx-active-content.mjs, mdx-escape.mjs,
    architecture-content.mjs): every mdast node type, JSX element, JSX
    attribute and URL scheme is checked. A node type a future parser or
    extension introduces is reported as disallowed node type X; any MDX
    expression or non-literal attribute expression stays rejected.
  • Schemes (scripts/lib/uri-safety.mjs): activeScheme (denylist) is
    replaced by disallowedScheme (allowlist).

The remote-subresource gate (findRemoteReferences) no longer checks
src/srcset/action/formaction itself: those attributes are off the
allowlist, and both gates always run together in every caller (importer,
asset validator, profile/asset mirroring).

Allowlists

SVG elements in the SVG namespace (40): a, circle, clipPath, defs, desc, ellipse, feBlend, feColorMatrix, feComposite, feDropShadow, feFlood, feGaussianBlur, feMerge, feMergeNode, feOffset, filter, foreignObject, g, image, line, linearGradient, marker, mask, metadata, path, pattern, polygon, polyline, radialGradient, rect, stop, style, svg, switch, symbol, text, textPath, title, tspan, use.

SVG attributes, unprefixed (133): alignment-baseline, aria-describedby, aria-hidden, aria-label, aria-labelledby, baseProfile, baseline-shift, class, clip, clip-path, clip-rule, clipPathUnits, color, color-interpolation, color-interpolation-filters, color-rendering, cx, cy, d, direction, display, dominant-baseline, dx, dy, enable-background, fill, fill-opacity, fill-rule, filter, filterUnits, flood-color, flood-opacity, font-family, font-size, font-size-adjust, font-stretch, font-style, font-variant, font-weight, fr, fx, fy, gradientTransform, gradientUnits, height, href, id, image-rendering, in, in2, isolation, k1, k2, k3, k4, lang, lengthAdjust, letter-spacing, lighting-color, marker-end, marker-mid, marker-start, markerHeight, markerUnits, markerWidth, mask, maskContentUnits, maskUnits, mix-blend-mode, mode, offset, opacity, operator, orient, overflow, paint-order, pathLength, patternContentUnits, patternTransform, patternUnits, pointer-events, points, preserveAspectRatio, primitiveUnits, r, refX, refY, requiredExtensions, requiredFeatures, result, role, rotate, rx, ry, shape-rendering, spreadMethod, startOffset, stdDeviation, stop-color, stop-opacity, stroke, stroke-dasharray, stroke-dashoffset, stroke-linecap, stroke-linejoin, stroke-miterlimit, stroke-opacity, stroke-width, style, systemLanguage, target, text-anchor, text-decoration, text-rendering, textLength, transform, transform-origin, type, unicode-bidi, values, vector-effect, version, viewBox, visibility, width, word-spacing, writing-mode, x, x1, x2, y, y1, y2.

Root <svg> only: content (draw.io stores its mxfile there;
the validator warns and strips it under --fix).

Any element: data-* attributes matching /^data-[a-z0-9._-]+$/ (author
storage with no platform behaviour; Illustrator data-name, draw.io
data-cell-id).

XHTML elements inside <foreignObject> (21): a, b, br, code, div, em, font, hr, i, li, ol, p, pre, s, span, strike, strong, sub, sup, u, ul.

XHTML attributes (12): align, class, color, dir, face, href, id, lang, size, style, target, title.

xlink: attributes: href, title. xml: attributes: lang, space.

Namespaces that may be declared: SVG, XHTML, XLink, XML, and these editor
metadata namespaces (9), whose elements and prefixed attributes are
allowed because no browser gives them behaviour: http://creativecommons.org/ns#, http://ns.adobe.com/AdobeIllustrator/10.0/, http://ns.adobe.com/Extensibility/1.0/, http://ns.adobe.com/Graphs/1.0/, http://ns.adobe.com/SaveForWeb/1.0/, http://purl.org/dc/elements/1.1/, http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd, http://www.inkscape.org/namespaces/inkscape, http://www.w3.org/1999/02/22-rdf-syntax-ns#.

Unprefixed attributes on editor metadata elements (16): bleed, bordercolor, borderopacity, bottomLeftOrigin, gridtolerance, guidetolerance, height, id, margin, objecttolerance, pagecolor, showgrid, units, width, x, y.

SVG URL values (href, xlink:href, url()): relative, fragment-only,
http, https, mailto, and data: with a raster type image/avif,
image/bmp, image/gif, image/jpeg, image/png, image/webp. (Remote
http(s) subresources are still rejected separately by the remote gate.)

MDX node types (24 Markdown): blockquote, break, code, definition, delete, emphasis, footnoteDefinition, footnoteReference, heading, image, imageReference, inlineCode, link, linkReference, list, listItem, paragraph, root, strong, table, tableCell, tableRow, text, thematicBreak; plus mdxJsxFlowElement, mdxJsxTextElement, mdxjsEsm
(mdxjsEsm only as the exact generated
import CNCFProjectCard from '@site/src/components/CNCFProjectCard';).

MDX inline elements, with no attributes (13): b, br, code, em, hr, i, kbd, p, small, strong, sub, sup, u.

MDX component: CNCFProjectCard with description, href, logo, name, since, version, values
string literals only; href and logo are URL-checked and logo must
not be remote.

MDX URL schemes (links, images, definitions, href/logo): relative,
fragment, http, https, mailto. Remote images are rejected separately.

Basis: the vocabulary measured from the tracked SVGs (172 architecture assets
plus logos), all of which pass unchanged, plus closely related presentational
names editors emit for the same drawings (for example feDropShadow,
textPath, switch, strike, font), as CONTENT-SECURITY.md states.

Behaviour changes worth reviewing

  • An SVG without xmlns (or HTML inside foreignObject without the XHTML
    namespace) is now rejected: its elements are in no allowlisted namespace.
    No tracked asset is affected; three test fixtures gained an xmlns.
  • MDX inline elements accept no attributes (previously anything not on a
    denylist), and data: images in MDX are rejected (the importer already
    reduced them to alt text).
  • The importer now reduces an image with any off-list scheme (for example
    ftp:) to alt text instead of demoting it to a link.
  • findActiveContent (MDX) gains an optional { mdastExtensions }
    argument so the unknown-node-type path is exercised with a real parse.

Tests

  • New tests/active-content-allowlist.test.mjs (fast-check, seed 1718, 300
    runs per property, ~0.7 s): SVG and MDX trees with arbitrary element,
    attribute, namespace and scheme names. An independent sax/mdast walk
    asserts that no output of stripActiveContent, no body the MDX gate
    accepts, and no escapeMdx output contains anything off-list. The four
    historical bypasses (rescan after strip, first-definition-wins,
    mdx-code-block, action/formaction) are table rows in the same
    file. I mutation-checked it by hand: dropping the element or attribute
    allowlist check, the malformed-url() check, the data-* pattern,
    the JSX attribute check, the literal-expression check or either MDX
    scheme check makes a property fail.
  • Retired denylist tests that the property and tables now cover:
    per-construct strip loops and "every construct at once", per-scheme and
    per-attribute rejection tests (event handlers, src/srcset/poster/
    action/formaction remote tests, entity and data-type tests), the
    activeScheme tests and the denylist contract in
    content-security-contract.test.mjs (now binds each documented allowlist
    to the exported set, with a probe per entry).
  • Net test lines: +895 / -1237 = -342.

Verification

  • npm run test:unit:coverage:check: 2981 pass; source lines and regions
    100% (the changed scripts/lib modules at 100%/100%).
  • npm run validate:architectures (8 records) and
    npm run validate:architecture-assets (172 assets; the one existing
    foreignObject warning) pass.
  • npm run build passes.
  • check:format, check:spelling, check:markdown, check:duplication
    (255 clones; CLONE_CEILING lowered from 264 to 255) and check:audit
    pass.

Out of scope: CSP / Permissions-Policy headers (item 3, part 2) and a CSS
property allowlist (CSS values are scanned for url() schemes only; noted
under residual risks in CONTENT-SECURITY.md).

Checklist

  • Content speaks to end users (see
    CONTRIBUTING.md)
  • Commits are DCO-signed (git commit -s)
  • Site builds without new warnings

@netlify

netlify Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for endusers ready!

Name Link
🔨 Latest commit ba4148e
🔍 Latest deploy log https://app.netlify.com/projects/endusers/deploys/6ad83b1487cdd184295f8eaf
😎 Deploy Preview https://deploy-preview-1730--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.

Comment thread tests/active-content-allowlist.test.mjs Fixed
@hivecommons-hive

Copy link
Copy Markdown
Contributor
2cc7f42

ℹ️ Sentinel notice — informational; author is trusted (owner), merge is not blocked

Hive flagged this PR (author @mrbobbytables, head 2cc7f4247cd4) because it matches behaviors that can override security controls, escalate privileges or damage the codebase. This is a heuristic, not an accusation — a maintainer should confirm the change is intended before it merges.

  • sensitive_path — changes 3 sensitive path(s) (Touches a sensitive path (OWNERS, workflows, policies, hive config, security docs, …))
    • SECURITY.md
    • package-lock.json
    • package.json
  • security_policy_edit — edits security policy, scanner config, rulesets or hooks (Edits SECURITY.md, dependabot/CodeQL config, rulesets or git hooks)
    • SECURITY.md

Hive did not add the sentinel-alert label because this author is trusted. The finding remains recorded for audit and the dashboard Security tab; set sentinel.trusted_authors_block: true to require the blocking label for trusted authors too.

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Heads-up: this rewrite still misses the cross-document mdx-code-block fence-pairing bypass filed as #1732 (opened after this PR). I reproduced it against this branch at 6ba02dc.

Reproduction

A page carrying:

```mdx-code-block
<img src="https://beacon.invalid/px.png" />
````

```

returns [] from findActiveContent() on this branch, while findActiveContent(unwrapMdxCodeBlocks(body)) reports:

[ { line: 3, reason: 'disallowed element <img>', snippet: '<img src="https://beacon.invalid/px.png" />' } ]

Why the allowlist does not catch it

The gate keeps the per-node check node.type === 'code' && isUnwrappedCodeBlock(text, node). The two fence grammars disagree about which closing fence pairs with the opener:

  • CommonMark closes a three-backtick fence with any run of three or more backticks, so mdast ends the code node at the four-backtick line and the <img> sits inside the code node, invisible to the JSX/element allowlist.
  • Docusaurus's regexp3 requires a closer of exactly three backticks and pairs the opener with the later bare ``` line, so at publish time the <img> is unwrapped and compiled as live MDX.

isUnwrappedCodeBlock() slices the node's own range, the slice does not unwrap, and the payload is never scanned as MDX. This is independent of the denylist→allowlist switch: the node containing the payload never reaches the element checks.

Suggested fix

Give the publish gate the whole-document pass the import-time escaper already has (scripts/lib/mdx-escape.mjs:187-188): scan unwrapMdxCodeBlocks(text) in addition to text and merge the findings. unwrapMdxCodeBlocks() only deletes fence lines, so the unwrapped lines are a strict subsequence of the source's — a greedy two-pointer walk maps each unwrapped-pass finding back to its true source line exactly. Keep the per-node check too, since it reports the fence at its own line for the common case.

Since this PR is mid-rewrite of exactly that function, folding the second pass in here is likely cheaper than a follow-up that has to rebase over it. Details and completion criteria are in #1732.


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

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

@hivecommons-hive

Copy link
Copy Markdown
Contributor
6ba02dc

ℹ️ Sentinel notice — informational; author is trusted (owner), merge is not blocked

Hive flagged this PR (author @mrbobbytables, head 6ba02dcc7d2e) because it matches behaviors that can override security controls, escalate privileges or damage the codebase. This is a heuristic, not an accusation — a maintainer should confirm the change is intended before it merges.

  • sensitive_path — changes 3 sensitive path(s) (Touches a sensitive path (OWNERS, workflows, policies, hive config, security docs, …))
    • SECURITY.md
    • package-lock.json
    • package.json
  • security_policy_edit — edits security policy, scanner config, rulesets or hooks (Edits SECURITY.md, dependabot/CodeQL config, rulesets or git hooks)
    • SECURITY.md

Hive did not add the sentinel-alert label because this author is trusted. The finding remains recorded for audit and the dashboard Security tab; set sentinel.trusted_authors_block: true to require the blocking label for trusted authors too.

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Conclusion: the mechanics check out in everything I read, and I found no defect — but this is a security-posture change that touches SECURITY.md, so a maintainer should sign off rather than an agent.

What I verified against the branch (head 6ba02dc), reading the tree rather than just the diff:

  • scripts/lib/uri-safety.mjs: disallowedScheme() reads the scheme of the normalized value (/^([a-z][a-z0-9+.-]*):/) against ALLOWED_SCHEMES = {http, https, mailto}; data: is admitted only for RASTER_DATA_TYPES and only when the caller opts in. Since normalizeUri can only remove characters a browser keeps, mis-normalization over-rejects rather than misses — fail-closed holds.
  • scripts/lib/svg-active-content.mjs attributeAllowed(): namespace declarations themselves are gated (if (uri === XMLNS_NS) return NAMESPACES.has(value)), so content cannot be rebound into MathML/XSLT/XML-Events or a future namespace. Unbound prefixes are well-formedness errors under sax.parser(true, { xmlns: true }).
  • classifyTag() checks href values directly and runs cssUrls() over every attribute value (presentation attributes take url()); <style> blocks go through disallowedStyles(); a malformed url() token is itself rejected ("not vouched for"). The strip-then-rescan contract in the importer is preserved.
  • All checks green at this head.

The maintainer question (one sentence): do you accept the allowlist breadth — e.g. the nine metadata namespaces allowed wholesale (descendants still checked), target on <a>, data: raster images — and the new SECURITY.md sentence that puts "a way to publish anything outside those lists" explicitly in scope? That sentence changes what reporters are invited to probe, which is a policy decision, not a code one.

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

mrbobbytables and others added 2 commits October 20, 2026 23:09
The SVG and MDX gates rejected known-dangerous names and schemes, so every
name or scheme nobody had thought of was published. Invert the model:
anything not explicitly allowed is rejected on import or stripped and then
fails the rescan.

- SVG: allowlists of elements, attributes, namespaces and URL schemes,
  measured from the architecture diagrams in the repo.
- MDX: allowlists of mdast node types, inline elements, the one component
  and its attributes, and URL schemes (http, https, mailto, relative,
  fragment).
- A fast-check property test with a fixed seed and run count, plus
  table-driven rows for the four historical denylist bypasses, replaces
  the per-bypass denylist tests.
- CONTENT-SECURITY.md lists every allowlist; SECURITY.md points to it.

Part of #1718 (item 3).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Killen <bkillen@linuxfoundation.org>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Killen <bkillen@linuxfoundation.org>
@mrbobbytables
mrbobbytables force-pushed the sec/allowlist-active-content-gates branch from 6ba02dc to ba4148e Compare October 21, 2026 04:09
@mrbobbytables
mrbobbytables merged commit fca8dfa into main Oct 21, 2026
11 of 12 checks passed
@mrbobbytables
mrbobbytables deleted the sec/allowlist-active-content-gates branch October 21, 2026 04:17
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.

2 participants