Skip to content
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
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '42 KB',
limit: '43 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 1,
autoSessionTracking: false,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Fetch a data URL to verify that the span name and attributes are sanitized
// Data URLs are used for inline resources, e.g., Web Workers with inline scripts
const dataUrl = 'data:text/plain;base64,SGVsbG8gV29ybGQh';
fetch(dataUrl).catch(() => {
// Data URL fetch might fail in some browsers, but the span should still be created
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import {
envelopeRequestParser,
shouldSkipTracingTest,
waitForTransactionRequestOnUrl,
} from '../../../../utils/helpers';

sentryTest('sanitizes data URLs in fetch span name and attributes', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });

const req = await waitForTransactionRequestOnUrl(page, url);
const transactionEvent = envelopeRequestParser(req);

const requestSpans = transactionEvent.spans?.filter(({ op }) => op === 'http.client');

expect(requestSpans).toHaveLength(1);

const span = requestSpans?.[0];

const sanitizedUrl = 'data:text/plain,base64,SGVsbG8gV2... [truncated]';
expect(span?.description).toBe(`GET ${sanitizedUrl}`);

expect(span?.data).toMatchObject({
'http.method': 'GET',
url: sanitizedUrl,
type: 'fetch',
});

expect(span?.data?.['http.url']).toBe(sanitizedUrl);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 1,
autoSessionTracking: false,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// XHR request to a data URL to verify that the span name and attributes are sanitized
const dataUrl = 'data:text/plain;base64,SGVsbG8gV29ybGQh';
const xhr = new XMLHttpRequest();
xhr.open('GET', dataUrl);
xhr.send();
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';

sentryTest('sanitizes data URLs in XHR span name and attributes', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client');

expect(requestSpans).toHaveLength(1);

const span = requestSpans?.[0];

const sanitizedUrl = 'data:text/plain,base64,SGVsbG8gV2... [truncated]';
expect(span?.description).toBe(`GET ${sanitizedUrl}`);

expect(span?.data).toMatchObject({
'http.method': 'GET',
url: sanitizedUrl,
type: 'xhr',
});

expect(span?.data?.['http.url']).toBe(sanitizedUrl);
});
14 changes: 7 additions & 7 deletions packages/browser/src/integrations/globalhandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getLocationHref,
isPrimitive,
isString,
stripDataUrlContent,
UNKNOWN_FUNCTION,
} from '@sentry/core';
import type { BrowserClient } from '../client';
Expand Down Expand Up @@ -208,14 +209,13 @@ function getFilenameFromUrl(url: string | undefined): string | undefined {
return undefined;
}

// stack frame urls can be data urls, for example when initializing a Worker with a base64 encoded script
// in this case we just show the data prefix and mime type to avoid too long raw data urls
// Strip data URL content to avoid long base64 strings in stack frames
// (e.g. when initializing a Worker with a base64 encoded script)
// Don't include data prefix for filenames as it's not useful for stack traces
// Wrap with < > to indicate it's a placeholder
if (url.startsWith('data:')) {
const match = url.match(/^data:([^;]+)/);
const mimeType = match ? match[1] : 'text/javascript';
const isBase64 = url.includes('base64,');
return `<data:${mimeType}${isBase64 ? ',base64' : ''}>`;
return `<${stripDataUrlContent(url, false)}>`;
}

return url; // it's fine to not truncate it as it's not put in a regex (https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos)
return url;
}
9 changes: 5 additions & 4 deletions packages/browser/src/tracing/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
spanToJSON,
startInactiveSpan,
stringMatchesSomePattern,
stripDataUrlContent,
stripUrlQueryAndFragment,
} from '@sentry/core';
import type { XhrHint } from '@sentry-internal/browser-utils';
Expand Down Expand Up @@ -199,7 +200,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
const fullUrl = getFullURL(handlerData.fetchData.url);
const host = fullUrl ? parseUrl(fullUrl).host : undefined;
createdSpan.setAttributes({
'http.url': fullUrl,
'http.url': fullUrl ? stripDataUrlContent(fullUrl) : undefined,
'server.address': host,
});

Expand Down Expand Up @@ -355,7 +356,7 @@ function xhrCallback(
const fullUrl = getFullURL(url);
const parsedUrl = fullUrl ? parseUrl(fullUrl) : parseUrl(url);

const urlForSpanName = stripUrlQueryAndFragment(url);
const urlForSpanName = stripDataUrlContent(stripUrlQueryAndFragment(url));

const hasParent = !!getActiveSpan();

Expand All @@ -364,10 +365,10 @@ function xhrCallback(
? startInactiveSpan({
name: `${method} ${urlForSpanName}`,
attributes: {
url,
url: stripDataUrlContent(url),
type: 'xhr',
'http.method': method,
'http.url': fullUrl,
'http.url': fullUrl ? stripDataUrlContent(fullUrl) : undefined,
'server.address': parsedUrl?.host,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client',
Expand Down
26 changes: 22 additions & 4 deletions packages/core/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import { hasSpansEnabled } from './utils/hasSpansEnabled';
import { isInstanceOf, isRequest } from './utils/is';
import { getActiveSpan } from './utils/spanUtils';
import { getTraceData } from './utils/traceData';
import { getSanitizedUrlStringFromUrlObject, isURLObjectRelative, parseStringToURLObject } from './utils/url';
import {
getSanitizedUrlStringFromUrlObject,
isURLObjectRelative,
parseStringToURLObject,
stripDataUrlContent,
} from './utils/url';

type PolymorphicRequestHeaders =
| Record<string, string | undefined>
Expand Down Expand Up @@ -317,9 +322,22 @@ function getSpanStartOptions(
method: string,
spanOrigin: SpanOrigin,
): Parameters<typeof startInactiveSpan>[0] {
// Data URLs need special handling because parseStringToURLObject treats them as "relative"
// (no "://"), causing getSanitizedUrlStringFromUrlObject to return just the pathname
// without the "data:" prefix, making later stripDataUrlContent calls ineffective.
// So for data URLs, we strip the content first and use that directly.
if (url.startsWith('data:')) {
const sanitizedUrl = stripDataUrlContent(url);
return {
name: `${method} ${sanitizedUrl}`,
attributes: getFetchSpanAttributes(url, undefined, method, spanOrigin),
};
}

const parsedUrl = parseStringToURLObject(url);
const sanitizedUrl = parsedUrl ? getSanitizedUrlStringFromUrlObject(parsedUrl) : url;
return {
name: parsedUrl ? `${method} ${getSanitizedUrlStringFromUrlObject(parsedUrl)}` : method,
name: `${method} ${sanitizedUrl}`,
attributes: getFetchSpanAttributes(url, parsedUrl, method, spanOrigin),
};
}
Expand All @@ -331,15 +349,15 @@ function getFetchSpanAttributes(
spanOrigin: SpanOrigin,
): SpanAttributes {
const attributes: SpanAttributes = {
url,
url: stripDataUrlContent(url),
type: 'fetch',
'http.method': method,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client',
};
if (parsedUrl) {
if (!isURLObjectRelative(parsedUrl)) {
attributes['http.url'] = parsedUrl.href;
attributes['http.url'] = stripDataUrlContent(parsedUrl.href);
attributes['server.address'] = parsedUrl.host;
}
if (parsedUrl.search) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ export {
getHttpSpanDetailsFromUrlObject,
isURLObjectRelative,
getSanitizedUrlStringFromUrlObject,
stripDataUrlContent,
} from './utils/url';
export {
eventFromMessage,
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,39 @@ export function getSanitizedUrlString(url: PartialURL): string {

return `${protocol ? `${protocol}://` : ''}${filteredHost}${path}`;
}

/**
* Strips the content from a data URL, returning a placeholder with the MIME type.
*
* Data URLs can be very long (e.g. base64 encoded scripts for Web Workers),
* with little valuable information, often leading to envelopes getting dropped due
* to size limit violations. Therefore, we strip data URLs and replace them with a
* placeholder.
*
* @param url - The URL to process
* @param includeDataPrefix - If true, includes the first 10 characters of the data stream
* for debugging (e.g., to identify magic bytes like WASM's AGFzbQ).
* Defaults to true.
* @returns For data URLs, returns a short format like `data:text/javascript;base64,SGVsbG8gV2... [truncated]`.
* For non-data URLs, returns the original URL unchanged.
*/
export function stripDataUrlContent(url: string, includeDataPrefix: boolean = true): string {
if (url.startsWith('data:')) {
// Match the MIME type (everything after 'data:' until the first ';' or ',')
const match = url.match(/^data:([^;,]+)/);
const mimeType = match ? match[1] : 'text/plain';
const isBase64 = url.includes(';base64,');

// Find where the actual data starts (after the comma)
const dataStart = url.indexOf(',');
let dataPrefix = '';
if (includeDataPrefix && dataStart !== -1) {
const data = url.slice(dataStart + 1);
// Include first 10 chars of data to help identify content (e.g., magic bytes)
dataPrefix = data.length > 10 ? `${data.slice(0, 10)}... [truncated]` : data;
}

return `data:${mimeType}${isBase64 ? ',base64' : ''}${dataPrefix ? `,${dataPrefix}` : ''}`;
}
return url;
}
Loading
Loading