Skip to content

fix: apply the data: URI allowlist to media tags too - #717

Open
dealerweb wants to merge 1 commit into
bulwarkmail:mainfrom
dealerweb:fix/data-uri-allowlist-media-tags
Open

fix: apply the data: URI allowlist to media tags too#717
dealerweb wants to merge 1 commit into
bulwarkmail:mainfrom
dealerweb:fix/data-uri-allowlist-media-tags

Conversation

@dealerweb

@dealerweb dealerweb commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

ALLOWED_URI_REGEXP excludes image/svg+xml on purpose, with the reason spelled out in the config: DOMPurify cannot inspect the bytes inside a data: URI, so an SVG payload can carry <script>/<foreignObject> the surrounding sanitizer never sees. That exclusion never took effect on <img>, <video>, <audio>, <source> and <track> - any data: URI reached them, including image/svg+xml and text/html, while <a href> behaved exactly as documented. Not a live script vector (browsers render SVG-in-<img> in secure static mode), but the sanitizer promised something it did not enforce, and handlePrint writes sanitized bodies into a window.open document with neither the iframe sandbox nor the strict CSP that back up the normal viewer.

Root cause

DOMPurify's attribute check short-circuits on DATA_URI_TAGS before ALLOWED_URI_REGEXP is consulted:

} else if (regExpTest(IS_ALLOWED_URI, ...)) ;
  else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href')
           && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0
           && DATA_URI_TAGS[lcTag]) ;

DATA_URI_TAGS defaults to audio, video, img, source, image, track and resolves via _resolveSetOption(cfg, 'ADD_DATA_URI_TAGS', DEFAULT_DATA_URI_TAGS, { base: DEFAULT_DATA_URI_TAGS }) - it can only be extended, never trimmed. The config cannot express the restriction, so a hook is the only way to re-apply it.

Changes

  • restrictDataUriResourcesOnNode drops data: URIs that are not inline raster images from the affected tags, checking src/href/xlink:href and normalising C0 controls first (the same treatment isExternalResourceUrl already applies). <image> needs no case of its own - the HTML parser rewrites it to <img>.
  • Wired in through the existing afterSanitizeAttributes hooks in the viewer and the thread view, next to applyNewTabToAnchor; sanitizeEmailHtml/sanitizeEmailHtmlForIframe apply it through the addHook/try/finally idiom already used by the signature sanitizers.
  • TRANSPARENT_BLOCKED_PIXEL becomes a 1x1 GIF instead of a 1x1 SVG. It is assigned inside the same hook pass, so an SVG placeholder would be stripped or kept depending on hook order; a raster placeholder removes that coupling. The constant is compared by identity in the existing tests, so nothing else changes.
  • The three viewer call sites that hand-rolled DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG) (plugin-rendered, TNEF, embedded message/rfc822) now call sanitizeEmailHtmlForIframe, which is the same call and picks up the guard automatically.
  • Regression tests in lib/__tests__/email-sanitization.test.ts, eight of which fail against the previous code: every blocked type (svg base64, svg inline, text/html, application/javascript, leading space, control chars in the scheme) through both email sanitizers; the other affected tags (video, audio, source, track, <image href>); the sources that must survive (data:image/png|gif|jpeg, cid:, https:, blob:); <a href> staying with ALLOWED_URI_REGEXP; and the blocked-image placeholder surviving blockExternalResourcesOnNode plus the new restriction in the same pass.

Related issues

None - found while reviewing Bulwark against the OWA half-click XSS (CVE-2026-42897 / TA488).

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactor / code quality improvement
  • Chore / dependency update / CI change

Checklist

  • I have read the Contributing Guide
  • My code follows the project's code style and conventions
  • I have run npm run typecheck && npm run lint and there are no errors
  • The build passes (npm run build)
  • I have tested my changes locally
  • I have added or updated documentation if needed
  • I have updated translations (locales/) if my changes affect user-facing text
  • I have included screenshots or a screen recording for UI changes

Screenshots / demo

No UI changes - sanitizer logic only, nothing visible to show. No user-facing strings changed either, so locales/ is untouched.

Notes for reviewers

  • Verification run: npm run typecheck clean, npm run lint 0 errors (8 pre-existing warnings, all in untouched calendar files), npx vitest run 2341/2341, npm run build succeeds, npm run test:translations 48/48. npm run test:integration was not run - no Docker stack on this machine - so the Playwright pass over the render path is still owed if you want it.
  • The sweep that found this also threw mXSS namespace-confusion payloads and event handlers absent from FORBID_ATTR (onbeforetoggle, onanimationstart, onpointerover, ...) at the sanitizers; all were neutralised, since DOMPurify's allowlist is what does the work there. The data: gap was the one thing that came back.
  • sanitizeWithDataUriGuard is extracted rather than inlined twice, unlike the signature pair - there the two hooks genuinely differ, here they are identical and the shared helper keeps them in sync. Happy to inline it if you prefer.
  • sanitizePlainTextRenderedHtml, sanitizeI18nHtml and the signature sanitizers are deliberately untouched: the first two use strict ALLOWED_TAGS lists with no media elements, and signatures already restrict img src through restrictSignatureImages.
  • Unrelated to this PR, in case you hit it locally: jmap-client-resilience's keep-alive tests fail intermittently (fake timers with shouldAdvanceTime, which the file itself flags). Reproduced on unmodified main, roughly one run in three.

@dealerweb dealerweb changed the title Fix: apply the data: URI allowlist to media tags too fix: apply the data: URI allowlist to media tags too Jul 31, 2026
ALLOWED_URI_REGEXP excludes image/svg+xml on purpose - DOMPurify cannot
inspect the bytes inside a data: URI, so an SVG payload can carry
<script>/<foreignObject> the surrounding sanitizer never sees. That
exclusion never took effect on <img>, <video>, <audio>, <source> and
<track>: DOMPurify's attribute check short-circuits on DATA_URI_TAGS
before ALLOWED_URI_REGEXP is consulted, and that set can only be extended
via ADD_DATA_URI_TAGS, never trimmed. Any data: URI reached those tags,
including image/svg+xml and text/html; <a href> behaved as documented.

Not a live script vector - browsers render SVG-in-<img> in secure static
mode - but the sanitizer promised something it did not enforce, and the
print window renders sanitized bodies without the iframe sandbox and CSP
that otherwise back it up.

restrictDataUriResourcesOnNode re-applies the raster allowlist on those
tags, wired in through the existing afterSanitizeAttributes hooks. The
blocked-external-image placeholder becomes a 1x1 GIF: it is set in the
same hook pass, so an SVG placeholder would depend on hook order.

The three viewer call sites that hand-rolled
DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG) now use
sanitizeEmailHtmlForIframe, which is the same thing and picks up the
guard automatically.
@dealerweb
dealerweb force-pushed the fix/data-uri-allowlist-media-tags branch from f653165 to f4b21c8 Compare July 31, 2026 11:47
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.

1 participant