Skip to content

hackweek: Trusted Types - #122362

Draft
oioki wants to merge 14 commits into
masterfrom
hackweek/trusted-types
Draft

hackweek: Trusted Types#122362
oioki wants to merge 14 commits into
masterfrom
hackweek/trusted-types

Conversation

@oioki

@oioki oioki commented Aug 20, 2026

Copy link
Copy Markdown
Member

Hackweek spike: everything below, on one branch, to see it working end to end. Not for merge — the pieces ship as the PRs listed here.

Notion: Hackweek: Trusted Types

Already split out

#122318 csp help → JSX
#122319 org loading indicator
#122322 chart tooltip marker
#122323 partner support note
#122347 printable recovery codes
#122348 demo analytics shim
#122349 replay placeholder comments
#122355 drop id from the sanitizer allowlist
#122356 email preview iframe
#122358 report-only header
zrender#1167 upstream textContent fix

Next

  1. Frontend policies + zrender patch (commits 11–12) as PRs
  2. Enable collection in dev, triage the stream
  3. Lint rule
  4. Remaining sinks: Prism / CodeBlock, ECharts tooltip, rrweb-player, showReportDialog, replay DOMParser
  5. Decide the cross-origin demo analytics script — allowlist or leave unrouted
  6. Enforce

oioki and others added 14 commits August 20, 2026 20:20
The CSP effective-directive help strings embedded literal <code> tags and
escaped angle brackets, then rendered them with dangerouslySetInnerHTML.
Translators had to carry markup, and a missing slash in the worker-src
entry (<code>Worker<code>) produced nested unbalanced tags.

Use tctCode() with [code:...] groups so the markup is real JSX and the
translated strings hold only text. No visual change.

Co-Authored-By: Claude <noreply@anthropic.com>
OrganizationLoadingIndicator read the React root's innerHTML and re-injected
it, to reuse the loader the Django view rendered and avoid a layout shift on
boot. Two problems followed from copying rather than moving it.

The non-null assertion on `document.getElementById(ROOT_ELEMENT)` throws
whenever that element is absent, which is the case in jsdom. That is why the
component had no test coverage at all.

Reading the root on every render also means a later render copies whatever is
there at that moment. An org switch resets the store back to loading, and by
then the root holds the rendered app, so the loading state rendered a frozen
copy of the previous page instead of the loader.

Capture the loader node at bootstrap and re-parent it with appendChild, which
moves the node rather than cloning it. It is the same node, so there is
nothing for React to reconcile, and re-mounting picks it back up. When no
loader is present the component now renders an empty div instead of throwing.

Co-Authored-By: Claude <noreply@anthropic.com>
The categorical series tooltip took ECharts' `param.marker`, an HTML string
holding a styled empty span, ran it through DOMPurify and injected it with
dangerouslySetInnerHTML. The tooltip is already rendered as React, so the
marker can just be a styled component driven by `param.color`.

Same markup as ECharts generates: inline-block, 10px circle, 4px right
margin, series colour.

Co-Authored-By: Claude <noreply@anthropic.com>
printCodes streamed the codes into the hidden print iframe with
document.write, which needed a matching document.close() and produced a
body with no structure.

Append text nodes and <br> elements instead. Same rendered output, and
the document.close() call is no longer necessary.

Co-Authored-By: Claude <noreply@anthropic.com>
Writing to a script element's textContent is a Trusted Types injection
sink, so it is rejected on pages that set
`require-trusted-types-for 'script'`. Appending a text node is not, and
is otherwise equivalent.

Co-Authored-By: Claude <noreply@anthropic.com>
removeChildLevel replaced pruned subtrees by assigning comment markup to
innerHTML, which parses a string to build a single comment node.
createComment does it directly.

Assigning to innerHTML is also a Trusted Types injection sink, so this
path is rejected on pages that set `require-trusted-types-for 'script'`.

Co-Authored-By: Claude <noreply@anthropic.com>
Nothing in the markdown pipeline emits `id` — marked has not added header
ids by default since v12, and no renderer here sets one. It only survived
on raw inline HTML in user-authored markdown.

An attacker-chosen `id` is the DOM clobbering primitive: it shadows
`window.x` and `document.x` for scripts that read globals without
guarding. No gadget was found in the app, so this is hardening rather
than a fix, but the attribute buys nothing.

`align` is kept — markdown table syntax does generate it.

Co-Authored-By: Claude <noreply@anthropic.com>
html_content is a whole HTML document. Splicing it into a div discarded
everything outside <body>, including the viewport meta the email's own
media query depends on, while the one <style> the inliner leaves behind
applied to the debug page itself and pulled in a webfont.

The reverse leaked too: the email uses generic class names like .btn and
.container, which Sentry's own stylesheets also define, so app CSS filled
in wherever the email had not set an inline style. The preview was showing
something no mail client would render.

Render it in an iframe instead, sanitized with WHOLE_DOCUMENT so the shell
survives. The frame is sandboxed without allow-scripts, so the document is
inert; allow-same-origin is only there to read scrollHeight for sizing.

Also corrects html_content's type. It was declared TrustedHTML, but the
endpoint serializes a plain string.

Co-Authored-By: Claude <noreply@anthropic.com>
The partner support note is server-supplied HTML rendered straight through
dangerouslySetInnerHTML with no sanitizer. Today every value is a hardcoded
literal in the partnership config, so nothing untrusted reaches it, but the
config documents the field as "plain HTML" and nothing enforces that.

Run it through DOMPurify. `target` is added to the allowlist because the note
links out to the partner's own support site and the default allowlist drops
it; without that the link would silently stop opening in a new tab.

Also corrects the comment above it, which claimed markdown cannot add
attributes to links. That is only true of the HTML-string pipeline in
utils/marked; the React <Markdown> renderer routes external links through
ExternalLink, which sets target and rel.

Adds a spec, which this component did not have.

Co-Authored-By: Claude <noreply@anthropic.com>
Trusted Types is delivered on its own Content-Security-Policy-Report-Only
header rather than through the CSP_* settings. Browsers evaluate each
delivered policy independently, so this collects violations without
touching the CSP we enforce — the alternative, flipping the real CSP to
report-only for the rollout window, would drop XSS protection in
production.

Default off, and no policy is emitted unless TRUSTED_TYPES_ENABLED is set,
so this change is inert until someone opts in.

Requires CSP_REPORT_ONLY = False. This middleware runs before
CSPMiddleware on the response path and django-csp bails when its header
name is taken, so claiming that name while the CSP is itself report-only
would drop the whole CSP. The middleware stands down instead, and logs
once so that enabling the setting and seeing nothing is diagnosable.

Co-Authored-By: Claude <noreply@anthropic.com>
Every trusted value in the app now comes from exactly one named policy, and
each policy sanitizes or validates rather than passing its input through:

- `dompurify` — created by DOMPurify when sanitizing with
  RETURN_TRUSTED_TYPE. Covers markdown and the bulk of untrusted HTML.
  Warmed at boot so a missing allowlist entry fails on startup rather than
  mid-render.
- `sentry-script-url` — validates same-origin and throws otherwise. Covers
  serviceWorker.register().
- `sentry-bundler` — Rspack's own policy for chunk loading, with
  onPolicyCreationFailure: 'continue' so an unallowlisted deploy degrades
  instead of white-screening.

Requires the report-only header from the previous commit to have any effect.

Co-Authored-By: Claude <noreply@anthropic.com>
zrender writes to innerHTML in seven places, all of them clearing a
container. Assigning to innerHTML is a Trusted Types injection sink, so the
browser rejects it even when the value is an empty string — which makes
zrender, and therefore ECharts, unusable under enforcement.

Mirrors the upstream PR (ecomfe/zrender#1167). Drop this patch once that
lands and ECharts picks up a release containing it.

Co-Authored-By: Claude <noreply@anthropic.com>
Kept separate from the header plumbing so the two can ship independently:
the plumbing is inert and safe to land any time, while enabling collection
is only useful once the policies exist and only wanted once the violation
stream is worth reading.

Also needs CSP_REPORT_ONLY = False locally.

Co-Authored-By: Claude <noreply@anthropic.com>
Catches new sinks in review rather than in a production violation report,
and covers code nobody has written yet — which neither grep nor the
report stream can do.

It allowlists sanitizer calls and resolves `const x = sanitizeHtml(...)`
through scope, so the twelve correct DOMPurify sites need no annotation.
Without that it would be a churn generator rather than a guardrail.

Seven sites are annotated: three that are provably safe but not statically
provable, and four real TODOs (the Prism token replacement, the ECharts
tooltip path, the cross-origin demo analytics script, and replay HTML
parsing).

Note the rule cannot police node_modules, which is where most sinks turned
out to live. It complements the browser-level control rather than
replacing it.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added Scope: Frontend Automatically applied to PRs that change frontend components Scope: Backend Automatically applied to PRs that change backend components labels Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🚨 Warning: This pull request contains Frontend and Backend changes!

It's discouraged to make changes to Sentry's Frontend and Backend in a single pull request. The Frontend and Backend are not atomically deployed. If the changes are interdependent of each other, they must be separated into two pull requests and be made forward or backwards compatible, such that the Backend or Frontend can be safely deployed independently.

Have questions? Please ask in the #discuss-dev-infra channel.

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

📊 Type Coverage Diff

Metric Before After Delta
Coverage 94.35% 94.34% 🔴 -0.01%
Typed 134,147 134,204 🟢 +57
Untyped 8,040 8,046 🔴 +6
🔍 5 new type safety issues introduced

any-typed symbols (1 new)

File Line Detail
static/app/views/settings/account/accountSecurity/components/recoveryCodes.tsx 34 doc (var)

Non-null assertions (!) (2 new)

File Line Detail
static/app/utils/trustedTypes.ts 52 window.trustedTypes!
static/eslint/eslintPluginSentry/noTrustedTypesSinks.ts 74 variable.defs[0]!

Type assertions (as) (2 new)

File Line Detail
static/app/serviceWorker/client/serviceWorkerContext.tsx 24 as string(getSentryScriptUrlPolicy()?.createScriptURL(url) ?? url) as unknown as string
static/app/serviceWorker/client/serviceWorkerContext.tsx 24 as unknown(getSentryScriptUrlPolicy()?.createScriptURL(url) ?? url) as unknown

This is informational only and does not block the PR.

_warned_about_report_only_csp = True
logger.warning(
"trusted_types.disabled_by_report_only_csp",
extra={"header": TRUSTED_TYPES_HEADER},
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants