From 10d41aa40d21c02dc2f43355b65b6dfb8bdc204a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:43:32 -0400 Subject: [PATCH 01/32] feat(core): Add managed tunnel helpers that derive the tunnel path from the DSN Browser and server SDKs can now derive the same opaque tunnel path from the DSN with `getTunnelPath`, so they agree on it without a build step. `isTunnelRequest` and `handleTunnelRequestIfMatched` let a server wrapper serve the tunnel when its client has `_managedTunnel` set, which `resolveServerTunnelOption` derives from the `tunnel` option. `allowedDsns` defaults to the client's DSN and the forward to Sentry runs with tracing suppressed. --- packages/core/src/index.ts | 10 +- packages/core/src/types/options.ts | 23 +++ packages/core/src/utils/tunnel.ts | 119 ++++++++++- packages/core/test/lib/utils/tunnel.test.ts | 217 ++++++++++++++++---- 4 files changed, 312 insertions(+), 57 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aae98061c25d..b2962368c856 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,13 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; -export { handleTunnelRequest } from './utils/tunnel'; +export { + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { convertSpanLinksForEnvelope, @@ -415,7 +421,7 @@ export type { HttpHeadersCollection, ResolvedDataCollection, } from './types/datacollection'; -export type { ClientOptions, CoreOptions as Options } from './types/options'; +export type { ClientOptions, CoreOptions as Options, TunnelOptions } from './types/options'; export type { Package } from './types/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics'; export type { diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 8ea3944f1613..ee3ded025cfa 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -373,6 +373,14 @@ export interface ClientOptions Streame */ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; +/** Options for the managed tunnel, see `tunnel`. */ +export interface TunnelOptions { + /** + * Server only. DSNs whose envelopes the tunnel forwards. Defaults to the server's own DSN, so list the browser DSN + * here when the browser uses a different one. + */ + allowedDsns?: string[]; + + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ + basePath?: string; + + /** Browser only. Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ + trailingSlash?: boolean; +} + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 4831d6f48ea8..6b50d3b6fde4 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,6 +1,10 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import { getClient } from '../currentScopes'; +import { suppressTracing } from '../tracing/trace'; +import type { DsnLike } from '../types/dsn'; +import type { TunnelOptions } from '../types/options'; import { debug } from './debug-logger'; -import { makeDsn } from './dsn'; +import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; // Bounds the memory of each request. SDK envelopes stay far below it. @@ -9,8 +13,67 @@ export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** Pre-parsed array of allowed DSN strings */ - allowedDsns: Array; + /** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */ + allowedDsns?: Array; +} + +/** + * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own + * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. + */ +export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): { + tunnel: string | undefined; + _managedTunnel?: TunnelOptions; +} { + if (typeof tunnel === 'string' || !tunnel) { + return { tunnel }; + } + + return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; +} + +/** + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. + * + * Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it + * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. + */ +export function getTunnelPath(dsn: DsnLike): string | undefined { + const components = makeDsn(dsn); + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; +} + +/** + * Returns true if the request targets the managed tunnel. + * + * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its + * `init` was called with `tunnel: true`. + */ +export function isTunnelRequest(method: string, pathname: string, options?: TunnelOptions): boolean { + if (method !== 'POST') { + return false; + } + + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return false; + } + + const path = stripBasePath(pathname, tunnelOptions.basePath).replace(/\/$/, ''); + return resolveAllowedDsns(tunnelOptions.allowedDsns).some(dsn => getTunnelPath(dsn) === path); +} + +/** + * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller + * can hand the request to the app. + */ +export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { + if (!isTunnelRequest(request.method, new URL(request.url).pathname, options)) { + return undefined; + } + + const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + return handleTunnelRequest({ request, allowedDsns }); } /** @@ -22,7 +85,8 @@ export interface HandleTunnelRequestOptions { * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - const { request, allowedDsns } = options; + const { request } = options; + const allowedDsns = resolveAllowedDsns(options.allowedDsns); if (allowedDsns.length === 0) { return new Response('Tunnel not configured', { status: 500 }); @@ -74,15 +138,48 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); try { - return await fetch(sentryIngestUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }); + // The forward is SDK plumbing, not app work, so it must not show up as a span. + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body, + }), + ); } catch (error) { debug.error('Sentry tunnel: failed to forward envelope', error); return new Response('Failed to forward envelope to Sentry', { status: 500 }); } } + +function resolveAllowedDsns(allowedDsns: Array | undefined): Array { + if (allowedDsns) { + return allowedDsns; + } + + const dsn = getClient()?.getDsn(); + return dsn ? [dsnToString(dsn)] : []; +} + +function stripBasePath(pathname: string, basePath: string | undefined): string { + const base = basePath?.replace(/\/$/, ''); + if (!base) { + return pathname; + } + + return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname; +} + +/** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */ +function fnv1a(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + // eslint-disable-next-line no-bitwise + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + // eslint-disable-next-line no-bitwise + return hash >>> 0; +} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 7e56a5badfca..8c091845dcb2 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,20 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; +import { getCurrentScope, setCurrentClient } from '../../../src'; +import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest, MAX_TUNNEL_BODY_SIZE } from '../../../src/utils/tunnel'; +import { + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from '../../../src/utils/tunnel'; +import type { TunnelOptions } from '../../../src/types/options'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record, contentLength?: number): Request { +function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); - const byteLength = typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength; - return new Request('http://localhost/tunnel', { - method: 'POST', - body, - headers: { 'content-length': String(contentLength ?? byteLength) }, - }); + return new Request(url, { method: 'POST', body }); } describe('handleTunnelRequest', () => { @@ -27,6 +32,7 @@ describe('handleTunnelRequest', () => { afterEach(() => { vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); }); it('forwards the envelope to Sentry and returns the upstream response', async () => { @@ -143,47 +149,170 @@ describe('handleTunnelRequest', () => { expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); - describe('body size limit', () => { - it('forwards the envelope when Content-Length equals the limit', async () => { - fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + it('forwards with tracing suppressed so the forward does not create a span', async () => { + let suppressed: boolean | undefined; + fetchMock.mockImplementationOnce(() => { + suppressed = isTracingSuppressed(); + return Promise.resolve(new Response('ok')); + }); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE), - allowedDsns: [TEST_DSN], - }); + await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); - expect(result.status).toBe(200); - }); + expect(suppressed).toBe(true); + }); - it('returns 413 when Content-Length is above the limit', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE + 1), - allowedDsns: [TEST_DSN], - }); + it('defaults allowedDsns to the DSN of the active client', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValueOnce(new Response('ok')); - expect(result.status).toBe(413); - expect(await result.text()).toBe('Envelope too large'); - expect(fetchMock).not.toHaveBeenCalled(); - }); + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns 500 when allowedDsns is omitted and there is no client', async () => { + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('getTunnelPath', () => { + it('returns a short opaque path that does not leak the DSN', () => { + const path = getTunnelPath(TEST_DSN); + + expect(path).toMatch(/^\/[0-9a-z]{1,7}$/); + expect(path).not.toContain('sentry'); + }); + + it('is stable for the same DSN and differs between DSNs', () => { + expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); + }); + + it('ignores DSN formatting differences so browser and server derive the same path', () => { + expect(getTunnelPath('https://public:secret@dsn.ingest.sentry.io/1337')).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(makeDsn(TEST_DSN)!)).toBe(getTunnelPath(TEST_DSN)); + }); + + it('returns undefined for an invalid DSN', () => { + expect(getTunnelPath('not-a-dsn')).toBeUndefined(); + }); +}); + +describe('isTunnelRequest', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('matches a POST to the tunnel path of an allowed DSN', () => { + expect(isTunnelRequest('POST', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest('POST', `${tunnelPath}/`, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match other methods, other paths, or path prefixes', () => { + expect(isTunnelRequest('GET', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest('POST', '/somewhere', { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest('POST', `${tunnelPath}x`, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('does not match the tunnel path of a DSN that is not allowed', () => { + const otherDsn = 'https://other@example.com/9999'; + + expect(isTunnelRequest('POST', getTunnelPath(otherDsn)!, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('uses the managed tunnel config of the active client when no options are passed', () => { + setTestClient(TEST_DSN); + expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + + setTestClient(TEST_DSN, {}); + expect(isTunnelRequest('POST', tunnelPath)).toBe(true); + }); + + it('matches nothing without a client', () => { + expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + expect(isTunnelRequest('POST', tunnelPath, {})).toBe(false); + }); + + it('matches under a base path', () => { + expect(isTunnelRequest('POST', `/docs${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs/' })).toBe(true); + expect(isTunnelRequest('POST', `/docsx${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe(false); + }); +}); + +describe('handleTunnelRequestIfMatched', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('forwards a matching request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); + + const result = await handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] }); - it('returns 411 when the request has no Content-Length', async () => { - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ dsn: TEST_DSN })}\n`)); - controller.close(); - }, - }); - // Node.js requires `duplex` for a stream body, but the DOM types do not have it yet. - const init: RequestInit & { duplex: 'half' } = { method: 'POST', body, duplex: 'half' }; - - const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', init), - allowedDsns: [TEST_DSN], - }); - - expect(result.status).toBe(411); - expect(await result.text()).toBe('Length required'); - expect(fetchMock).not.toHaveBeenCalled(); + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('forwards using the allowedDsns of the active client managed tunnel config', async () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn] }); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: browserDsn }, `http://localhost${getTunnelPath(browserDsn)}`); + + const result = await handleTunnelRequestIfMatched(request); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(browserDsn)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns undefined without reading the body when the request does not match', () => { + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); + + expect(handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] })).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('resolveServerTunnelOption', () => { + it('keeps a URL as the transport tunnel', () => { + expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); + }); + + it('enables the managed tunnel for `true` and options, and leaves the transport untunneled', () => { + expect(resolveServerTunnelOption(true)).toEqual({ tunnel: undefined, _managedTunnel: {} }); + expect(resolveServerTunnelOption({ allowedDsns: [TEST_DSN] })).toEqual({ + tunnel: undefined, + _managedTunnel: { allowedDsns: [TEST_DSN] }, }); }); + + it('leaves everything off when unset', () => { + expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); + }); }); + +function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { + setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); +} From d1d01c70374b5b025df67a86334ab06eef1c18b5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 10:37:01 -0400 Subject: [PATCH 02/32] ref(core): Pass the whole request to the tunnel matcher and expose `getTunnelPaths` Callers now hand `isTunnelRequest` a request-like object, so the matching rule can look at more of the request later without changing every framework. `getTunnelPaths` covers frameworks that can only register routes. --- packages/core/src/index.ts | 3 +- packages/core/src/utils/tunnel.ts | 44 ++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 76 +++++++++++++++++---- 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2962368c856..f753236a441a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,9 +101,10 @@ export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; -export type { HandleTunnelRequestOptions } from './utils/tunnel'; +export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { getTunnelPath, + getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 6b50d3b6fde4..10f113a1b2e6 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -6,6 +6,7 @@ import type { TunnelOptions } from '../types/options'; import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; +import { parseUrl } from './url'; // Bounds the memory of each request. SDK envelopes stay far below it. export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; @@ -40,17 +41,46 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions */ export function getTunnelPath(dsn: DsnLike): string | undefined { const components = makeDsn(dsn); + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; } +/** + * The parts of an incoming request the tunnel decision may look at. A fetch `Request` satisfies it, and Node SDKs pass + * an `IncomingMessage` directly. Callers always pass the whole request so the decision can change without touching them. + */ +export interface TunnelRequestLike { + method?: string; + /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ + url?: string; + headers?: Headers | Record; +} + +/** + * Returns the tunnel paths the server accepts, relative to its base path. Frameworks that can only register routes + * use these, everything else should call {@link isTunnelRequest} or {@link handleTunnelRequestIfMatched}. + * + * Without `options`, this uses the managed tunnel config of the active client. + */ +export function getTunnelPaths(options?: TunnelOptions): string[] { + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return []; + } + + return resolveAllowedDsns(tunnelOptions.allowedDsns) + .map(dsn => getTunnelPath(dsn)) + .filter((path): path is string => !!path); +} + /** * Returns true if the request targets the managed tunnel. * * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its * `init` was called with `tunnel: true`. */ -export function isTunnelRequest(method: string, pathname: string, options?: TunnelOptions): boolean { - if (method !== 'POST') { +export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOptions): boolean { + if (request.method !== 'POST' || !request.url) { return false; } @@ -59,8 +89,9 @@ export function isTunnelRequest(method: string, pathname: string, options?: Tunn return false; } - const path = stripBasePath(pathname, tunnelOptions.basePath).replace(/\/$/, ''); - return resolveAllowedDsns(tunnelOptions.allowedDsns).some(dsn => getTunnelPath(dsn) === path); + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath).replace(/\/$/, ''); + + return getTunnelPaths(tunnelOptions).includes(path); } /** @@ -68,11 +99,12 @@ export function isTunnelRequest(method: string, pathname: string, options?: Tunn * can hand the request to the app. */ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { - if (!isTunnelRequest(request.method, new URL(request.url).pathname, options)) { + if (!isTunnelRequest(request, options)) { return undefined; } const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + return handleTunnelRequest({ request, allowedDsns }); } @@ -160,6 +192,7 @@ function resolveAllowedDsns(allowedDsns: Array | undefined): Array>> 0; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 8c091845dcb2..b365206d0722 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -6,6 +6,7 @@ import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { getTunnelPath, + getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, @@ -19,6 +20,7 @@ const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); + return new Request(url, { method: 'POST', body }); } @@ -153,6 +155,7 @@ describe('handleTunnelRequest', () => { let suppressed: boolean | undefined; fetchMock.mockImplementationOnce(() => { suppressed = isTracingSuppressed(); + return Promise.resolve(new Response('ok')); }); @@ -213,38 +216,87 @@ describe('isTunnelRequest', () => { }); it('matches a POST to the tunnel path of an allowed DSN', () => { - expect(isTunnelRequest('POST', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(true); - expect(isTunnelRequest('POST', `${tunnelPath}/`, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('does not match other methods, other paths, or path prefixes', () => { - expect(isTunnelRequest('GET', tunnelPath, { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest('POST', '/somewhere', { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest('POST', `${tunnelPath}x`, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); }); it('does not match the tunnel path of a DSN that is not allowed', () => { const otherDsn = 'https://other@example.com/9999'; - expect(isTunnelRequest('POST', getTunnelPath(otherDsn)!, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe(false); }); it('uses the managed tunnel config of the active client when no options are passed', () => { setTestClient(TEST_DSN); - expect(isTunnelRequest('POST', tunnelPath)).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); setTestClient(TEST_DSN, {}); - expect(isTunnelRequest('POST', tunnelPath)).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(true); }); it('matches nothing without a client', () => { - expect(isTunnelRequest('POST', tunnelPath)).toBe(false); - expect(isTunnelRequest('POST', tunnelPath, {})).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, {})).toBe(false); }); it('matches under a base path', () => { - expect(isTunnelRequest('POST', `/docs${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs/' })).toBe(true); - expect(isTunnelRequest('POST', `/docsx${tunnelPath}`, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe(false); + expect( + isTunnelRequest({ method: 'POST', url: `/docs${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs/' }), + ).toBe(true); + expect( + isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + ).toBe(false); + }); +}); + +describe('isTunnelRequest request shapes', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + it('accepts a fetch Request with an absolute URL and query', () => { + const request = new Request(`https://example.com${tunnelPath}?o=1`, { method: 'POST' }); + + expect(isTunnelRequest(request, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('accepts a Node IncomingMessage-like object', () => { + const incomingMessage = { method: 'POST', url: `${tunnelPath}?o=1`, headers: { host: 'example.com' } }; + + expect(isTunnelRequest(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match without a URL', () => { + expect(isTunnelRequest({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); +}); + +describe('getTunnelPaths', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('returns one path per allowed DSN and skips invalid ones', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect(getTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] })).toEqual([ + getTunnelPath(TEST_DSN), + getTunnelPath(otherDsn), + ]); + }); + + it('uses the managed tunnel config of the active client', () => { + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN); + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN, {}); + expect(getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); }); }); From 80bc1deeb1e0b67a39db265c3962c8939bca1ca1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 10:44:30 -0400 Subject: [PATCH 03/32] perf(core): Cache the tunnel path per DSN Servers derive the tunnel paths on every POST, so each DSN is now parsed and hashed once. An invalid DSN in `allowedDsns` also logs once instead of on every request. --- packages/core/src/utils/tunnel.ts | 21 +++++++++++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 10 ++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 10f113a1b2e6..eb16dc82a51a 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -18,6 +18,10 @@ export interface HandleTunnelRequestOptions { allowedDsns?: Array; } +// Servers derive the paths on every POST, so parse and hash each DSN once. This also keeps an invalid DSN in +// `allowedDsns` from logging on every request. Keys come from config, never from requests, so the map stays small. +const tunnelPathCache = new Map(); + /** * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. @@ -40,9 +44,17 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. */ export function getTunnelPath(dsn: DsnLike): string | undefined { + if (typeof dsn === 'string' && tunnelPathCache.has(dsn)) { + return tunnelPathCache.get(dsn); + } + const components = makeDsn(dsn); + const path = components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; + if (typeof dsn === 'string') { + tunnelPathCache.set(dsn, path); + } - return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; + return path; } /** @@ -205,7 +217,12 @@ function stripBasePath(pathname: string, basePath: string | undefined): string { return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname; } -/** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */ +/** + * 32-bit FNV-1a, because the browser needs the path synchronously in `init` (`crypto.subtle.digest` is async) and it + * runs in every runtime without dependencies. It needn't be cryptographic since the DSN is public. + * + * Browser and server SDKs compute the path separately, so changing the algorithm breaks mixed SDK versions. + */ function fnv1a(input: string): number { let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index b365206d0722..735a97f1730b 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -206,6 +206,16 @@ describe('getTunnelPath', () => { it('returns undefined for an invalid DSN', () => { expect(getTunnelPath('not-a-dsn')).toBeUndefined(); }); + + it('parses each DSN string once, so an invalid DSN logs once', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + getTunnelPath('also-not-a-dsn'); + getTunnelPath('also-not-a-dsn'); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + consoleErrorSpy.mockRestore(); + }); }); describe('isTunnelRequest', () => { From 8cb2104b8513d8c95dff0d636effe72b6d5133a2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:11:43 -0400 Subject: [PATCH 04/32] feat(core): Serve a fixed managed tunnel path from a relative `tunnel` on the server A relative `tunnel` can't be a transport URL on the server, so it now names the path the server serves, and `TunnelOptions.path` overrides the DSN-derived path. `tunnel: '/monitor'` on both sides then gives a fixed path the same way `tunnel: true` gives the DSN-derived one. --- packages/core/src/types/options.ts | 3 +++ packages/core/src/utils/tunnel.ts | 11 ++++++++++- packages/core/test/lib/utils/tunnel.test.ts | 11 +++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index ee3ded025cfa..00d60c38cb80 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -661,6 +661,9 @@ export interface TunnelOptions { */ allowedDsns?: string[]; + /** A fixed tunnel path instead of the one derived from the DSN. A relative `tunnel` string sets this. */ + path?: string; + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ basePath?: string; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index eb16dc82a51a..92eee9b7d993 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -30,10 +30,15 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions tunnel: string | undefined; _managedTunnel?: TunnelOptions; } { - if (typeof tunnel === 'string' || !tunnel) { + if (!tunnel) { return { tunnel }; } + if (typeof tunnel === 'string') { + // A relative path can't be a transport URL on the server, so it names the path to serve instead. + return tunnel.startsWith('/') ? { tunnel: undefined, _managedTunnel: { path: tunnel } } : { tunnel }; + } + return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; } @@ -80,6 +85,10 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { return []; } + if (tunnelOptions.path) { + return [tunnelOptions.path]; + } + return resolveAllowedDsns(tunnelOptions.allowedDsns) .map(dsn => getTunnelPath(dsn)) .filter((path): path is string => !!path); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 735a97f1730b..f8fb2275e365 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -299,6 +299,13 @@ describe('getTunnelPaths', () => { ]); }); + it('returns a fixed path instead of the DSN-derived ones', () => { + expect(getTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] })).toEqual(['/monitor']); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( + true, + ); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -370,6 +377,10 @@ describe('resolveServerTunnelOption', () => { }); }); + it('serves a relative path instead of using it as the transport tunnel', () => { + expect(resolveServerTunnelOption('/monitor')).toEqual({ tunnel: undefined, _managedTunnel: { path: '/monitor' } }); + }); + it('leaves everything off when unset', () => { expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); }); From 2f819129fda47c1c1ac7eb356f95d8694e26d68a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:47:06 -0400 Subject: [PATCH 05/32] feat(core): Drop the server spans of managed tunnel requests Server SDKs start the incoming request span before a framework hook gets to answer a tunnel request, so clients with the managed tunnel enabled now add the tunnel paths to `ignoreSpans`. --- packages/core/src/client.ts | 6 +++ packages/core/src/types/options.ts | 2 +- packages/core/src/utils/tunnel.ts | 38 +++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 57 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c61b3975d41b..b87a294792ac 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,6 +41,7 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; +import { getTunnelIgnoreSpanFilter } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -271,6 +272,11 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } + const tunnelSpanFilter = options._managedTunnel && getTunnelIgnoreSpanFilter(options._managedTunnel, this._dsn); + if (tunnelSpanFilter) { + this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), tunnelSpanFilter]; + } + const { beforeSendSpan, traceLifecycle } = this._options; // A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle, // so a mismatch means it is silently never called. diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 00d60c38cb80..e5b7be5d96dc 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -96,7 +96,7 @@ export type IgnoreSpanAttributeValue = string | boolean | number | string[] | bo * At least one of the properties (`name`, `op`, or `attributes`) must be set. * If multiple are set, all must match for the span to be ignored. */ -type IgnoreSpanFilter = +export type IgnoreSpanFilter = | { /** * Spans with a name matching this pattern will be ignored. diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 92eee9b7d993..46bb84f625d5 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,8 +1,10 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; -import type { DsnLike } from '../types/dsn'; -import type { TunnelOptions } from '../types/options'; +import { URL_PATH } from '@sentry/conventions/attributes'; +import type { DsnComponents, DsnLike } from '../types/dsn'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; @@ -85,13 +87,27 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { return []; } - if (tunnelOptions.path) { - return [tunnelOptions.path]; + return tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns)); +} + +/** + * An `ignoreSpans` filter for the server spans of tunnel requests. Server SDKs start the span before a framework + * hook gets to answer the request, so clients add this whenever the managed tunnel is enabled. + */ +export function getTunnelIgnoreSpanFilter( + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter | undefined { + const allowedDsns = tunnelOptions.allowedDsns ?? (dsn ? [dsnToString(dsn)] : []); + const paths = tunnelPathsFor(tunnelOptions, allowedDsns).map(escapeStringForRegex); + if (!paths.length) { + return undefined; } - return resolveAllowedDsns(tunnelOptions.allowedDsns) - .map(dsn => getTunnelPath(dsn)) - .filter((path): path is string => !!path); + const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + return { attributes: { [URL_PATH]: new RegExp(`^(?:${basePath})?(?:${paths.join('|')})/?$`) } }; } /** @@ -243,3 +259,11 @@ function fnv1a(input: string): number { // eslint-disable-next-line no-bitwise return hash >>> 0; } + +function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { + if (tunnelOptions.path) { + return [tunnelOptions.path]; + } + + return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); +} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index f8fb2275e365..9a9238a9499a 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -6,6 +6,7 @@ import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { getTunnelPath, + getTunnelIgnoreSpanFilter, getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, @@ -364,6 +365,62 @@ describe('handleTunnelRequestIfMatched', () => { }); }); +describe('getTunnelIgnoreSpanFilter', () => { + function urlPathMatcher(filter: ReturnType): RegExp { + return (filter as { attributes: { 'url.path': RegExp } }).attributes['url.path']; + } + + it("matches the tunnel path of the client's DSN, with or without a trailing slash", () => { + const matcher = urlPathMatcher(getTunnelIgnoreSpanFilter({}, makeDsn(TEST_DSN))); + const tunnelPath = getTunnelPath(TEST_DSN)!; + + expect(matcher.test(tunnelPath)).toBe(true); + expect(matcher.test(`${tunnelPath}/`)).toBe(true); + expect(matcher.test(`${tunnelPath}x`)).toBe(false); + expect(matcher.test('/api/users')).toBe(false); + }); + + it('follows allowedDsns, a fixed path and the base path', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(otherDsn)!, + ), + ).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( + `/app${getTunnelPath(TEST_DSN)}`, + ), + ).toBe(true); + }); + + it('returns nothing when there is no path to match', () => { + expect(getTunnelIgnoreSpanFilter({}, undefined)).toBeUndefined(); + }); +}); + +describe('client with the managed tunnel enabled', () => { + it('ignores the server spans of tunnel requests and keeps user filters', () => { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), + ); + const ignoreSpans = client.getOptions().ignoreSpans!; + + expect(ignoreSpans[0]).toBe('user-filter'); + expect( + (ignoreSpans[1] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test(getTunnelPath(TEST_DSN)!), + ).toBe(true); + }); + + it('leaves ignoreSpans alone without the managed tunnel', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN })); + + expect(client.getOptions().ignoreSpans).toBeUndefined(); + }); +}); + describe('resolveServerTunnelOption', () => { it('keeps a URL as the transport tunnel', () => { expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); From 3b4f5cba199b95f677a9fa9160291323d73b4dc5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:11:40 -0400 Subject: [PATCH 06/32] fix(core): Skip fetch instrumentation while tracing is suppressed --- packages/core/src/integrations/fetch.ts | 4 +++- .../core/test/lib/integrations/fetch.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index 5cf7a0489719..dd5fe7e16ced 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -5,6 +5,7 @@ import { getClient } from '../currentScopes'; import { instrumentFetchRequest } from '../fetch'; import { defineIntegration } from '../integration'; import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import { isTracingSuppressed } from '../tracing/trace'; import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; import type { HandlerDataFetch } from '../types/instrument'; import type { Integration, IntegrationFn } from '../types/integration'; @@ -92,7 +93,8 @@ export function createFetchIntegration({ return; } - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + // Matches Node's http and undici instrumentation, so SDK-internal requests (e.g. a tunnel forward) leave no breadcrumb. + if (isTracingSuppressed() || isSentryRequestUrl(handlerData.fetchData.url, client)) { return; } diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts index 4c332b479fa0..4362c15dd2fd 100644 --- a/packages/core/test/lib/integrations/fetch.test.ts +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -4,6 +4,7 @@ import * as currentScopesModule from '../../../src/currentScopes'; import * as fetchModule from '../../../src/fetch'; import { createFetchIntegration } from '../../../src/integrations/fetch'; import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import { suppressTracing } from '../../../src/tracing/trace'; import type { HandlerDataFetch } from '../../../src/types/instrument'; import type { Integration } from '../../../src/types/integration'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -136,6 +137,23 @@ describe('createFetchIntegration', () => { expect(addBreadcrumbSpy).not.toHaveBeenCalled(); }); + it('does not instrument requests made while tracing is suppressed', () => { + const handler = setupIntegration(fetchIntegration(), client); + + suppressTracing(() => + handler({ + fetchData: { url: 'https://o1.ingest.us.sentry.io/api/2/envelope/', method: 'POST' }, + args: ['https://o1.ingest.us.sentry.io/api/2/envelope/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }), + ); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + it('applies the `shouldCreateSpanForRequest` option', () => { const handler = setupIntegration( fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), From 42edf12a15c222b081468b81c614f2afd38c0088 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:11:40 -0400 Subject: [PATCH 07/32] feat(core): Check the tunnel DSN before reading the envelope body --- packages/core/src/utils/tunnel.ts | 101 +++++++++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 74 +++++++++++++- 2 files changed, 159 insertions(+), 16 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 46bb84f625d5..2b7df2e80bbc 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -161,20 +161,15 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Tunnel not configured', { status: 500 }); } - // The HTTP server ends the body at `Content-Length`, so the header bounds what `arrayBuffer()` reads. - const contentLength = Number(request.headers.get('content-length')); - if (!contentLength) { - return new Response('Length required', { status: 411 }); - } - if (contentLength > MAX_TUNNEL_BODY_SIZE) { - return new Response('Envelope too large', { status: 413 }); + const reader = request.body?.getReader(); + const head = reader && (await readEnvelopeHeaderLine(reader)); + if (!reader || !head) { + return new Response('Invalid envelope', { status: 400 }); } - const body = new Uint8Array(await request.arrayBuffer()); - let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(body); + [envelopeHeader] = parseEnvelope(head.line); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -211,11 +206,11 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return await suppressTracing(() => fetch(sentryIngestUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }), + headers: forwardHeaders(request), + // The body streams through without being buffered, so its size is left for Sentry to judge. + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), ); } catch (error) { debug.error('Sentry tunnel: failed to forward envelope', error); @@ -223,6 +218,82 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): } } +/** + * The header is a small JSON object, so a line longer than this is not an envelope. + */ +const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; + +/** + * Reads the request body only up to the end of the envelope header line. + * Returns the line and every byte read so far, or `undefined` when no header line fits the limit. + */ +async function readEnvelopeHeaderLine( + reader: ReadableStreamDefaultReader, +): Promise<{ line: Uint8Array; read: Uint8Array } | undefined> { + let read: Uint8Array = new Uint8Array(0); + + while (read.length <= MAX_ENVELOPE_HEADER_BYTES) { + const { done, value } = await reader.read(); + if (done) { + return read.length ? { line: read, read } : undefined; + } + + const searchFrom = read.length; + read = concatBytes(read, value); + + const newline = read.indexOf(0x0a, searchFrom); + if (newline !== -1) { + return { line: read.subarray(0, newline), read }; + } + } + + await reader.cancel(); + + return undefined; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const merged = new Uint8Array(a.length + b.length); + merged.set(a); + merged.set(b, a.length); + + return merged; +} + +function prependToStream( + prefix: Uint8Array, + reader: ReadableStreamDefaultReader, +): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(prefix); + }, + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + +function forwardHeaders(request: Request): Record { + const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; + // Keeps the upload fixed-length where the runtime allows it (Node, Bun), instead of chunked. + const contentLength = request.headers.get('content-length'); + if (contentLength) { + headers['Content-Length'] = contentLength; + } + + return headers; +} + function resolveAllowedDsns(allowedDsns: Array | undefined): Array { if (allowedDsns) { return allowedDsns; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 9a9238a9499a..b40b65dd20f6 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -25,6 +25,25 @@ function makeEnvelopeRequest(envelopeHeader: Record, url = 'htt return new Request(url, { method: 'POST', body }); } +/** + * A request whose body starts with `head` and then never ends, so buffering it would hang the test. + */ +function makeEndlessRequest(head: string): Request { + const filler = new Uint8Array(16 * 1024).fill(0x61); + let sentHead = false; + + return new Request('http://localhost/tunnel', { + method: 'POST', + duplex: 'half', + body: new ReadableStream({ + pull(controller) { + controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); + sentHead = true; + }, + }), + } as RequestInit); +} + describe('handleTunnelRequest', () => { let fetchMock: ReturnType; @@ -52,11 +71,64 @@ describe('handleTunnelRequest', () => { expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); expect(init.method).toBe('POST'); expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); - expect(init.body).toBeInstanceOf(Uint8Array); + expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); + + expect(result).toBe(upstreamResponse); + }); + + it('forwards the content length of the incoming request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const body = `${JSON.stringify({ dsn: TEST_DSN })}\n`; + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': String(body.length) }, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-sentry-envelope', 'Content-Length': String(body.length) }, + }), + ); + }); + + it('rejects a disallowed DSN after reading only the envelope header', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { + const upstreamResponse = new Response('ok'); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), + allowedDsns: [TEST_DSN], + }); expect(result).toBe(upstreamResponse); }); + it('returns 400 when the envelope header line exceeds the limit', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(''), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('returns 500 when allowedDsns is empty', async () => { const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), From 52cb5bd88d9c170739751aa4a35217ec5603c703 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:25:49 -0400 Subject: [PATCH 08/32] fix(core): Cancel the body of a rejected tunnel request --- packages/core/src/utils/tunnel.ts | 49 +++++++++++++-------- packages/core/test/lib/utils/tunnel.test.ts | 19 +++++++- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 2b7df2e80bbc..efee1b4828d0 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -167,9 +167,38 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid envelope', { status: 400 }); } + const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); + if (typeof sentryIngestUrl !== 'string') { + // Nothing past the header gets forwarded, so the runtime can stop receiving the body. + reader.cancel().catch(() => undefined); + + return sentryIngestUrl; + } + + try { + // The forward is SDK plumbing, not app work, so it must not show up as a span. + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: forwardHeaders(request), + // The body streams through without being buffered, so its size is left for Sentry to judge. + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), + ); + } catch (error) { + debug.error('Sentry tunnel: failed to forward envelope', error); + return new Response('Failed to forward envelope to Sentry', { status: 500 }); + } +} + +/** + * Returns the Sentry ingest URL for an envelope header line, or the response that rejects it. + */ +function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(head.line); + [envelopeHeader] = parseEnvelope(headerLine); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -199,23 +228,7 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid DSN', { status: 403 }); } - const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); - - try { - // The forward is SDK plumbing, not app work, so it must not show up as a span. - return await suppressTracing(() => - fetch(sentryIngestUrl, { - method: 'POST', - headers: forwardHeaders(request), - // The body streams through without being buffered, so its size is left for Sentry to judge. - body: prependToStream(head.read, reader), - duplex: 'half', - } as RequestInit), - ); - } catch (error) { - debug.error('Sentry tunnel: failed to forward envelope', error); - return new Response('Failed to forward envelope to Sentry', { status: 500 }); - } + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); } /** diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index b40b65dd20f6..b99d72fa2fbd 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -28,7 +28,7 @@ function makeEnvelopeRequest(envelopeHeader: Record, url = 'htt /** * A request whose body starts with `head` and then never ends, so buffering it would hang the test. */ -function makeEndlessRequest(head: string): Request { +function makeEndlessRequest(head: string, onCancel?: () => void): Request { const filler = new Uint8Array(16 * 1024).fill(0x61); let sentHead = false; @@ -40,6 +40,7 @@ function makeEndlessRequest(head: string): Request { controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); sentHead = true; }, + cancel: onCancel, }), } as RequestInit); } @@ -107,6 +108,17 @@ describe('handleTunnelRequest', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('cancels the body of a rejected request', async () => { + const onCancel = vi.fn(); + + await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`, onCancel), + allowedDsns: [TEST_DSN], + }); + + expect(onCancel).toHaveBeenCalledOnce(); + }); + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { const upstreamResponse = new Response('ok'); fetchMock.mockResolvedValueOnce(upstreamResponse); @@ -266,6 +278,11 @@ describe('getTunnelPath', () => { expect(path).not.toContain('sentry'); }); + // Browser and server SDKs of different versions must derive the same path, so the hash must never change. + it('derives a fixed path for a known DSN', () => { + expect(getTunnelPath(TEST_DSN)).toBe('/fdv680'); + }); + it('is stable for the same DSN and differs between DSNs', () => { expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); From bf70d4ed0ba6a2b4c4df64b052fd0b229f66e713 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:28:11 -0400 Subject: [PATCH 09/32] feat(core): Forward the client IP of tunnel requests to Sentry --- packages/core/src/utils/tunnel.ts | 10 +++++++++ packages/core/test/lib/utils/tunnel.test.ts | 24 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index efee1b4828d0..a1c6f5905bad 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -304,6 +304,16 @@ function forwardHeaders(request: Request): Record { headers['Content-Length'] = contentLength; } + // Relay infers the user's IP and geo from the sender, which is now this server. Proxies and CDNs put the + // visitor's IP in these headers, in the order Relay itself reads them. + const clientIp = + request.headers.get('cf-connecting-ip') || + request.headers.get('x-vercel-forwarded-for') || + request.headers.get('x-forwarded-for'); + if (clientIp) { + headers['X-Sentry-Forwarded-For'] = clientIp; + } + return headers; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index b99d72fa2fbd..602fe3fb0fe0 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -98,6 +98,30 @@ describe('handleTunnelRequest', () => { ); }); + it.each([ + ['cf-connecting-ip', { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, '1.1.1.1'], + ['x-vercel-forwarded-for', { 'x-vercel-forwarded-for': '3.3.3.3', 'x-forwarded-for': '2.2.2.2' }, '3.3.3.3'], + ['x-forwarded-for', { 'x-forwarded-for': '2.2.2.2, 10.0.0.1' }, '2.2.2.2, 10.0.0.1'], + ])('forwards the client IP from %s', async (_, incomingHeaders, forwardedFor) => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + headers: incomingHeaders, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Sentry-Forwarded-For': forwardedFor }), + }), + ); + }); + it('rejects a disallowed DSN after reading only the envelope header', async () => { const result = await handleTunnelRequest({ request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), From f36d4a94d3cb52dc7f346ae8e63784544231ddbd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:38:40 -0400 Subject: [PATCH 10/32] fix(core): Match a fixed tunnel path that ends with a slash --- packages/core/src/utils/tunnel.ts | 2 +- packages/core/test/lib/utils/tunnel.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index a1c6f5905bad..2693bb07c436 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -356,7 +356,7 @@ function fnv1a(input: string): number { function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { if (tunnelOptions.path) { - return [tunnelOptions.path]; + return [tunnelOptions.path.replace(/\/$/, '')]; } return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 602fe3fb0fe0..d20fed94a5aa 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -420,6 +420,11 @@ describe('getTunnelPaths', () => { ); }); + it('matches a fixed path that ends with a slash', () => { + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -502,6 +507,7 @@ describe('getTunnelIgnoreSpanFilter', () => { ), ).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); expect( urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( `/app${getTunnelPath(TEST_DSN)}`, From 5ed0ddcba0a9673bfc24f820779fc9c2c14f9eb4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 09:57:47 -0400 Subject: [PATCH 11/32] fix(core): Only answer the managed tunnel under its base path --- packages/core/src/utils/tunnel.ts | 13 ++++++++----- packages/core/test/lib/utils/tunnel.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 2693bb07c436..48083f05d7f8 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -107,7 +107,7 @@ export function getTunnelIgnoreSpanFilter( const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return { attributes: { [URL_PATH]: new RegExp(`^(?:${basePath})?(?:${paths.join('|')})/?$`) } }; + return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } /** @@ -126,9 +126,9 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOpti return false; } - const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath).replace(/\/$/, ''); + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); - return getTunnelPaths(tunnelOptions).includes(path); + return path !== undefined && getTunnelPaths(tunnelOptions).includes(path.replace(/\/$/, '')); } /** @@ -327,13 +327,16 @@ function resolveAllowedDsns(allowedDsns: Array | undefined): Array { isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), ).toBe(false); }); + + it('matches nothing outside the base path', () => { + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe( + false, + ); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe(false); + }); }); describe('isTunnelRequest request shapes', () => { @@ -513,6 +520,9 @@ describe('getTunnelIgnoreSpanFilter', () => { `/app${getTunnelPath(TEST_DSN)}`, ), ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN))).test(getTunnelPath(TEST_DSN)!), + ).toBe(false); }); it('returns nothing when there is no path to match', () => { From 2e639add760ae7466a4b9b9d5d37b1f3c360cf62 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 10:34:20 -0400 Subject: [PATCH 12/32] feat(core): Always accept the server's own DSN in the managed tunnel --- packages/core/src/types/options.ts | 2 +- packages/core/src/utils/tunnel.ts | 27 +++++++++++-------- packages/core/test/lib/utils/tunnel.test.ts | 29 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index e5b7be5d96dc..d2896dd3979b 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -656,7 +656,7 @@ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; /** Options for the managed tunnel, see `tunnel`. */ export interface TunnelOptions { /** - * Server only. DSNs whose envelopes the tunnel forwards. Defaults to the server's own DSN, so list the browser DSN + * Server only. DSNs whose envelopes the tunnel forwards, in addition to the server's own DSN. List the browser DSN * here when the browser uses a different one. */ allowedDsns?: string[]; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 48083f05d7f8..df2debe0f8e1 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -16,7 +16,7 @@ export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */ + /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ allowedDsns?: Array; } @@ -98,8 +98,9 @@ export function getTunnelIgnoreSpanFilter( tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { - const allowedDsns = tunnelOptions.allowedDsns ?? (dsn ? [dsnToString(dsn)] : []); - const paths = tunnelPathsFor(tunnelOptions, allowedDsns).map(escapeStringForRegex); + const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( + escapeStringForRegex, + ); if (!paths.length) { return undefined; } @@ -317,14 +318,17 @@ function forwardHeaders(request: Request): Record { return headers; } -function resolveAllowedDsns(allowedDsns: Array | undefined): Array { - if (allowedDsns) { - return allowedDsns; - } - - const dsn = getClient()?.getDsn(); +/** + * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock + * out a browser that shares it. It goes last, because React Router can serve only the first path. + */ +function resolveAllowedDsns( + allowedDsns: Array | undefined, + ownDsn: DsnComponents | undefined = getClient()?.getDsn(), +): Array { + const dsns = [...(allowedDsns ?? []), ...(ownDsn ? [dsnToString(ownDsn)] : [])]; - return dsn ? [dsnToString(dsn)] : []; + return [...new Set(dsns)]; } /** @@ -362,5 +366,6 @@ function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array return [tunnelOptions.path.replace(/\/$/, '')]; } - return allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path); + // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. + return [...new Set(allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path))]; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 846a1c208696..604801391e9e 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -273,6 +273,23 @@ describe('handleTunnelRequest', () => { expect(suppressed).toBe(true); }); + it('accepts the DSN of the active client on top of allowedDsns', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValue(new Response('ok')); + + const ownDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + const unlistedDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://other@o3.ingest.sentry.io/7' }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + + expect(ownDsnResult.status).toBe(200); + expect(unlistedDsnResult.status).toBe(403); + }); + it('defaults allowedDsns to the DSN of the active client', async () => { setTestClient(TEST_DSN); fetchMock.mockResolvedValueOnce(new Response('ok')); @@ -432,6 +449,13 @@ describe('getTunnelPaths', () => { expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); }); + it('serves the path of the active client DSN on top of allowedDsns, once', () => { + const browserDsn = 'https://browser@o2.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn, TEST_DSN] }); + + expect(getTunnelPaths()).toEqual([getTunnelPath(browserDsn), getTunnelPath(TEST_DSN)]); + }); + it('uses the managed tunnel config of the active client', () => { expect(getTunnelPaths()).toEqual([]); @@ -513,6 +537,11 @@ describe('getTunnelIgnoreSpanFilter', () => { getTunnelPath(otherDsn)!, ), ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(TEST_DSN)!, + ), + ).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); expect( From 3bad53aac106938d87ba7528400b8834f4040856 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:47:06 -0400 Subject: [PATCH 13/32] feat(core): Add `applyTunnelDefaults` so framework SDKs fill in the base path the same way --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 20 ++++++++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 23 +++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f753236a441a..4a15ed3fdf32 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,6 +103,7 @@ export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { + applyTunnelDefaults, getTunnelPath, getTunnelPaths, handleTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index df2debe0f8e1..97f7c2f6c1ab 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -44,6 +44,26 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; } +/** + * Fills in the defaults a framework SDK knows about its app, like the base path, for a managed tunnel. Options the + * user set win. A relative `tunnel` path is a managed tunnel with that `path`, so it gets the defaults too, while a + * URL and an unset `tunnel` are returned as they are. + */ +export function applyTunnelDefaults( + tunnel: string | true | TunnelOptions | undefined, + defaults: TunnelOptions | undefined, +): string | true | TunnelOptions | undefined { + if (!tunnel || !defaults || !Object.keys(defaults).length) { + return tunnel; + } + + if (typeof tunnel === 'string') { + return tunnel.startsWith('/') ? { ...defaults, path: tunnel } : tunnel; + } + + return { ...defaults, ...(tunnel === true ? {} : tunnel) }; +} + /** * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. * diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 604801391e9e..1b3f0e14c890 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -5,6 +5,7 @@ import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { + applyTunnelDefaults, getTunnelPath, getTunnelIgnoreSpanFilter, getTunnelPaths, @@ -601,6 +602,28 @@ describe('resolveServerTunnelOption', () => { }); }); +describe('applyTunnelDefaults', () => { + it('fills in the defaults for `true` and options, and lets options the user set win', () => { + expect(applyTunnelDefaults(true, { basePath: '/app' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults({ allowedDsns: [TEST_DSN] }, { basePath: '/app' })).toEqual({ + basePath: '/app', + allowedDsns: [TEST_DSN], + }); + expect(applyTunnelDefaults({ basePath: '/custom' }, { basePath: '/app' })).toEqual({ basePath: '/custom' }); + }); + + it('treats a relative path as a managed tunnel with that path', () => { + expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + }); + + it('leaves a URL, an unset tunnel and empty defaults alone', () => { + expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); + expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); + expect(applyTunnelDefaults('/monitor', {})).toBe('/monitor'); + expect(applyTunnelDefaults(true, undefined)).toBe(true); + }); +}); + function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From 14899378aaf302b09a3e1e791a5a5958f0db7cb0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:59:20 -0400 Subject: [PATCH 14/32] feat(core): Add `setManagedTunnelBasePath` for servers that learn the base path after `init` --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 21 ++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 38 +++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a15ed3fdf32..f8dd7cb802da 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -110,6 +110,7 @@ export { handleTunnelRequestIfMatched, isTunnelRequest, resolveServerTunnelOption, + setManagedTunnelBasePath, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 97f7c2f6c1ab..eab2264cce7e 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,4 +1,5 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; @@ -131,6 +132,26 @@ export function getTunnelIgnoreSpanFilter( return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } +/** + * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime + * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. + */ +export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { + const options = client?.getOptions(); + const tunnelOptions = options?._managedTunnel; + const normalizedBasePath = basePath?.replace(/\/+$/, ''); + if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { + return; + } + + options._managedTunnel = { ...tunnelOptions, basePath: normalizedBasePath }; + + const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); + if (spanFilter) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), spanFilter]; + } +} + /** * Returns true if the request targets the managed tunnel. * diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 1b3f0e14c890..9553a573010d 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -13,6 +13,7 @@ import { handleTunnelRequestIfMatched, isTunnelRequest, resolveServerTunnelOption, + setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; import type { TunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -624,6 +625,43 @@ describe('applyTunnelDefaults', () => { }); }); +describe('setManagedTunnelBasePath', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('serves the tunnel under the base path and drops its spans there', () => { + setTestClient(TEST_DSN, {}); + const client = getCurrentScope().getClient()!; + + setManagedTunnelBasePath(client, '/app/'); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); + expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) })).toBe(false); + expect(client.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN)), + ); + }); + + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { + setTestClient(TEST_DSN, { basePath: '/custom' }); + const client = getCurrentScope().getClient()!; + setManagedTunnelBasePath(client, '/app'); + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + + setTestClient(TEST_DSN, {}); + const rootClient = getCurrentScope().getClient()!; + setManagedTunnelBasePath(rootClient, '/'); + expect(rootClient.getOptions()._managedTunnel).toEqual({}); + + setTestClient(TEST_DSN); + const offClient = getCurrentScope().getClient()!; + setManagedTunnelBasePath(offClient, '/app'); + expect(offClient.getOptions()._managedTunnel).toBeUndefined(); + }); +}); + function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From 94e06cbfa52bd3950dd90bf552bd57b1f2ba5210 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 15:13:08 -0400 Subject: [PATCH 15/32] feat(core): Mark the tunnel handler's responses so the browser can tell them apart --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 20 ++++++++++++++ packages/core/test/lib/utils/tunnel.test.ts | 29 ++++++++++++++++++--- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8dd7cb802da..41f56d747dd7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -111,6 +111,7 @@ export { isTunnelRequest, resolveServerTunnelOption, setManagedTunnelBasePath, + TUNNEL_RESPONSE_HEADER, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index eab2264cce7e..ded38f2c004d 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -196,6 +196,26 @@ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelO * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { + return markTunnelResponse(await forwardTunnelRequest(options)); +} + +/** + * Set on every response the tunnel handler returns, so a browser can tell it apart from whatever else answers the + * tunnel path, e.g. a static host serving `index.html` for every path. + */ +export const TUNNEL_RESPONSE_HEADER = 'X-Sentry-Tunnel'; + +function markTunnelResponse(response: Response): Response { + const headers = new Headers(response.headers); + // `fetch` already decoded the body, so its original encoding and length no longer describe it. + headers.delete('content-encoding'); + headers.delete('content-length'); + headers.set(TUNNEL_RESPONSE_HEADER, '1'); + + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promise { const { request } = options; const allowedDsns = resolveAllowedDsns(options.allowedDsns); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 9553a573010d..0b15befeb9fd 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -76,7 +76,30 @@ describe('handleTunnelRequest', () => { expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); - expect(result).toBe(upstreamResponse); + expect(result.status).toBe(200); + expect(await result.text()).toBe('ok'); + }); + + it("marks every response it returns as the tunnel's, so the browser can tell it from a static host", async () => { + fetchMock.mockResolvedValueOnce( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } }), + ); + + const forwarded = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }); + const rejected = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://public@other.ingest.sentry.io/1' }), + allowedDsns: [TEST_DSN], + }); + + expect(forwarded.headers.get('X-Sentry-Tunnel')).toBe('1'); + expect(forwarded.headers.get('Content-Type')).toBe('application/json'); + // `fetch` already decoded the body, so the original encoding would make the browser decode it twice. + expect(forwarded.headers.get('Content-Encoding')).toBeNull(); + expect(rejected.status).toBe(403); + expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); }); it('forwards the content length of the incoming request', async () => { @@ -154,7 +177,7 @@ describe('handleTunnelRequest', () => { allowedDsns: [TEST_DSN], }); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 400 when the envelope header line exceeds the limit', async () => { @@ -246,7 +269,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchMock.mock.calls[0]!; expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 500 when fetch throws a network error', async () => { From bb03470c8608c0c3644841ca6c8f05015c22b666 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:00:11 -0400 Subject: [PATCH 16/32] fix(core): Normalize tunnel base paths with one util that doesn't backtrack --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 15 ++++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41f56d747dd7..578bc3e949b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,7 @@ export { handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, + normalizeTunnelBasePath, resolveServerTunnelOption, setManagedTunnelBasePath, TUNNEL_RESPONSE_HEADER, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index ded38f2c004d..271cc076db80 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -126,12 +126,21 @@ export function getTunnelIgnoreSpanFilter( return undefined; } - const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; } +/** + * Normalizes a framework's base path to the `/app` form the managed tunnel is served under, or `''` for the root. + */ +export function normalizeTunnelBasePath(basePath: string | undefined): string { + const path = (basePath?.startsWith('./') ? basePath.slice(1) : (basePath ?? '')).replace(/\/$/, ''); + + return !path || path.startsWith('/') ? path : `/${path}`; +} + /** * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. @@ -139,7 +148,7 @@ export function getTunnelIgnoreSpanFilter( export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { const options = client?.getOptions(); const tunnelOptions = options?._managedTunnel; - const normalizedBasePath = basePath?.replace(/\/+$/, ''); + const normalizedBasePath = normalizeTunnelBasePath(basePath); if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { return; } @@ -396,7 +405,7 @@ function resolveAllowedDsns( * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. */ function stripBasePath(pathname: string, basePath: string | undefined): string | undefined { - const base = basePath?.replace(/\/$/, ''); + const base = normalizeTunnelBasePath(basePath); if (!base) { return pathname; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 0b15befeb9fd..0594eef69187 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -12,6 +12,7 @@ import { handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, + normalizeTunnelBasePath, resolveServerTunnelOption, setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; @@ -648,6 +649,21 @@ describe('applyTunnelDefaults', () => { }); }); +describe('normalizeTunnelBasePath', () => { + it.each([ + ['/app', '/app'], + ['/app/', '/app'], + ['app', '/app'], + ['./app/', '/app'], + ['./', ''], + ['/', ''], + ['', ''], + [undefined, ''], + ])('normalizes %j to %j', (input, expected) => { + expect(normalizeTunnelBasePath(input)).toBe(expected); + }); +}); + describe('setManagedTunnelBasePath', () => { afterEach(() => { getCurrentScope().setClient(undefined); From d39345a747de92a94a5a95cf70523830052b124b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:07:25 -0400 Subject: [PATCH 17/32] fix(core): Leave a fixed tunnel path to the app when there is no DSN to forward to --- packages/core/src/utils/tunnel.ts | 5 +++++ packages/core/test/lib/utils/tunnel.test.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 271cc076db80..c93c6aefb24e 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -192,6 +192,11 @@ export function handleTunnelRequestIfMatched(request: Request, options?: TunnelO } const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes + // the browser send directly, instead of losing every envelope to an error response. + if (!resolveAllowedDsns(allowedDsns).length) { + return undefined; + } return handleTunnelRequest({ request, allowedDsns }); } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 0594eef69187..9b75a8da8582 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -531,6 +531,15 @@ describe('handleTunnelRequestIfMatched', () => { ); }); + it('leaves a fixed path to the app when there is no DSN to forward to', () => { + setTestClient(undefined, { path: '/monitor' }); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(handleTunnelRequestIfMatched(request, { path: '/monitor' })).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + }); + it('returns undefined without reading the body when the request does not match', () => { const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); @@ -701,6 +710,6 @@ describe('setManagedTunnelBasePath', () => { }); }); -function setTestClient(dsn: string, managedTunnel?: TunnelOptions): void { +function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); } From 986314235a05ca970ccbe58b315849315e965b48 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:41:09 -0400 Subject: [PATCH 18/32] fix(core): Don't add the base path again to a tunnel path that already starts with it --- packages/core/src/utils/tunnel.ts | 16 +++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 23 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index c93c6aefb24e..8623524d0190 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -59,10 +59,10 @@ export function applyTunnelDefaults( } if (typeof tunnel === 'string') { - return tunnel.startsWith('/') ? { ...defaults, path: tunnel } : tunnel; + return tunnel.startsWith('/') ? withPathBelowBasePath({ ...defaults, path: tunnel }) : tunnel; } - return { ...defaults, ...(tunnel === true ? {} : tunnel) }; + return withPathBelowBasePath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); } /** @@ -153,7 +153,7 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s return; } - options._managedTunnel = { ...tunnelOptions, basePath: normalizedBasePath }; + options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); if (spanFilter) { @@ -406,6 +406,16 @@ function resolveAllowedDsns( return [...new Set(dsns)]; } +/** + * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to + * be before the SDK added the base path, would otherwise be served under the base path twice. + */ +function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { + const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); + + return path ? { ...tunnelOptions, path } : tunnelOptions; +} + /** * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. */ diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 9b75a8da8582..11357a314fac 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -650,6 +650,18 @@ describe('applyTunnelDefaults', () => { expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); }); + it('does not add the base path again to a path that already starts with it', () => { + expect(applyTunnelDefaults('/app/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + expect(applyTunnelDefaults({ path: '/app/monitor' }, { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/monitor', + }); + expect(applyTunnelDefaults('/application/monitor', { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/application/monitor', + }); + }); + it('leaves a URL, an unset tunnel and empty defaults alone', () => { expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); @@ -692,6 +704,17 @@ describe('setManagedTunnelBasePath', () => { ); }); + it('does not add the base path again to a fixed path that already starts with it', () => { + setTestClient(TEST_DSN, { path: '/app/monitor' }); + const client = getCurrentScope().getClient()!; + + setManagedTunnelBasePath(client, '/app'); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); + expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/app/app/monitor' })).toBe(false); + }); + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { setTestClient(TEST_DSN, { basePath: '/custom' }); const client = getCurrentScope().getClient()!; From bbc866d3ff5ebccb7d6ce2bbe611ec3f0d6ebe0c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:28:57 -0400 Subject: [PATCH 19/32] fix(core): Don't report a client that drops the tunnel request as an error --- packages/core/src/utils/tunnel.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 8623524d0190..ee1c4411b2ed 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -238,7 +238,9 @@ async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promis } const reader = request.body?.getReader(); - const head = reader && (await readEnvelopeHeaderLine(reader)); + // A client that drops the connection mid-upload makes the read throw. That is the client going away, not an app + // error, so it gets the same answer as any other unreadable envelope. + const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); if (!reader || !head) { return new Response('Invalid envelope', { status: 400 }); } From 68508f9860db65211faadaa60d6c5ce5fae8263c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:24:57 -0400 Subject: [PATCH 20/32] fix(core): Also drop tunnel spans that only carry `http.url`, like SvelteKit's root span --- packages/core/src/client.ts | 10 +++++--- packages/core/src/utils/tunnel.ts | 42 ++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b87a294792ac..621faec3dc49 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,7 +41,7 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; -import { getTunnelIgnoreSpanFilter } from './utils/tunnel'; +import { getTunnelIgnoreSpanFilters } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -272,9 +272,11 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } - const tunnelSpanFilter = options._managedTunnel && getTunnelIgnoreSpanFilter(options._managedTunnel, this._dsn); - if (tunnelSpanFilter) { - this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), tunnelSpanFilter]; + const tunnelSpanFilters = options._managedTunnel + ? getTunnelIgnoreSpanFilters(options._managedTunnel, this._dsn) + : []; + if (tunnelSpanFilters.length) { + this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), ...tunnelSpanFilters]; } const { beforeSendSpan, traceLifecycle } = this._options; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index ee1c4411b2ed..e9854b259305 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -119,6 +119,37 @@ export function getTunnelIgnoreSpanFilter( tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { + const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + return pathPattern ? { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } } : undefined; +} + +/** + * The `ignoreSpans` filters a client installs for tunnel requests. Besides `url.path`, they match the legacy + * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. + */ +export function getTunnelIgnoreSpanFilters( + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter[] { + const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); + if (!pathPattern) { + return []; + } + + return [ + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } }, + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { 'http.url': new RegExp(`^[a-z][a-z\\d+.-]*://[^/]+${pathPattern}(?:[?#].*)?$`) } }, + ]; +} + +/** + * The tunnel paths under the base path, as a regex source matching a pathname with an optional trailing slash. + */ +function getTunnelPathPattern(tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined): string | undefined { const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( escapeStringForRegex, ); @@ -128,8 +159,7 @@ export function getTunnelIgnoreSpanFilter( const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); - // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return { attributes: { [URL_PATH]: new RegExp(`^${basePath}(?:${paths.join('|')})/?$`) } }; + return `${basePath}(?:${paths.join('|')})/?`; } /** @@ -155,9 +185,9 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); - const spanFilter = getTunnelIgnoreSpanFilter(options._managedTunnel, client?.getDsn()); - if (spanFilter) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), spanFilter]; + const spanFilters = getTunnelIgnoreSpanFilters(options._managedTunnel, client?.getDsn()); + if (spanFilters.length) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; } } @@ -397,7 +427,7 @@ function forwardHeaders(request: Request): Record { /** * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock - * out a browser that shares it. It goes last, because React Router can serve only the first path. + * out a browser that shares it. */ function resolveAllowedDsns( allowedDsns: Array | undefined, From e832e3d2a58d25c3eb723ccb4aa8a7de72971a4a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:40:02 -0400 Subject: [PATCH 21/32] feat(core): Add enableManagedTunnel for framework options that enable the tunnel outside init --- packages/core/src/index.ts | 1 + packages/core/src/utils/tunnel.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 578bc3e949b9..ad62826a3f51 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -104,6 +104,7 @@ export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; export { applyTunnelDefaults, + enableManagedTunnel, getTunnelPath, getTunnelPaths, handleTunnelRequest, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index e9854b259305..acd2580385ae 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -191,6 +191,24 @@ export function setManagedTunnelBasePath(client: Client | undefined, basePath: s } } +/** + * Enables the managed tunnel on a client whose `init` didn't, from an option a framework SDK received elsewhere, + * like a build option, and drops the tunnel's spans from then on. A managed tunnel the `init` enabled wins. + */ +export function enableManagedTunnel(client: Client | undefined, tunnelOptions: TunnelOptions): void { + const options = client?.getOptions(); + if (!options || options._managedTunnel) { + return; + } + + options._managedTunnel = tunnelOptions; + + const spanFilters = getTunnelIgnoreSpanFilters(tunnelOptions, client?.getDsn()); + if (spanFilters.length) { + options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; + } +} + /** * Returns true if the request targets the managed tunnel. * From 59ab0de042b9ba57cf8774ac0e1ccdf618de499c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:55:15 -0400 Subject: [PATCH 22/32] ref(core): Settle a server client's managed tunnel in one place, from init or framework defaults Framework SDKs report tunnel options and the base path once with setManagedTunnelDefaults, and ServerRuntimeClient applies them when it's created, so per-request clients get the span filters before their first span starts. The span filters move out of the base Client into the server client, and the server-only helpers move to @sentry/core/server. --- packages/core/src/client.ts | 8 --- packages/core/src/index.ts | 8 +-- packages/core/src/server-runtime-client.ts | 2 + packages/core/src/server.ts | 8 +++ .../core/src/utils/managedTunnelDefaults.ts | 72 +++++++++++++++++++ packages/core/src/utils/tunnel.ts | 64 ++--------------- packages/core/test/lib/utils/tunnel.test.ts | 72 ++++++++++++++----- 7 files changed, 143 insertions(+), 91 deletions(-) create mode 100644 packages/core/src/utils/managedTunnelDefaults.ts diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 621faec3dc49..c61b3975d41b 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,7 +41,6 @@ import type { ResolvedDataCollection } from './types/datacollection'; import { createClientReportEnvelope } from './utils/clientreport'; import { consoleSandbox, debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; -import { getTunnelIgnoreSpanFilters } from './utils/tunnel'; import { addItemToEnvelope, createAttachmentEnvelopeItem, getDataCategoryByType } from './utils/envelope'; import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive } from './utils/is'; @@ -272,13 +271,6 @@ export abstract class Client { DEBUG_BUILD && debug.warn('No DSN provided, client will not send events.'); } - const tunnelSpanFilters = options._managedTunnel - ? getTunnelIgnoreSpanFilters(options._managedTunnel, this._dsn) - : []; - if (tunnelSpanFilters.length) { - this._options.ignoreSpans = [...(this._options.ignoreSpans ?? []), ...tunnelSpanFilters]; - } - const { beforeSendSpan, traceLifecycle } = this._options; // A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle, // so a mismatch means it is silently never called. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad62826a3f51..b7172593df2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,18 +101,12 @@ export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; -export type { HandleTunnelRequestOptions, TunnelRequestLike } from './utils/tunnel'; +export type { HandleTunnelRequestOptions } from './utils/tunnel'; export { applyTunnelDefaults, - enableManagedTunnel, getTunnelPath, - getTunnelPaths, handleTunnelRequest, - handleTunnelRequestIfMatched, - isTunnelRequest, normalizeTunnelBasePath, - resolveServerTunnelOption, - setManagedTunnelBasePath, TUNNEL_RESPONSE_HEADER, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 159c4aeb3b93..9dcc4512bea4 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -21,6 +21,7 @@ import { uuid4 } from './utils/misc'; import { makePromiseBuffer } from './utils/promisebuffer'; import { resolvedSyncPromise } from './utils/syncpromise'; import { _getTraceInfoFromScope } from './utils/trace-info'; +import { applyManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -58,6 +59,7 @@ export class ServerRuntimeClient< super(options); + applyManagedTunnelDefaults(this); this._setUpMetricsProcessing(); } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 73f0c08e174f..d2158c8d6ab3 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -6,6 +6,14 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export { ServerRuntimeClient } from './server-runtime-client'; +export type { TunnelRequestLike } from './utils/tunnel'; +export { + getTunnelPaths, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from './utils/tunnel'; +export { _INTERNAL_resetManagedTunnelDefaults, setManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export type { ServerRuntimeOptions } from './types/options'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts new file mode 100644 index 000000000000..df84bd3be34f --- /dev/null +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -0,0 +1,72 @@ +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, withPathBelowBasePath } from './tunnel'; + +/** + * Managed tunnel settings a framework SDK knows outside `init`. + */ +interface ManagedTunnelDefaults { + /** Enables the managed tunnel, e.g. from TanStack Start's `tunnelRoute` build option. */ + tunnel?: TunnelOptions; + /** The path prefix the app is served under. */ + basePath?: string; +} + +let managedTunnelDefaults: ManagedTunnelDefaults | undefined; + +// The span filters each client got for its managed tunnel, so applying new defaults replaces them. +const installedSpanFilters = new WeakMap(); + +/** + * Sets the managed tunnel settings a framework SDK knows outside `init`, for every server client created afterwards + * and for the current one. Clients created per request, like on Cloudflare, then start with the tunnel and its span + * filters, so not even their first tunnel request's span gets through. A managed tunnel or `basePath` that `init` + * set wins. + * + * Framework SDKs call this once, when their server code loads. + */ +export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { + managedTunnelDefaults = { + ...managedTunnelDefaults, + ...(defaults.tunnel && { tunnel: defaults.tunnel }), + ...(defaults.basePath !== undefined && { basePath: defaults.basePath }), + }; + applyManagedTunnelDefaults(getClient()); +} + +/** + * Settles a server client's managed tunnel: the one its `init` enabled, or else the defaults, under the default base + * path, and installs the span filters for it. Server clients call this when they're created, and + * {@link setManagedTunnelDefaults} calls it again for a client that already exists, replacing the filters it added. + */ +export function applyManagedTunnelDefaults(client: Client | undefined): void { + const options = client?.getOptions(); + const tunnelOptions = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; + if (!client || !options || !tunnelOptions) { + return; + } + + const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); + const resolved = withPathBelowBasePath( + tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, + ); + options._managedTunnel = resolved; + + const previousFilters = installedSpanFilters.get(client) ?? []; + const spanFilters = getTunnelIgnoreSpanFilters(resolved, client.getDsn()); + options.ignoreSpans = [ + ...(options.ignoreSpans ?? []).filter(filter => !(previousFilters as unknown[]).includes(filter)), + ...spanFilters, + ]; + installedSpanFilters.set(client, spanFilters); +} + +/** + * Resets {@link setManagedTunnelDefaults}, for tests. + * + * @internal + */ +export function _INTERNAL_resetManagedTunnelDefaults(): void { + managedTunnelDefaults = undefined; +} diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index acd2580385ae..8c3caad3657f 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,5 +1,4 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; -import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; @@ -112,21 +111,8 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { } /** - * An `ignoreSpans` filter for the server spans of tunnel requests. Server SDKs start the span before a framework - * hook gets to answer the request, so clients add this whenever the managed tunnel is enabled. - */ -export function getTunnelIgnoreSpanFilter( - tunnelOptions: TunnelOptions, - dsn: DsnComponents | undefined, -): IgnoreSpanFilter | undefined { - const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); - - // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input - return pathPattern ? { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } } : undefined; -} - -/** - * The `ignoreSpans` filters a client installs for tunnel requests. Besides `url.path`, they match the legacy + * The `ignoreSpans` filters a server client installs for the server spans of tunnel requests, which server SDKs start + * before a framework hook gets to answer the request. Besides `url.path`, they match the legacy * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. */ export function getTunnelIgnoreSpanFilters( @@ -171,44 +157,6 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { return !path || path.startsWith('/') ? path : `/${path}`; } -/** - * Serves the client's managed tunnel under a base path the framework only knows after `init`, e.g. from its runtime - * config, and drops the tunnel's server spans under it. A `basePath` the user set wins. - */ -export function setManagedTunnelBasePath(client: Client | undefined, basePath: string | undefined): void { - const options = client?.getOptions(); - const tunnelOptions = options?._managedTunnel; - const normalizedBasePath = normalizeTunnelBasePath(basePath); - if (!options || !tunnelOptions || tunnelOptions.basePath !== undefined || !normalizedBasePath) { - return; - } - - options._managedTunnel = withPathBelowBasePath({ ...tunnelOptions, basePath: normalizedBasePath }); - - const spanFilters = getTunnelIgnoreSpanFilters(options._managedTunnel, client?.getDsn()); - if (spanFilters.length) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; - } -} - -/** - * Enables the managed tunnel on a client whose `init` didn't, from an option a framework SDK received elsewhere, - * like a build option, and drops the tunnel's spans from then on. A managed tunnel the `init` enabled wins. - */ -export function enableManagedTunnel(client: Client | undefined, tunnelOptions: TunnelOptions): void { - const options = client?.getOptions(); - if (!options || options._managedTunnel) { - return; - } - - options._managedTunnel = tunnelOptions; - - const spanFilters = getTunnelIgnoreSpanFilters(tunnelOptions, client?.getDsn()); - if (spanFilters.length) { - options.ignoreSpans = [...(options.ignoreSpans ?? []), ...spanFilters]; - } -} - /** * Returns true if the request targets the managed tunnel. * @@ -234,12 +182,12 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOpti * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller * can hand the request to the app. */ -export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise | undefined { - if (!isTunnelRequest(request, options)) { +export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { + if (!isTunnelRequest(request)) { return undefined; } - const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns; + const allowedDsns = getClient()?.getOptions()._managedTunnel?.allowedDsns; // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes // the browser send directly, instead of losing every envelope to an error response. if (!resolveAllowedDsns(allowedDsns).length) { @@ -460,7 +408,7 @@ function resolveAllowedDsns( * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to * be before the SDK added the base path, would otherwise be served under the base path twice. */ -function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { +export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); return path ? { ...tunnelOptions, path } : tunnelOptions; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 11357a314fac..3c9182808076 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -7,20 +7,32 @@ import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { applyTunnelDefaults, getTunnelPath, - getTunnelIgnoreSpanFilter, + getTunnelIgnoreSpanFilters, getTunnelPaths, handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, normalizeTunnelBasePath, resolveServerTunnelOption, - setManagedTunnelBasePath, } from '../../../src/utils/tunnel'; -import type { TunnelOptions } from '../../../src/types/options'; +import { + _INTERNAL_resetManagedTunnelDefaults, + applyManagedTunnelDefaults, + setManagedTunnelDefaults, +} from '../../../src/utils/managedTunnelDefaults'; +import type { IgnoreSpanFilter, TunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { ServerRuntimeClient } from '../../../src/server-runtime-client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +/** The `url.path` filter, the first of the tunnel span filters. */ +function getTunnelIgnoreSpanFilter( + ...args: Parameters +): IgnoreSpanFilter | undefined { + return getTunnelIgnoreSpanFilters(...args)[0]; +} + function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); @@ -508,9 +520,10 @@ describe('handleTunnelRequestIfMatched', () => { it('forwards a matching request', async () => { fetchMock.mockResolvedValueOnce(new Response('ok')); + setTestClient(TEST_DSN, {}); const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); - const result = await handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] }); + const result = await handleTunnelRequestIfMatched(request); expect(result?.status).toBe(200); expect(fetchMock).toHaveBeenCalledOnce(); @@ -536,14 +549,14 @@ describe('handleTunnelRequestIfMatched', () => { const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); - expect(handleTunnelRequestIfMatched(request, { path: '/monitor' })).toBeUndefined(); expect(request.bodyUsed).toBe(false); }); it('returns undefined without reading the body when the request does not match', () => { + setTestClient(TEST_DSN, {}); const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); - expect(handleTunnelRequestIfMatched(request, { allowedDsns: [TEST_DSN] })).toBeUndefined(); + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); expect(request.bodyUsed).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -594,9 +607,9 @@ describe('getTunnelIgnoreSpanFilter', () => { }); }); -describe('client with the managed tunnel enabled', () => { +describe('server client with the managed tunnel enabled', () => { it('ignores the server spans of tunnel requests and keeps user filters', () => { - const client = new TestClient( + const client = new ServerRuntimeClient( getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), ); const ignoreSpans = client.getOptions().ignoreSpans!; @@ -607,10 +620,13 @@ describe('client with the managed tunnel enabled', () => { ).toBe(true); }); - it('leaves ignoreSpans alone without the managed tunnel', () => { - const client = new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN })); - - expect(client.getOptions().ignoreSpans).toBeUndefined(); + it('leaves ignoreSpans alone without the managed tunnel, and in non-server clients', () => { + expect( + new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN })).getOptions().ignoreSpans, + ).toBeUndefined(); + expect( + new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })).getOptions().ignoreSpans, + ).toBeUndefined(); }); }); @@ -685,16 +701,17 @@ describe('normalizeTunnelBasePath', () => { }); }); -describe('setManagedTunnelBasePath', () => { +describe('setManagedTunnelDefaults', () => { afterEach(() => { getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); }); it('serves the tunnel under the base path and drops its spans there', () => { setTestClient(TEST_DSN, {}); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app/'); + setManagedTunnelDefaults({ basePath: '/app/' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); @@ -708,7 +725,7 @@ describe('setManagedTunnelBasePath', () => { setTestClient(TEST_DSN, { path: '/app/monitor' }); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); @@ -718,19 +735,38 @@ describe('setManagedTunnelBasePath', () => { it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { setTestClient(TEST_DSN, { basePath: '/custom' }); const client = getCurrentScope().getClient()!; - setManagedTunnelBasePath(client, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + _INTERNAL_resetManagedTunnelDefaults(); setTestClient(TEST_DSN, {}); const rootClient = getCurrentScope().getClient()!; - setManagedTunnelBasePath(rootClient, '/'); + setManagedTunnelDefaults({ basePath: '/' }); expect(rootClient.getOptions()._managedTunnel).toEqual({}); + _INTERNAL_resetManagedTunnelDefaults(); setTestClient(TEST_DSN); const offClient = getCurrentScope().getClient()!; - setManagedTunnelBasePath(offClient, '/app'); + setManagedTunnelDefaults({ basePath: '/app' }); expect(offClient.getOptions()._managedTunnel).toBeUndefined(); }); + + it('enables the tunnel on clients created later, unless their `init` set one', () => { + setManagedTunnelDefaults({ tunnel: { path: '/monitor' }, basePath: '/app' }); + + setTestClient(TEST_DSN); + const laterClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(laterClient); + expect(laterClient.getOptions()._managedTunnel).toEqual({ path: '/monitor', basePath: '/app' }); + expect(laterClient.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ path: '/monitor', basePath: '/app' }, makeDsn(TEST_DSN)), + ); + + setTestClient(TEST_DSN, { path: '/from-init' }); + const initClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(initClient); + expect(initClient.getOptions()._managedTunnel).toEqual({ path: '/from-init', basePath: '/app' }); + }); }); function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { From b74bf04418532d2d88d695212c3e2c679c710bf4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 11:26:56 -0400 Subject: [PATCH 23/32] test(nextjs): Read back the streamed body in the tunnel route test The core tunnel handler now streams the envelope to Sentry instead of buffering it, so the forwarded body is a stream. --- packages/nextjs/test/tunnel/index.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 0df557baa736..516543a2b5a9 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -59,12 +59,14 @@ describe('POST', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( 'https://o1.ingest.sentry.io/api/1/envelope/?sentry_version=7&sentry_key=public', - { + expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/x-sentry-envelope' }, - body, - }, + }), ); + // The body streams through, so it is read back to compare it. + const [, init] = fetchSpy.mock.calls[0]!; + expect(new Uint8Array(await new Response((init as RequestInit).body).arrayBuffer())).toEqual(body); }); it('responds with 500 when the server SDK has no DSN', async () => { From b21a043cc13c4551723d12f6a00bed98b7acb70f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 12:13:58 -0400 Subject: [PATCH 24/32] ref(core): Compute the managed tunnel paths once per server client The paths only depend on the client's tunnel options and DSN, so they're derived when the managed tunnel is settled and read per request. This drops the path cache, which the browser never needed, and the options forms of `isTunnelRequest` and `getTunnelPaths`. --- packages/core/src/types/options.ts | 7 ++ .../core/src/utils/managedTunnelDefaults.ts | 15 +++- packages/core/src/utils/tunnel.ts | 87 ++++++------------ packages/core/test/lib/utils/tunnel.test.ts | 88 ++++++++++++------- 4 files changed, 102 insertions(+), 95 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index d2896dd3979b..2a877219f01b 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -381,6 +381,13 @@ export interface ClientOptions !(previousFilters as unknown[]).includes(filter)), ...spanFilters, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 8c3caad3657f..08d9772378ee 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -20,10 +20,6 @@ export interface HandleTunnelRequestOptions { allowedDsns?: Array; } -// Servers derive the paths on every POST, so parse and hash each DSN once. This also keeps an invalid DSN in -// `allowedDsns` from logging on every request. Keys come from config, never from requests, so the map stays small. -const tunnelPathCache = new Map(); - /** * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. @@ -71,17 +67,9 @@ export function applyTunnelDefaults( * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. */ export function getTunnelPath(dsn: DsnLike): string | undefined { - if (typeof dsn === 'string' && tunnelPathCache.has(dsn)) { - return tunnelPathCache.get(dsn); - } - const components = makeDsn(dsn); - const path = components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; - if (typeof dsn === 'string') { - tunnelPathCache.set(dsn, path); - } - return path; + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; } /** @@ -96,18 +84,27 @@ export interface TunnelRequestLike { } /** - * Returns the tunnel paths the server accepts, relative to its base path. Frameworks that can only register routes - * use these, everything else should call {@link isTunnelRequest} or {@link handleTunnelRequestIfMatched}. - * - * Without `options`, this uses the managed tunnel config of the active client. + * Returns the tunnel paths the active server client accepts, relative to its base path. Frameworks that can only + * register routes use these, everything else should call {@link isTunnelRequest} or + * {@link handleTunnelRequestIfMatched}. */ -export function getTunnelPaths(options?: TunnelOptions): string[] { - const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; - if (!tunnelOptions) { - return []; +export function getTunnelPaths(): string[] { + return getClient()?.getOptions()._managedTunnelPaths ?? []; +} + +/** + * The paths a server client serves its managed tunnel at: the fixed `path`, or else the path of each allowed DSN and + * of its own. Server clients compute them once, when their managed tunnel is settled, since neither changes later. + */ +export function resolveTunnelPaths(tunnelOptions: TunnelOptions, ownDsn: DsnComponents | undefined): string[] { + if (tunnelOptions.path) { + return [tunnelOptions.path.replace(/\/$/, '')]; } - return tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns)); + // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. + const paths = resolveAllowedDsns(tunnelOptions.allowedDsns, ownDsn).map(dsn => getTunnelPath(dsn)); + + return [...new Set(paths.filter((path): path is string => !!path))]; } /** @@ -115,15 +112,14 @@ export function getTunnelPaths(options?: TunnelOptions): string[] { * before a framework hook gets to answer the request. Besides `url.path`, they match the legacy * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. */ -export function getTunnelIgnoreSpanFilters( - tunnelOptions: TunnelOptions, - dsn: DsnComponents | undefined, -): IgnoreSpanFilter[] { - const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); - if (!pathPattern) { +export function getTunnelIgnoreSpanFilters(tunnelOptions: TunnelOptions, paths: string[]): IgnoreSpanFilter[] { + if (!paths.length) { return []; } + const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); + const pathPattern = `${basePath}(?:${paths.map(escapeStringForRegex).join('|')})/?`; + return [ // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } }, @@ -132,22 +128,6 @@ export function getTunnelIgnoreSpanFilters( ]; } -/** - * The tunnel paths under the base path, as a regex source matching a pathname with an optional trailing slash. - */ -function getTunnelPathPattern(tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined): string | undefined { - const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( - escapeStringForRegex, - ); - if (!paths.length) { - return undefined; - } - - const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); - - return `${basePath}(?:${paths.join('|')})/?`; -} - /** * Normalizes a framework's base path to the `/app` form the managed tunnel is served under, or `''` for the root. */ @@ -158,24 +138,22 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { } /** - * Returns true if the request targets the managed tunnel. - * - * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its + * Returns true if the request targets the managed tunnel of the active client, which matches nothing unless its * `init` was called with `tunnel: true`. */ -export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOptions): boolean { +export function isTunnelRequest(request: TunnelRequestLike): boolean { if (request.method !== 'POST' || !request.url) { return false; } - const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + const tunnelOptions = getClient()?.getOptions()._managedTunnel; if (!tunnelOptions) { return false; } const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); - return path !== undefined && getTunnelPaths(tunnelOptions).includes(path.replace(/\/$/, '')); + return path !== undefined && getTunnelPaths().includes(path.replace(/\/$/, '')); } /** @@ -443,12 +421,3 @@ function fnv1a(input: string): number { // eslint-disable-next-line no-bitwise return hash >>> 0; } - -function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { - if (tunnelOptions.path) { - return [tunnelOptions.path.replace(/\/$/, '')]; - } - - // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. - return [...new Set(allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path))]; -} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 3c9182808076..cccfc52beb4c 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -14,12 +14,14 @@ import { isTunnelRequest, normalizeTunnelBasePath, resolveServerTunnelOption, + resolveTunnelPaths, } from '../../../src/utils/tunnel'; import { _INTERNAL_resetManagedTunnelDefaults, applyManagedTunnelDefaults, setManagedTunnelDefaults, } from '../../../src/utils/managedTunnelDefaults'; +import type { DsnComponents } from '../../../src/types/dsn'; import type { IgnoreSpanFilter, TunnelOptions } from '../../../src/types/options'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; import { ServerRuntimeClient } from '../../../src/server-runtime-client'; @@ -28,9 +30,10 @@ const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; /** The `url.path` filter, the first of the tunnel span filters. */ function getTunnelIgnoreSpanFilter( - ...args: Parameters + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { - return getTunnelIgnoreSpanFilters(...args)[0]; + return getTunnelIgnoreSpanFilters(tunnelOptions, resolveTunnelPaths(tunnelOptions, dsn))[0]; } function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { @@ -375,16 +378,6 @@ describe('getTunnelPath', () => { it('returns undefined for an invalid DSN', () => { expect(getTunnelPath('not-a-dsn')).toBeUndefined(); }); - - it('parses each DSN string once, so an invalid DSN logs once', () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - getTunnelPath('also-not-a-dsn'); - getTunnelPath('also-not-a-dsn'); - - expect(consoleErrorSpy).toHaveBeenCalledOnce(); - consoleErrorSpy.mockRestore(); - }); }); describe('isTunnelRequest', () => { @@ -395,20 +388,22 @@ describe('isTunnelRequest', () => { }); it('matches a POST to the tunnel path of an allowed DSN', () => { - expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); - expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('does not match other methods, other paths, or path prefixes', () => { - expect(isTunnelRequest({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); - expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); }); it('does not match the tunnel path of a DSN that is not allowed', () => { const otherDsn = 'https://other@example.com/9999'; - expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe( + false, + ); }); it('uses the managed tunnel config of the active client when no options are passed', () => { @@ -421,43 +416,54 @@ describe('isTunnelRequest', () => { it('matches nothing without a client', () => { expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); - expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, {})).toBe(false); }); it('matches under a base path', () => { expect( - isTunnelRequest({ method: 'POST', url: `/docs${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs/' }), + isTunnelRequestFor( + { method: 'POST', url: `/docs${tunnelPath}` }, + { allowedDsns: [TEST_DSN], basePath: '/docs/' }, + ), ).toBe(true); expect( - isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + isTunnelRequestFor( + { method: 'POST', url: `/docsx${tunnelPath}` }, + { allowedDsns: [TEST_DSN], basePath: '/docs' }, + ), ).toBe(false); }); it('matches nothing outside the base path', () => { - expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe( + expect( + isTunnelRequestFor({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + ).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe( false, ); - expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe(false); }); }); describe('isTunnelRequest request shapes', () => { const tunnelPath = getTunnelPath(TEST_DSN)!; + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + it('accepts a fetch Request with an absolute URL and query', () => { const request = new Request(`https://example.com${tunnelPath}?o=1`, { method: 'POST' }); - expect(isTunnelRequest(request, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor(request, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('accepts a Node IncomingMessage-like object', () => { const incomingMessage = { method: 'POST', url: `${tunnelPath}?o=1`, headers: { host: 'example.com' } }; - expect(isTunnelRequest(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); }); it('does not match without a URL', () => { - expect(isTunnelRequest({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); }); }); @@ -469,22 +475,22 @@ describe('getTunnelPaths', () => { it('returns one path per allowed DSN and skips invalid ones', () => { const otherDsn = 'https://other@o2.ingest.sentry.io/42'; - expect(getTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] })).toEqual([ + expect(resolveTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] }, undefined)).toEqual([ getTunnelPath(TEST_DSN), getTunnelPath(otherDsn), ]); }); it('returns a fixed path instead of the DSN-derived ones', () => { - expect(getTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] })).toEqual(['/monitor']); - expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( + expect(resolveTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] }, undefined)).toEqual(['/monitor']); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( true, ); }); it('matches a fixed path that ends with a slash', () => { - expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); - expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); }); it('serves the path of the active client DSN on top of allowedDsns, once', () => { @@ -503,6 +509,17 @@ describe('getTunnelPaths', () => { setTestClient(TEST_DSN, {}); expect(getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); }); + + it('derives the paths once per client, so an invalid DSN in allowedDsns logs once', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setTestClient(TEST_DSN, { allowedDsns: ['also-not-a-dsn'] }); + + isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) }); + isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) }); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + consoleErrorSpy.mockRestore(); + }); }); describe('handleTunnelRequestIfMatched', () => { @@ -770,5 +787,12 @@ describe('setManagedTunnelDefaults', () => { }); function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { - setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); + setCurrentClient(new ServerRuntimeClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); +} + +/** `isTunnelRequest` for a server client with this managed tunnel and no DSN of its own. */ +function isTunnelRequestFor(request: Parameters[0], managedTunnel: TunnelOptions): boolean { + setTestClient(undefined, managedTunnel); + + return isTunnelRequest(request); } From 3d3a30cbaebafbc4893b15379862cc7561d4c2cc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 12:22:53 -0400 Subject: [PATCH 25/32] ref(core): Resolve the managed tunnel's DSNs once per server client The matcher forwarded through the public handler, which resolved the allowed DSNs again on every request. The client now stores them next to the tunnel paths, and the own DSN is passed explicitly, so a client without a DSN never picks up another client's. The header line is parsed as JSON directly, since it's all the handler reads. --- packages/core/src/types/options.ts | 8 ++++ .../core/src/utils/managedTunnelDefaults.ts | 10 ++-- packages/core/src/utils/tunnel.ts | 46 +++++++++---------- packages/core/test/lib/utils/tunnel.test.ts | 9 ++-- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 2a877219f01b..a601b89248f8 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -388,6 +388,14 @@ export interface ClientOptions): string[] { if (tunnelOptions.path) { return [tunnelOptions.path.replace(/\/$/, '')]; } // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. - const paths = resolveAllowedDsns(tunnelOptions.allowedDsns, ownDsn).map(dsn => getTunnelPath(dsn)); + const paths = allowedDsns.map(dsn => getTunnelPath(dsn)); return [...new Set(paths.filter((path): path is string => !!path))]; } @@ -161,18 +160,14 @@ export function isTunnelRequest(request: TunnelRequestLike): boolean { * can hand the request to the app. */ export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { - if (!isTunnelRequest(request)) { - return undefined; - } - - const allowedDsns = getClient()?.getOptions()._managedTunnel?.allowedDsns; + const allowedDsns = getClient()?.getOptions()._managedTunnelAllowedDsns; // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes // the browser send directly, instead of losing every envelope to an error response. - if (!resolveAllowedDsns(allowedDsns).length) { + if (!allowedDsns?.length || !isTunnelRequest(request)) { return undefined; } - return handleTunnelRequest({ request, allowedDsns }); + return forwardTunnelRequest(request, allowedDsns); } /** @@ -183,8 +178,8 @@ export function handleTunnelRequestIfMatched(request: Request): Promise { - return markTunnelResponse(await forwardTunnelRequest(options)); +export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { + return forwardTunnelRequest(options.request, resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn())); } /** @@ -203,10 +198,11 @@ function markTunnelResponse(response: Response): Response { return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } -async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promise { - const { request } = options; - const allowedDsns = resolveAllowedDsns(options.allowedDsns); +async function forwardTunnelRequest(request: Request, allowedDsns: Array): Promise { + return markTunnelResponse(await forwardEnvelope(request, allowedDsns)); +} +async function forwardEnvelope(request: Request, allowedDsns: Array): Promise { if (allowedDsns.length === 0) { return new Response('Tunnel not configured', { status: 500 }); } @@ -248,9 +244,10 @@ async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promis * Returns the Sentry ingest URL for an envelope header line, or the response that rejects it. */ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { - let envelopeHeader; + let envelopeHeader: { dsn?: string } | null; try { - [envelopeHeader] = parseEnvelope(headerLine); + // Only the header line is read, and it's a single JSON object. + envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -373,13 +370,16 @@ function forwardHeaders(request: Request): Record { * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock * out a browser that shares it. */ -function resolveAllowedDsns( +export function resolveAllowedDsns( allowedDsns: Array | undefined, - ownDsn: DsnComponents | undefined = getClient()?.getDsn(), + ownDsn: DsnComponents | undefined, ): Array { - const dsns = [...(allowedDsns ?? []), ...(ownDsn ? [dsnToString(ownDsn)] : [])]; + const dsns = new Set(allowedDsns); + if (ownDsn) { + dsns.add(dsnToString(ownDsn)); + } - return [...new Set(dsns)]; + return [...dsns]; } /** diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index cccfc52beb4c..02a35879401f 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -13,6 +13,7 @@ import { handleTunnelRequestIfMatched, isTunnelRequest, normalizeTunnelBasePath, + resolveAllowedDsns, resolveServerTunnelOption, resolveTunnelPaths, } from '../../../src/utils/tunnel'; @@ -33,7 +34,9 @@ function getTunnelIgnoreSpanFilter( tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined, ): IgnoreSpanFilter | undefined { - return getTunnelIgnoreSpanFilters(tunnelOptions, resolveTunnelPaths(tunnelOptions, dsn))[0]; + const paths = resolveTunnelPaths(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)); + + return getTunnelIgnoreSpanFilters(tunnelOptions, paths)[0]; } function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { @@ -475,14 +478,14 @@ describe('getTunnelPaths', () => { it('returns one path per allowed DSN and skips invalid ones', () => { const otherDsn = 'https://other@o2.ingest.sentry.io/42'; - expect(resolveTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] }, undefined)).toEqual([ + expect(resolveTunnelPaths({}, [TEST_DSN, otherDsn, 'not-a-dsn'])).toEqual([ getTunnelPath(TEST_DSN), getTunnelPath(otherDsn), ]); }); it('returns a fixed path instead of the DSN-derived ones', () => { - expect(resolveTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] }, undefined)).toEqual(['/monitor']); + expect(resolveTunnelPaths({ path: '/monitor' }, [TEST_DSN])).toEqual(['/monitor']); expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( true, ); From 1dc01f00cb8730411c7d697d6dea5e3621425bf4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 12:42:25 -0400 Subject: [PATCH 26/32] fix(core): Don't forward the tunnel request's Content-Length with the streamed body Next.js's edge runtime in dev rejects a Content-Length header on a streamed fetch body, so every forward failed with a 500. Relay takes the chunked upload. --- packages/core/src/utils/tunnel.ts | 7 ++----- packages/core/test/lib/utils/tunnel.test.ts | 21 --------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index cd125c26e5e9..79f000127df2 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -346,12 +346,9 @@ function prependToStream( } function forwardHeaders(request: Request): Record { + // No `Content-Length`: the body is a stream, and some runtimes' `fetch` rejects the header on one (Next.js's edge + // runtime in dev). const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; - // Keeps the upload fixed-length where the runtime allows it (Node, Bun), instead of chunked. - const contentLength = request.headers.get('content-length'); - if (contentLength) { - headers['Content-Length'] = contentLength; - } // Relay infers the user's IP and geo from the sender, which is now this server. Proxies and CDNs put the // visitor's IP in these headers, in the order Relay itself reads them. diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 02a35879401f..787e1c3f7a00 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -121,27 +121,6 @@ describe('handleTunnelRequest', () => { expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); }); - it('forwards the content length of the incoming request', async () => { - fetchMock.mockResolvedValueOnce(new Response('ok')); - const body = `${JSON.stringify({ dsn: TEST_DSN })}\n`; - - await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { - method: 'POST', - body, - headers: { 'content-length': String(body.length) }, - }), - allowedDsns: [TEST_DSN], - }); - - expect(fetchMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: { 'Content-Type': 'application/x-sentry-envelope', 'Content-Length': String(body.length) }, - }), - ); - }); - it.each([ ['cf-connecting-ip', { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, '1.1.1.1'], ['x-vercel-forwarded-for', { 'x-vercel-forwarded-for': '3.3.3.3', 'x-forwarded-for': '2.2.2.2' }, '3.3.3.3'], From 68c7a06f912766de0fdfdccc214350b33818d98c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 14:14:37 -0400 Subject: [PATCH 27/32] ref(core): Keep the served tunnel paths and DSNs out of ClientOptions They're derived per client, so core keeps them in its own map instead of on the public options type. --- packages/core/src/types/options.ts | 15 ---------- .../core/src/utils/managedTunnelDefaults.ts | 8 +++-- packages/core/src/utils/tunnel.ts | 30 ++++++++++++++++--- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index a601b89248f8..d2896dd3979b 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -381,21 +381,6 @@ export interface ClientOptions !(previousFilters as unknown[]).includes(filter)), ...spanFilters, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 79f000127df2..75d7c6556224 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,4 +1,5 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; @@ -9,8 +10,29 @@ import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseUrl } from './url'; -// Bounds the memory of each request. SDK envelopes stay far below it. -export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; +/** + * What a server client serves its managed tunnel at and forwards for. Both only depend on its tunnel options and DSN, + * so they're computed once, when its managed tunnel is settled, and only read per request. + */ +interface ServedTunnel { + paths: string[]; + allowedDsns: string[]; +} + +const servedTunnels = new WeakMap(); + +/** + * Records what a server client serves its managed tunnel at and forwards for. + */ +export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { + servedTunnels.set(client, servedTunnel); +} + +function getServedTunnel(): ServedTunnel | undefined { + const client = getClient(); + + return client && servedTunnels.get(client); +} export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ @@ -88,7 +110,7 @@ export interface TunnelRequestLike { * {@link handleTunnelRequestIfMatched}. */ export function getTunnelPaths(): string[] { - return getClient()?.getOptions()._managedTunnelPaths ?? []; + return getServedTunnel()?.paths ?? []; } /** @@ -160,7 +182,7 @@ export function isTunnelRequest(request: TunnelRequestLike): boolean { * can hand the request to the app. */ export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { - const allowedDsns = getClient()?.getOptions()._managedTunnelAllowedDsns; + const allowedDsns = getServedTunnel()?.allowedDsns; // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes // the browser send directly, instead of losing every envelope to an error response. if (!allowedDsns?.length || !isTunnelRequest(request)) { From feb51ba594ef4845c959d197b7834d2f1186b1b9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 14:19:39 -0400 Subject: [PATCH 28/32] ref(core): Trim the managed tunnel comments --- packages/core/src/integrations/fetch.ts | 2 +- packages/core/src/types/options.ts | 6 +- .../core/src/utils/managedTunnelDefaults.ts | 16 ++-- packages/core/src/utils/tunnel.ts | 86 +++++++------------ 4 files changed, 38 insertions(+), 72 deletions(-) diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index dd5fe7e16ced..cc57d5edca45 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -93,7 +93,7 @@ export function createFetchIntegration({ return; } - // Matches Node's http and undici instrumentation, so SDK-internal requests (e.g. a tunnel forward) leave no breadcrumb. + // Like Node's http and undici instrumentation: SDK-internal requests leave no breadcrumb. if (isTracingSuppressed() || isSentryRequestUrl(handlerData.fetchData.url, client)) { return; } diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index d2896dd3979b..9cff5450e374 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,8 +374,7 @@ export interface ClientOptions SpanJSON; /** Options for the managed tunnel, see `tunnel`. */ export interface TunnelOptions { /** - * Server only. DSNs whose envelopes the tunnel forwards, in addition to the server's own DSN. List the browser DSN - * here when the browser uses a different one. + * Server only. DSNs to forward besides the server's own, e.g. a different browser DSN. */ allowedDsns?: string[]; diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index db2d1a489067..89eefbcf711c 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -22,16 +22,12 @@ interface ManagedTunnelDefaults { let managedTunnelDefaults: ManagedTunnelDefaults | undefined; -// The span filters each client got for its managed tunnel, so applying new defaults replaces them. +// So re-applying defaults replaces a client's filters instead of adding more. const installedSpanFilters = new WeakMap(); /** - * Sets the managed tunnel settings a framework SDK knows outside `init`, for every server client created afterwards - * and for the current one. Clients created per request, like on Cloudflare, then start with the tunnel and its span - * filters, so not even their first tunnel request's span gets through. A managed tunnel or `basePath` that `init` - * set wins. - * - * Framework SDKs call this once, when their server code loads. + * Sets framework-known tunnel settings for the current and every later server client, so per-request clients + * (Cloudflare) start with them. Settings from `init` win. Framework SDKs call this once at load. */ export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { managedTunnelDefaults = { @@ -43,10 +39,8 @@ export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void } /** - * Settles a server client's managed tunnel: the one its `init` enabled, or else the defaults, under the default base - * path. It computes the DSNs the client forwards for and the paths it's served at, and installs the span filters for - * them. Server clients call this when they're created, and {@link setManagedTunnelDefaults} calls it again for a - * client that already exists, replacing the filters it added. + * Resolves a server client's managed tunnel (from `init` or the defaults), computes its paths and allowed DSNs, and + * installs its span filters. Runs when a server client is created and from {@link setManagedTunnelDefaults}. */ export function applyManagedTunnelDefaults(client: Client | undefined): void { const options = client?.getOptions(); diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 75d7c6556224..c3d3d022be82 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -11,8 +11,7 @@ import { dsnToString, makeDsn } from './dsn'; import { parseUrl } from './url'; /** - * What a server client serves its managed tunnel at and forwards for. Both only depend on its tunnel options and DSN, - * so they're computed once, when its managed tunnel is settled, and only read per request. + * A server client's tunnel paths and allowed DSNs, computed once per client. */ interface ServedTunnel { paths: string[]; @@ -22,7 +21,7 @@ interface ServedTunnel { const servedTunnels = new WeakMap(); /** - * Records what a server client serves its managed tunnel at and forwards for. + * Stores a server client's computed tunnel paths and allowed DSNs. */ export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { servedTunnels.set(client, servedTunnel); @@ -42,8 +41,7 @@ export interface HandleTunnelRequestOptions { } /** - * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own - * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. + * Splits a server `tunnel` option: a URL stays the transport tunnel, anything else enables the managed tunnel. */ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): { tunnel: string | undefined; @@ -54,7 +52,7 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions } if (typeof tunnel === 'string') { - // A relative path can't be a transport URL on the server, so it names the path to serve instead. + // A relative path can't be a transport URL on the server, so it's the path to serve. return tunnel.startsWith('/') ? { tunnel: undefined, _managedTunnel: { path: tunnel } } : { tunnel }; } @@ -62,9 +60,7 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions } /** - * Fills in the defaults a framework SDK knows about its app, like the base path, for a managed tunnel. Options the - * user set win. A relative `tunnel` path is a managed tunnel with that `path`, so it gets the defaults too, while a - * URL and an unset `tunnel` are returned as they are. + * Fills in framework defaults (like the base path) for a managed tunnel. User options win; URLs pass through. */ export function applyTunnelDefaults( tunnel: string | true | TunnelOptions | undefined, @@ -82,10 +78,8 @@ export function applyTunnelDefaults( } /** - * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. - * - * Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it - * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. Browser and server derive it independently, + * and as a hash it gives ad-blocker filter lists nothing to match. */ export function getTunnelPath(dsn: DsnLike): string | undefined { const components = makeDsn(dsn); @@ -94,8 +88,7 @@ export function getTunnelPath(dsn: DsnLike): string | undefined { } /** - * The parts of an incoming request the tunnel decision may look at. A fetch `Request` satisfies it, and Node SDKs pass - * an `IncomingMessage` directly. Callers always pass the whole request so the decision can change without touching them. + * The parts of a request the tunnel match reads. Satisfied by a fetch `Request` and Node's `IncomingMessage`. */ export interface TunnelRequestLike { method?: string; @@ -105,33 +98,30 @@ export interface TunnelRequestLike { } /** - * Returns the tunnel paths the active server client accepts, relative to its base path. Frameworks that can only - * register routes use these, everything else should call {@link isTunnelRequest} or - * {@link handleTunnelRequestIfMatched}. + * Returns the active server client's tunnel paths, relative to its base path. For frameworks that can only register + * routes; everything else uses {@link handleTunnelRequestIfMatched}. */ export function getTunnelPaths(): string[] { return getServedTunnel()?.paths ?? []; } /** - * The paths a server client serves its managed tunnel at: the fixed `path`, or else the path of each DSN it forwards - * for. Server clients compute them once, when their managed tunnel is settled, since neither changes later. + * The paths a managed tunnel is served at: the fixed `path`, or else one per allowed DSN. */ export function resolveTunnelPaths(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { if (tunnelOptions.path) { return [tunnelOptions.path.replace(/\/$/, '')]; } - // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. + // Differently written DSNs can hash to the same path. const paths = allowedDsns.map(dsn => getTunnelPath(dsn)); return [...new Set(paths.filter((path): path is string => !!path))]; } /** - * The `ignoreSpans` filters a server client installs for the server spans of tunnel requests, which server SDKs start - * before a framework hook gets to answer the request. Besides `url.path`, they match the legacy - * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. + * `ignoreSpans` filters for the server spans of tunnel requests. They match `url.path` and the legacy `http.url`, + * which some framework spans start with (SvelteKit's `sveltekit.handle.root`). */ export function getTunnelIgnoreSpanFilters(tunnelOptions: TunnelOptions, paths: string[]): IgnoreSpanFilter[] { if (!paths.length) { @@ -150,7 +140,7 @@ export function getTunnelIgnoreSpanFilters(tunnelOptions: TunnelOptions, paths: } /** - * Normalizes a framework's base path to the `/app` form the managed tunnel is served under, or `''` for the root. + * Normalizes a base path to `/app`, or `''` for the root. */ export function normalizeTunnelBasePath(basePath: string | undefined): string { const path = (basePath?.startsWith('./') ? basePath.slice(1) : (basePath ?? '')).replace(/\/$/, ''); @@ -159,8 +149,7 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { } /** - * Returns true if the request targets the managed tunnel of the active client, which matches nothing unless its - * `init` was called with `tunnel: true`. + * Returns true if the request targets the active server client's managed tunnel. */ export function isTunnelRequest(request: TunnelRequestLike): boolean { if (request.method !== 'POST' || !request.url) { @@ -178,13 +167,11 @@ export function isTunnelRequest(request: TunnelRequestLike): boolean { } /** - * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller - * can hand the request to the app. + * Forwards the request if it targets the managed tunnel, otherwise returns `undefined` for the app to handle. */ export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { const allowedDsns = getServedTunnel()?.allowedDsns; - // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes - // the browser send directly, instead of losing every envelope to an error response. + // Without a DSN to forward to, the app's 404 makes the browser send directly. if (!allowedDsns?.length || !isTunnelRequest(request)) { return undefined; } @@ -205,8 +192,7 @@ export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promis } /** - * Set on every response the tunnel handler returns, so a browser can tell it apart from whatever else answers the - * tunnel path, e.g. a static host serving `index.html` for every path. + * Marks responses from the tunnel handler, so the browser can tell them from e.g. a static host's `index.html`. */ export const TUNNEL_RESPONSE_HEADER = 'X-Sentry-Tunnel'; @@ -230,8 +216,7 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr } const reader = request.body?.getReader(); - // A client that drops the connection mid-upload makes the read throw. That is the client going away, not an app - // error, so it gets the same answer as any other unreadable envelope. + // A client aborting mid-upload makes the read throw. const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); if (!reader || !head) { return new Response('Invalid envelope', { status: 400 }); @@ -239,19 +224,16 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); if (typeof sentryIngestUrl !== 'string') { - // Nothing past the header gets forwarded, so the runtime can stop receiving the body. reader.cancel().catch(() => undefined); return sentryIngestUrl; } try { - // The forward is SDK plumbing, not app work, so it must not show up as a span. return await suppressTracing(() => fetch(sentryIngestUrl, { method: 'POST', headers: forwardHeaders(request), - // The body streams through without being buffered, so its size is left for Sentry to judge. body: prependToStream(head.read, reader), duplex: 'half', } as RequestInit), @@ -263,12 +245,11 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr } /** - * Returns the Sentry ingest URL for an envelope header line, or the response that rejects it. + * Returns the ingest URL for an envelope header line, or the rejecting response. */ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { let envelopeHeader: { dsn?: string } | null; try { - // Only the header line is read, and it's a single JSON object. envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); } catch { return new Response('Invalid envelope', { status: 400 }); @@ -303,13 +284,12 @@ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): s } /** - * The header is a small JSON object, so a line longer than this is not an envelope. + * A header line longer than this is not an envelope. */ const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; /** - * Reads the request body only up to the end of the envelope header line. - * Returns the line and every byte read so far, or `undefined` when no header line fits the limit. + * Reads the body up to the envelope header line. Returns the line and every byte read, or `undefined`. */ async function readEnvelopeHeaderLine( reader: ReadableStreamDefaultReader, @@ -368,12 +348,10 @@ function prependToStream( } function forwardHeaders(request: Request): Record { - // No `Content-Length`: the body is a stream, and some runtimes' `fetch` rejects the header on one (Next.js's edge - // runtime in dev). + // No `Content-Length`: some runtimes reject it on a streamed body (Next.js edge in dev). const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; - // Relay infers the user's IP and geo from the sender, which is now this server. Proxies and CDNs put the - // visitor's IP in these headers, in the order Relay itself reads them. + // Relay would otherwise attribute the server's IP. Same precedence Relay uses. const clientIp = request.headers.get('cf-connecting-ip') || request.headers.get('x-vercel-forwarded-for') || @@ -386,8 +364,7 @@ function forwardHeaders(request: Request): Record { } /** - * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock - * out a browser that shares it. + * `allowedDsns` plus the server's own DSN, which is always allowed. */ export function resolveAllowedDsns( allowedDsns: Array | undefined, @@ -402,8 +379,7 @@ export function resolveAllowedDsns( } /** - * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to - * be before the SDK added the base path, would otherwise be served under the base path twice. + * Makes a fixed `path` relative to `basePath`, so one written with the base path isn't served under it twice. */ export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); @@ -412,7 +388,7 @@ export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptio } /** - * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. + * Returns the path below `basePath`, or `undefined` outside it. */ function stripBasePath(pathname: string, basePath: string | undefined): string | undefined { const base = normalizeTunnelBasePath(basePath); @@ -424,10 +400,8 @@ function stripBasePath(pathname: string, basePath: string | undefined): string | } /** - * 32-bit FNV-1a, because the browser needs the path synchronously in `init` (`crypto.subtle.digest` is async) and it - * runs in every runtime without dependencies. It needn't be cryptographic since the DSN is public. - * - * Browser and server SDKs compute the path separately, so changing the algorithm breaks mixed SDK versions. + * 32-bit FNV-1a: synchronous (the browser needs it in `init`) and dependency free. Changing it breaks mixed SDK + * versions, since browser and server compute the path separately. */ function fnv1a(input: string): number { let hash = 0x811c9dc5; From e69f1c92d0045951585f9f774c5a9ffbfb96cf31 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 14:21:40 -0400 Subject: [PATCH 29/32] feat(core): Keep the tunnel body size checks with the streamed forward #25515's 411 and 413 checks run before the header line is read, so an oversized or unlengthed request is rejected without reading its body. --- packages/core/src/utils/tunnel.ts | 14 ++++++ packages/core/test/lib/utils/tunnel.test.ts | 50 ++++++++++++++++++--- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index c3d3d022be82..d686eadd8d97 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -33,6 +33,11 @@ function getServedTunnel(): ServedTunnel | undefined { return client && servedTunnels.get(client); } +/** + * Bounds what one tunnel request may send. SDK envelopes stay far below it. + */ +export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; + export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; @@ -215,6 +220,15 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr return new Response('Tunnel not configured', { status: 500 }); } + // The HTTP server ends the body at `Content-Length`, so the header bounds what the forward streams. + const contentLength = Number(request.headers.get('content-length')); + if (!contentLength) { + return new Response('Length required', { status: 411 }); + } + if (contentLength > MAX_TUNNEL_BODY_SIZE) { + return new Response('Envelope too large', { status: 413 }); + } + const reader = request.body?.getReader(); // A client aborting mid-upload makes the read throw. const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 787e1c3f7a00..55ebe21561f8 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -16,6 +16,7 @@ import { resolveAllowedDsns, resolveServerTunnelOption, resolveTunnelPaths, + MAX_TUNNEL_BODY_SIZE, } from '../../../src/utils/tunnel'; import { _INTERNAL_resetManagedTunnelDefaults, @@ -39,11 +40,15 @@ function getTunnelIgnoreSpanFilter( return getTunnelIgnoreSpanFilters(tunnelOptions, paths)[0]; } -function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { - const envelope = createEnvelope(envelopeHeader, []); - const body = serializeEnvelope(envelope); +function makeEnvelopeRequest( + envelopeHeader: Record, + url = 'http://localhost/tunnel', + contentLength?: number, +): Request { + const body = serializeEnvelope(createEnvelope(envelopeHeader, [])); + const length = contentLength ?? new TextEncoder().encode(body as string).length; - return new Request(url, { method: 'POST', body }); + return new Request(url, { method: 'POST', body, headers: { 'content-length': String(length) } }); } /** @@ -56,6 +61,7 @@ function makeEndlessRequest(head: string, onCancel?: () => void): Request { return new Request('http://localhost/tunnel', { method: 'POST', duplex: 'half', + headers: { 'content-length': String(MAX_TUNNEL_BODY_SIZE) }, body: new ReadableStream({ pull(controller) { controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); @@ -74,6 +80,40 @@ describe('handleTunnelRequest', () => { vi.stubGlobal('fetch', fetchMock); }); + it('forwards the envelope when Content-Length equals the limit', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(200); + }); + + it('returns 413 when Content-Length is above the limit, without reading the body', async () => { + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE + 1); + + const result = await handleTunnelRequest({ request, allowedDsns: [TEST_DSN] }); + + expect(result.status).toBe(413); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 411 when the request has no Content-Length', async () => { + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + }), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(411); + expect(fetchMock).not.toHaveBeenCalled(); + }); + afterEach(() => { vi.restoreAllMocks(); getCurrentScope().setClient(undefined); @@ -132,7 +172,7 @@ describe('handleTunnelRequest', () => { request: new Request('http://localhost/tunnel', { method: 'POST', body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, - headers: incomingHeaders, + headers: { ...incomingHeaders, 'content-length': '1' }, }), allowedDsns: [TEST_DSN], }); From 74d138de6b2f2eb2551a97238b8d7eb4c8367ff4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:11:56 -0400 Subject: [PATCH 30/32] ref(core): Rename withPathBelowBasePath to stripBasePathFromTunnelPath --- packages/core/src/utils/managedTunnelDefaults.ts | 4 ++-- packages/core/src/utils/tunnel.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index 89eefbcf711c..19daffef2444 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -7,7 +7,7 @@ import { resolveAllowedDsns, resolveTunnelPaths, setServedTunnel, - withPathBelowBasePath, + stripBasePathFromTunnelPath, } from './tunnel'; /** @@ -50,7 +50,7 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void { } const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); - const resolved = withPathBelowBasePath( + const resolved = stripBasePathFromTunnelPath( tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, ); options._managedTunnel = resolved; diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index d686eadd8d97..01b78ed9d692 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -76,10 +76,10 @@ export function applyTunnelDefaults( } if (typeof tunnel === 'string') { - return tunnel.startsWith('/') ? withPathBelowBasePath({ ...defaults, path: tunnel }) : tunnel; + return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...defaults, path: tunnel }) : tunnel; } - return withPathBelowBasePath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); + return stripBasePathFromTunnelPath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); } /** @@ -395,7 +395,7 @@ export function resolveAllowedDsns( /** * Makes a fixed `path` relative to `basePath`, so one written with the base path isn't served under it twice. */ -export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { +export function stripBasePathFromTunnelPath(tunnelOptions: TunnelOptions): TunnelOptions { const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); return path ? { ...tunnelOptions, path } : tunnelOptions; From 8ae161a5da7c01559d9b44c907f739d1a9b1cdd4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:53:05 -0400 Subject: [PATCH 31/32] ref(core): Normalize the default base path in applyTunnelDefaults and ignore a root one --- packages/core/src/utils/tunnel.ts | 10 +++++++--- packages/core/test/lib/utils/tunnel.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 01b78ed9d692..95a122c86384 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -66,20 +66,24 @@ export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions /** * Fills in framework defaults (like the base path) for a managed tunnel. User options win; URLs pass through. + * The base path is normalized, and a root one is no default at all. */ export function applyTunnelDefaults( tunnel: string | true | TunnelOptions | undefined, defaults: TunnelOptions | undefined, ): string | true | TunnelOptions | undefined { - if (!tunnel || !defaults || !Object.keys(defaults).length) { + const { basePath, ...rest } = defaults ?? {}; + const normalizedBasePath = normalizeTunnelBasePath(basePath); + const resolvedDefaults: TunnelOptions = normalizedBasePath ? { ...rest, basePath: normalizedBasePath } : rest; + if (!tunnel || !Object.keys(resolvedDefaults).length) { return tunnel; } if (typeof tunnel === 'string') { - return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...defaults, path: tunnel }) : tunnel; + return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...resolvedDefaults, path: tunnel }) : tunnel; } - return stripBasePathFromTunnelPath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); + return stripBasePathFromTunnelPath({ ...resolvedDefaults, ...(tunnel === true ? {} : tunnel) }); } /** diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 55ebe21561f8..bee197f7b89a 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -723,6 +723,12 @@ describe('applyTunnelDefaults', () => { expect(applyTunnelDefaults('/monitor', {})).toBe('/monitor'); expect(applyTunnelDefaults(true, undefined)).toBe(true); }); + + it('normalizes the default base path and ignores a root one', () => { + expect(applyTunnelDefaults(true, { basePath: 'app/' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults(true, { basePath: '/' })).toBe(true); + expect(applyTunnelDefaults(true, { basePath: undefined })).toBe(true); + }); }); describe('normalizeTunnelBasePath', () => { From 0d03dca7793be9948126d7533e5b9f63245a1b0b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 16:05:29 -0400 Subject: [PATCH 32/32] fix(core): Normalize allowedDsns like the envelope header's DSN, so one written with a secret key still matches --- packages/core/src/utils/tunnel.ts | 9 ++++++--- packages/core/test/lib/utils/tunnel.test.ts | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 95a122c86384..0d3df1d4d8b5 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -388,9 +388,12 @@ export function resolveAllowedDsns( allowedDsns: Array | undefined, ownDsn: DsnComponents | undefined, ): Array { - const dsns = new Set(allowedDsns); - if (ownDsn) { - dsns.add(dsnToString(ownDsn)); + // Normalized like the envelope header's DSN, which has no secret key. + const dsns = new Set(); + for (const components of [...(allowedDsns ?? []).map(dsn => makeDsn(dsn)), ownDsn]) { + if (components) { + dsns.add(dsnToString(components)); + } } return [...dsns]; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index bee197f7b89a..5723bf29e528 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -280,7 +280,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('returns 403 when the DSN string cannot be parsed into components', async () => { + it('ignores an allowed DSN that cannot be parsed', async () => { const malformedDsn = 'not-a-valid-dsn'; const result = await handleTunnelRequest({ @@ -288,12 +288,22 @@ describe('handleTunnelRequest', () => { allowedDsns: [malformedDsn], }); - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(403); - expect(await result.text()).toBe('Invalid DSN'); + expect(result.status).toBe(500); expect(fetchMock).not.toHaveBeenCalled(); }); + it('matches an allowed DSN written with a secret key, which the envelope header leaves out', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN.replace('@', ':secret@')], + }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it('forwards the envelope when multiple DSNs are configured', async () => { const otherDsn = 'https://other@example.com/9999'; const upstreamResponse = new Response('ok', { status: 200 });