Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions packages/browser-utils/src/trustedTypes.ts
Original file line number Diff line number Diff line change
@@ -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;
}
95 changes: 95 additions & 0 deletions packages/browser-utils/test/trustedTypes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion packages/browser/src/report-dialog.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand Down
50 changes: 50 additions & 0 deletions packages/browser/test/report-dialog.test.ts
Original file line number Diff line number Diff line change
@@ -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/');
});
});
Loading