From ba2d12c316898963abc4b6b21c313caf240345d6 Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Fri, 25 Sep 2026 13:56:01 +0200 Subject: [PATCH] feat(browser): Add a Trusted Types policy for scripts the SDK loads showReportDialog assigned a plain string to script.src, which pages that enforce Trusted Types reject. Add a shared getTrustedScriptURL helper in browser-utils that mints URLs through a single "sentry-sdk" policy, and use it for the report dialog. One SDK-wide policy means users allowlist one name, and the other SDK script sinks (the replay compression worker, lazy-loaded integrations) can move onto it later. The policy only accepts the kinds of URL the SDK actually loads, currently the error-page embed endpoint. Without Trusted Types, or if the policy can't be created, the raw URL is used as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T35on2nNWQAfBWfSA9eBp1 --- packages/browser-utils/src/index.ts | 2 + packages/browser-utils/src/trustedTypes.ts | 76 +++++++++++++++ .../browser-utils/test/trustedTypes.test.ts | 95 +++++++++++++++++++ packages/browser/src/report-dialog.ts | 3 +- packages/browser/test/report-dialog.test.ts | 50 ++++++++++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 packages/browser-utils/src/trustedTypes.ts create mode 100644 packages/browser-utils/test/trustedTypes.test.ts create mode 100644 packages/browser/test/report-dialog.test.ts diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 7c3b08c40e3b..00e8ff3c36a1 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -57,6 +57,8 @@ export { isElement } from './is'; export { getAbsoluteUrl } from './instrumentation/location'; +export { getTrustedScriptURL, TRUSTED_TYPES_POLICY_NAME } from './trustedTypes'; + export type { FetchHint, HandlerDataDom, diff --git a/packages/browser-utils/src/trustedTypes.ts b/packages/browser-utils/src/trustedTypes.ts new file mode 100644 index 000000000000..f2b9c11da767 --- /dev/null +++ b/packages/browser-utils/src/trustedTypes.ts @@ -0,0 +1,76 @@ +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import { WINDOW } from './types'; + +/** + * The single Trusted Types policy the SDK mints values through. Pages that enforce + * Trusted Types add this name to their `trusted-types` CSP directive. + */ +export const TRUSTED_TYPES_POLICY_NAME = 'sentry-sdk'; + +interface ScriptURLPolicy { + createScriptURL(input: string): unknown; +} + +interface TrustedTypesWindow { + trustedTypes?: { + createPolicy(name: string, rules: { createScriptURL(input: string): string }): ScriptURLPolicy; + }; +} + +// Script URLs the SDK loads. A kind is added together with the code that loads it, so +// the policy can never be used to load an arbitrary script. +function isSdkScriptURL(input: string): boolean { + const url = new URL(input); + const isHttp = url.protocol === 'https:' || url.protocol === 'http:'; + // The report dialog, loaded from the DSN host by `showReportDialog`. + return isHttp && url.pathname.endsWith('/api/embed/error-page/'); +} + +// `undefined` until the first attempt, `null` if Trusted Types is unavailable or the +// policy could not be created. +let policy: ScriptURLPolicy | null | undefined; + +function getPolicy(): ScriptURLPolicy | null { + if (policy !== undefined) { + return policy; + } + + policy = null; + const trustedTypes = (WINDOW as TrustedTypesWindow).trustedTypes; + if (!trustedTypes) { + return policy; + } + + try { + policy = trustedTypes.createPolicy(TRUSTED_TYPES_POLICY_NAME, { + createScriptURL: (input: string) => { + if (!isSdkScriptURL(input)) { + throw new TypeError(`Refusing to load ${input} as a Sentry SDK script`); + } + return input; + }, + }); + } catch (error) { + DEBUG_BUILD && + debug.warn( + `Could not create the "${TRUSTED_TYPES_POLICY_NAME}" Trusted Types policy. Add it to your \`trusted-types\` CSP directive (with \`'allow-duplicates'\` if more than one Sentry SDK is loaded on the page).`, + error, + ); + } + + return policy; +} + +/** + * Returns `url` as a value that can be assigned to a script URL sink (`script.src`, + * `new Worker()`) on pages that enforce Trusted Types. + * + * The result is a `TrustedScriptURL` when Trusted Types is available and the plain + * string otherwise; it is typed as `string` because the DOM lib has no Trusted Types + * definitions. Throws for URLs the SDK does not load. + */ +export function getTrustedScriptURL(url: string): string { + const sdkPolicy = getPolicy(); + return (sdkPolicy ? sdkPolicy.createScriptURL(url) : url) as string; +} diff --git a/packages/browser-utils/test/trustedTypes.test.ts b/packages/browser-utils/test/trustedTypes.test.ts new file mode 100644 index 000000000000..4e9853fb2220 --- /dev/null +++ b/packages/browser-utils/test/trustedTypes.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const REPORT_DIALOG_URL = 'https://sentry.io/api/embed/error-page/?dsn=abc'; + +type Rules = { createScriptURL(input: string): string }; + +function fakeTrustedTypes() { + return { + createPolicy: vi.fn((_name: string, rules: Rules) => ({ + createScriptURL: (input: string) => rules.createScriptURL(input), + })), + }; +} + +function setTrustedTypes(value: unknown): void { + (window as { trustedTypes?: unknown }).trustedTypes = value; +} + +async function loadModule() { + vi.resetModules(); + return import('../src/trustedTypes'); +} + +describe('getTrustedScriptURL', () => { + afterEach(() => { + delete (window as { trustedTypes?: unknown }).trustedTypes; + }); + + it('returns the url unchanged when Trusted Types is unavailable', async () => { + const { getTrustedScriptURL } = await loadModule(); + + expect(getTrustedScriptURL(REPORT_DIALOG_URL)).toBe(REPORT_DIALOG_URL); + }); + + it('mints urls through the sentry-sdk policy', async () => { + const trustedTypes = fakeTrustedTypes(); + setTrustedTypes(trustedTypes); + + const { getTrustedScriptURL } = await loadModule(); + + expect(getTrustedScriptURL(REPORT_DIALOG_URL)).toBe(REPORT_DIALOG_URL); + expect(trustedTypes.createPolicy).toHaveBeenCalledWith('sentry-sdk', expect.anything()); + }); + + it('creates the policy only once', async () => { + const trustedTypes = fakeTrustedTypes(); + setTrustedTypes(trustedTypes); + + const { getTrustedScriptURL } = await loadModule(); + getTrustedScriptURL(REPORT_DIALOG_URL); + getTrustedScriptURL(REPORT_DIALOG_URL); + + expect(trustedTypes.createPolicy).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'http://localhost:9000/api/embed/error-page/?dsn=abc', + 'https://self-hosted.example.com/sentry/api/embed/error-page/', + ])('accepts the report dialog url %s', async url => { + setTrustedTypes(fakeTrustedTypes()); + + const { getTrustedScriptURL } = await loadModule(); + + expect(getTrustedScriptURL(url)).toBe(url); + }); + + it.each([ + 'https://example.com/evil.js', + 'https://sentry.io/api/embed/error-page/../../evil.js', + 'javascript:alert(1)', + 'data:text/javascript,alert(1)', + ])('refuses %s', async url => { + setTrustedTypes(fakeTrustedTypes()); + + const { getTrustedScriptURL } = await loadModule(); + + expect(() => getTrustedScriptURL(url)).toThrow(TypeError); + }); + + it('falls back to the raw url when the policy cannot be created', async () => { + setTrustedTypes({ + createPolicy: vi.fn(() => { + throw new Error('Policy "sentry-sdk" disallowed'); + }), + }); + + const { getTrustedScriptURL } = await loadModule(); + + expect(getTrustedScriptURL(REPORT_DIALOG_URL)).toBe(REPORT_DIALOG_URL); + }); +}); diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts index 03255a7db91d..9d927f4a83d7 100644 --- a/packages/browser/src/report-dialog.ts +++ b/packages/browser/src/report-dialog.ts @@ -1,3 +1,4 @@ +import { getTrustedScriptURL } from '@sentry/browser-utils'; import type { ReportDialogOptions } from '@sentry/core'; import { debug, getClient, getCurrentScope, getReportDialogEndpoint, lastEventId } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; @@ -39,7 +40,7 @@ export function showReportDialog(options: ReportDialogOptions = {}): void { const script = WINDOW.document.createElement('script'); script.async = true; script.crossOrigin = 'anonymous'; - script.src = getReportDialogEndpoint(dsn, mergedOptions); + script.src = getTrustedScriptURL(getReportDialogEndpoint(dsn, mergedOptions)); const { onLoad, onClose } = mergedOptions; diff --git a/packages/browser/test/report-dialog.test.ts b/packages/browser/test/report-dialog.test.ts new file mode 100644 index 000000000000..7f47524e76cd --- /dev/null +++ b/packages/browser/test/report-dialog.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const dsn = 'https://53039209a22b4ec1bcc296a3c9fdecd6@sentry.io/4291'; + +async function loadSdk() { + vi.resetModules(); + const sdk = await import('../src'); + sdk.init({ dsn }); + return sdk; +} + +function reportDialogScript(): HTMLScriptElement | null { + return document.head.querySelector('script[src*="/api/embed/error-page/"]'); +} + +describe('showReportDialog with Trusted Types', () => { + beforeEach(() => { + document.head.innerHTML = ''; + }); + + afterEach(() => { + delete (window as { trustedTypes?: unknown }).trustedTypes; + }); + + it('loads the dialog script through the sentry-sdk policy', async () => { + const createScriptURL = vi.fn((input: string) => input); + const createPolicy = vi.fn((_name: string, rules: { createScriptURL(input: string): string }) => ({ + createScriptURL: (input: string) => createScriptURL(rules.createScriptURL(input)), + })); + (window as { trustedTypes?: unknown }).trustedTypes = { createPolicy }; + + const { showReportDialog } = await loadSdk(); + showReportDialog({ eventId: 'abc' }); + + expect(createPolicy).toHaveBeenCalledWith('sentry-sdk', expect.anything()); + expect(createScriptURL).toHaveBeenCalledWith(expect.stringContaining('/api/embed/error-page/')); + expect(reportDialogScript()).not.toBeNull(); + }); + + it('loads the dialog script without Trusted Types', async () => { + const { showReportDialog } = await loadSdk(); + showReportDialog({ eventId: 'abc' }); + + expect(reportDialogScript()?.src).toContain('https://sentry.io/api/embed/error-page/'); + }); +});