From a2af1e1c021af365b58f1e439aa1cb88d7c6d08e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Oct 2026 11:43:32 -0400 Subject: [PATCH] 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 }))); +}