From b6c2e81d8ff70dc759163ba4fb29e011a0deaea2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:43:32 -0400 Subject: [PATCH 01/16] 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 | 184 +++++++++++++++++++- 4 files changed, 320 insertions(+), 16 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 5645633ff45e..c2a53aa3cf2f 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,13 +1,76 @@ 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'; 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 }); } /** @@ -19,7 +82,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 }); @@ -62,15 +126,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 2b499ce754ae..e5e981ac1807 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,15 +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 } 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): Request { +function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); - return new Request('http://localhost/tunnel', { method: 'POST', body }); + return new Request(url, { method: 'POST', body }); } describe('handleTunnelRequest', () => { @@ -22,6 +32,7 @@ describe('handleTunnelRequest', () => { afterEach(() => { vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); }); it('forwards the envelope to Sentry and returns the upstream response', async () => { @@ -133,4 +144,171 @@ describe('handleTunnelRequest', () => { expect(result.status).toBe(500); expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); + + 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')); + }); + + await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); + + expect(suppressed).toBe(true); + }); + + it('defaults allowedDsns to the DSN of the active client', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValueOnce(new Response('ok')); + + 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] }); + + 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 e0baa844389dad1300ec502c349065f979918578 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 10:37:01 -0400 Subject: [PATCH 02/16] 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 c2a53aa3cf2f..6eef5c176c07 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'; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ @@ -37,17 +38,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; } @@ -56,8 +86,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); } /** @@ -65,11 +96,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 }); } @@ -148,6 +180,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 e5e981ac1807..7b26e62ea2bc 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 }); } @@ -149,6 +151,7 @@ describe('handleTunnelRequest', () => { let suppressed: boolean | undefined; fetchMock.mockImplementationOnce(() => { suppressed = isTracingSuppressed(); + return Promise.resolve(new Response('ok')); }); @@ -209,38 +212,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 bdecbfc168d34b3a4765ca0b0fba75dac7027ffe Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 10:44:30 -0400 Subject: [PATCH 03/16] 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 6eef5c176c07..e2c2fcf419d1 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -15,6 +15,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. @@ -37,9 +41,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; } /** @@ -193,7 +205,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 7b26e62ea2bc..98cd495249b6 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -202,6 +202,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 d238df330e14c5ff269011750c7c6f4c455855ec Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:11:43 -0400 Subject: [PATCH 04/16] 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 e2c2fcf419d1..06db91376838 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -27,10 +27,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 }; } @@ -77,6 +82,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 98cd495249b6..dc190ab51ab7 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -295,6 +295,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([]); @@ -366,6 +373,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 a2ee93508556a59ca22ff4a2659e3777925b5e4b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:47:06 -0400 Subject: [PATCH 05/16] 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 06db91376838..49ca1586be92 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'; @@ -82,13 +84,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('|')})/?$`) } }; } /** @@ -231,3 +247,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 dc190ab51ab7..725c0863f114 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, @@ -360,6 +361,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 a7cb3930a51c034855a7877975f2e1b9ce59b408 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:11:40 -0400 Subject: [PATCH 06/16] 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 1ee484621fbb95bd76c38a93f8a7c8c9a43450cf Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:11:40 -0400 Subject: [PATCH 07/16] feat(core): Check the tunnel DSN before reading the envelope body --- packages/core/src/utils/tunnel.ts | 94 +++++++++++++++++++-- packages/core/test/lib/utils/tunnel.test.ts | 74 +++++++++++++++- 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 49ca1586be92..00756ac11a1d 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -158,11 +158,15 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Tunnel not configured', { status: 500 }); } - const body = new Uint8Array(await request.arrayBuffer()); + const reader = request.body?.getReader(); + const head = reader && (await readEnvelopeHeaderLine(reader)); + if (!reader || !head) { + return new Response('Invalid envelope', { status: 400 }); + } let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(body); + [envelopeHeader] = parseEnvelope(head.line); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -199,11 +203,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); @@ -211,6 +215,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 725c0863f114..1f055b8f6338 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 a3eddb6aff270747fca51b84460bcf4f60a6ea1c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:25:49 -0400 Subject: [PATCH 08/16] 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 00756ac11a1d..2ec3bc1f3aea 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -164,9 +164,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 }); } @@ -196,23 +225,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 1f055b8f6338..509ac9e81971 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); @@ -262,6 +274,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 aa97143c33d0fff98e7aae4feb43e5c1f17ad4cc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:28:11 -0400 Subject: [PATCH 09/16] 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 2ec3bc1f3aea..ebf5f3eb685f 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -301,6 +301,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 509ac9e81971..057a983f8916 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 d53b9815e28dff5390fe507d45f244e656fc90a5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:38:40 -0400 Subject: [PATCH 10/16] 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 ebf5f3eb685f..1f8570b371ad 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -353,7 +353,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 057a983f8916..3d13a63a426c 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -416,6 +416,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([]); @@ -498,6 +503,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 c664a5a3321e3526ab758101062d0b1fc3f42a8f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 09:57:47 -0400 Subject: [PATCH 11/16] 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 1f8570b371ad..b586c83a8d7d 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -104,7 +104,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('|')})/?$`) } }; } /** @@ -123,9 +123,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(/\/$/, '')); } /** @@ -324,13 +324,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', () => { @@ -509,6 +516,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 3d72f880aaa494f070af2a9bedf0a672667be11f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 10:34:20 -0400 Subject: [PATCH 12/16] 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 b586c83a8d7d..72a6c4898051 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -13,7 +13,7 @@ import { parseUrl } from './url'; 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; } @@ -95,8 +95,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; } @@ -314,14 +315,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)]; } /** @@ -359,5 +363,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 4b87be886fdc..9a63f02c74ad 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -269,6 +269,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')); @@ -428,6 +445,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([]); @@ -509,6 +533,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 cc5f9c60ee63979a1414c07b14debf1f82fd7e4b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:47:06 -0400 Subject: [PATCH 13/16] 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 72a6c4898051..82ead49d7f2b 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -41,6 +41,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 9a63f02c74ad..a9b0deeb3ed8 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, @@ -597,6 +598,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 c3bdc87f9469595625ebffd4efbc34347589144a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 12:59:20 -0400 Subject: [PATCH 14/16] 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 82ead49d7f2b..66919a149e06 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'; @@ -128,6 +129,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 a9b0deeb3ed8..648d1e077923 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'; @@ -620,6 +621,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 f4c72bdfc60eb03bca0c4eeb8a41372e9a6500f5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 15:13:08 -0400 Subject: [PATCH 15/16] 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 66919a149e06..76032b0851c3 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -193,6 +193,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 648d1e077923..ef2d901fc600 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 () => { @@ -242,7 +265,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 612037fd3ee1e411603d121094ea3873c6cf3f50 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:00:11 -0400 Subject: [PATCH 16/16] 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 76032b0851c3..0091244730e0 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -123,12 +123,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. @@ -136,7 +145,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; } @@ -393,7 +402,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 ef2d901fc600..87532a659dc3 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'; @@ -644,6 +645,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);