Skip to content

feat(sveltekit): Add options to configure fetch instrumentation script for CSP #9969

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 22, 2023
Merged
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
77 changes: 59 additions & 18 deletions packages/sveltekit/src/server/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ export type SentryHandleOptions = {
* @default false
*/
handleUnknownRoutes?: boolean;

/**
* Controls if `sentryHandle` should inject a script tag into the page that enables instrumentation
* of `fetch` calls in `load` functions.
*
* @default true
*/
injectFetchProxyScript?: boolean;

/**
* If this option is set, the `sentryHandle` handler will add a nonce attribute to the script
* tag it injects into the page. This script is used to enable instrumentation of `fetch` calls
* in `load` functions.
*
* Use this if your CSP policy blocks the fetch proxy script injected by `sentryHandle`.
*/
fetchProxyScriptNonce?: string;
};

function sendErrorToSentry(e: unknown): unknown {
Expand Down Expand Up @@ -53,30 +70,51 @@ function sendErrorToSentry(e: unknown): unknown {
return objectifiedErr;
}

const FETCH_PROXY_SCRIPT = `
/**
* Exported only for testing
*/
export const FETCH_PROXY_SCRIPT = `
const f = window.fetch;
if(f){
window._sentryFetchProxy = function(...a){return f(...a)}
window.fetch = function(...a){return window._sentryFetchProxy(...a)}
}
`;

export const transformPageChunk: NonNullable<ResolveOptions['transformPageChunk']> = ({ html }) => {
const transaction = getActiveTransaction();
if (transaction) {
const traceparentData = transaction.toTraceparent();
const dynamicSamplingContext = dynamicSamplingContextToSentryBaggageHeader(transaction.getDynamicSamplingContext());
const content = `<head>
<meta name="sentry-trace" content="${traceparentData}"/>
<meta name="baggage" content="${dynamicSamplingContext}"/>
<script>${FETCH_PROXY_SCRIPT}
</script>
`;
return html.replace('<head>', content);
}
/**
* Adds Sentry tracing <meta> tags to the returned html page.
* Adds Sentry fetch proxy script to the returned html page if enabled in options.
* Also adds a nonce attribute to the script tag if users specified one for CSP.
*
* Exported only for testing
*/
export function addSentryCodeToPage(options: SentryHandleOptions): NonNullable<ResolveOptions['transformPageChunk']> {
const { fetchProxyScriptNonce, injectFetchProxyScript } = options;
// if injectFetchProxyScript is not set, we default to true
const shouldInjectScript = injectFetchProxyScript !== false;
const nonce = fetchProxyScriptNonce ? `nonce="${fetchProxyScriptNonce}"` : '';

return html;
};
return ({ html }) => {
const transaction = getActiveTransaction();
Copy link
Member

Choose a reason for hiding this comment

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

l: unrelated to this PR, but we should look to rewrite this to use getActiveSpan() instead (as transaction will go away). Nothing to do here, just something I noticed :)

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah I had the same thought. Wanted to keep the PR atomic but I'll go over SvelteKit soon to get rid of startTxn and stuff like this.

if (transaction) {
const traceparentData = transaction.toTraceparent();
const dynamicSamplingContext = dynamicSamplingContextToSentryBaggageHeader(
transaction.getDynamicSamplingContext(),
);
const contentMeta = `<head>
<meta name="sentry-trace" content="${traceparentData}"/>
<meta name="baggage" content="${dynamicSamplingContext}"/>
`;
const contentScript = shouldInjectScript ? `<script ${nonce}>${FETCH_PROXY_SCRIPT}</script>` : '';

const content = `${contentMeta}\n${contentScript}`;

return html.replace('<head>', content);
}

return html;
};
}

/**
* A SvelteKit handle function that wraps the request for Sentry error and
Expand All @@ -89,13 +127,14 @@ export const transformPageChunk: NonNullable<ResolveOptions['transformPageChunk'
*
* export const handle = sentryHandle();
*
* // Optionally use the sequence function to add additional handlers.
* // Optionally use the `sequence` function to add additional handlers.
* // export const handle = sequence(sentryHandle(), yourCustomHandler);
* ```
*/
export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle {
const options = {
handleUnknownRoutes: false,
injectFetchProxyScript: true,
...handlerOptions,
};

Expand Down Expand Up @@ -139,7 +178,9 @@ async function instrumentHandle(
},
},
async (span?: Span) => {
const res = await resolve(event, { transformPageChunk });
const res = await resolve(event, {
transformPageChunk: addSentryCodeToPage(options),
});
if (span) {
span.setHttpStatus(res.status);
}
Expand Down
35 changes: 30 additions & 5 deletions packages/sveltekit/test/server/handle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { vi } from 'vitest';

import { sentryHandle, transformPageChunk } from '../../src/server/handle';
import { FETCH_PROXY_SCRIPT, addSentryCodeToPage, sentryHandle } from '../../src/server/handle';
import { getDefaultNodeClientOptions } from '../utils';

const mockCaptureException = vi.spyOn(SentryNode, 'captureException').mockImplementation(() => 'xx');
Expand Down Expand Up @@ -337,7 +337,7 @@ describe('handleSentry', () => {
});
});

describe('transformPageChunk', () => {
describe('addSentryCodeToPage', () => {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -351,16 +351,41 @@ describe('transformPageChunk', () => {
</html>`;

it('does not add meta tags if no active transaction', () => {
const transformPageChunk = addSentryCodeToPage({});
const transformed = transformPageChunk({ html, done: true });
expect(transformed).toEqual(html);
});

it('adds meta tags if there is an active transaction', () => {
it('adds meta tags and the fetch proxy script if there is an active transaction', () => {
const transformPageChunk = addSentryCodeToPage({});
const transaction = hub.startTransaction({ name: 'test' });
hub.getScope().setSpan(transaction);
const transformed = transformPageChunk({ html, done: true }) as string;

expect(transformed.includes('<meta name="sentry-trace"')).toEqual(true);
expect(transformed.includes('<meta name="baggage"')).toEqual(true);
expect(transformed).toContain('<meta name="sentry-trace"');
expect(transformed).toContain('<meta name="baggage"');
expect(transformed).toContain(`<script >${FETCH_PROXY_SCRIPT}</script>`);
});

it('adds a nonce attribute to the script if the `fetchProxyScriptNonce` option is specified', () => {
const transformPageChunk = addSentryCodeToPage({ fetchProxyScriptNonce: '123abc' });
const transaction = hub.startTransaction({ name: 'test' });
hub.getScope().setSpan(transaction);
const transformed = transformPageChunk({ html, done: true }) as string;

expect(transformed).toContain('<meta name="sentry-trace"');
expect(transformed).toContain('<meta name="baggage"');
expect(transformed).toContain(`<script nonce="123abc">${FETCH_PROXY_SCRIPT}</script>`);
});

it('does not add the fetch proxy script if the `injectFetchProxyScript` option is false', () => {
const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false });
const transaction = hub.startTransaction({ name: 'test' });
hub.getScope().setSpan(transaction);
const transformed = transformPageChunk({ html, done: true }) as string;

expect(transformed).toContain('<meta name="sentry-trace"');
expect(transformed).toContain('<meta name="baggage"');
expect(transformed).not.toContain(`<script >${FETCH_PROXY_SCRIPT}</script>`);
});
});