From 3f952f21100f06b44e29ce4e1ab836bdfbfbce09 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 15:36:29 -0400 Subject: [PATCH 01/17] feat: Export the managed tunnel API from every server SDK Users wiring the tunnel by hand could only import these from `@sentry/core`, which strict pnpm installs do not resolve. --- packages/astro/src/index.server.ts | 4 ++++ packages/aws-serverless/src/index.ts | 5 ++++ packages/bun/src/index.ts | 5 ++++ packages/cloudflare/src/index.ts | 11 ++++++++- .../cloudflare/test/tunnelExports.test.ts | 23 +++++++++++++++++++ packages/deno/src/index.ts | 11 ++++++++- packages/deno/test/exports.test.ts | 5 ++++ packages/google-cloud-serverless/src/index.ts | 5 ++++ packages/node/src/index.ts | 11 ++++++++- packages/node/test/tunnelExports.test.ts | 23 +++++++++++++++++++ packages/remix/src/server/index.ts | 4 ++++ packages/sveltekit/src/server/index.ts | 4 ++++ packages/sveltekit/src/worker/index.ts | 4 ++++ packages/vercel-edge/src/index.ts | 11 ++++++++- .../vercel-edge/test/tunnelExports.test.ts | 23 +++++++++++++++++++ 15 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 packages/cloudflare/test/tunnelExports.test.ts create mode 100644 packages/node/test/tunnelExports.test.ts create mode 100644 packages/vercel-edge/test/tunnelExports.test.ts diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index e1692463acd7..69a5899ae619 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -164,6 +164,10 @@ export { tediousIntegration, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, vercelAIIntegration, withActiveSpan, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index f5877b600a13..095858178815 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -152,6 +152,10 @@ export { systemErrorIntegration, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, instrumentSupabaseClient, @@ -193,6 +197,7 @@ export { eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; export { captureConsoleIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 24ddbe3ec328..763082aba7cd 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -172,6 +172,10 @@ export { spanToBaggageHeader, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, instrumentSupabaseClient, @@ -214,6 +218,7 @@ export { eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; export { captureConsoleIntegration, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index dc7312cb3685..60a32d7c5bbf 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -110,7 +110,16 @@ export { withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { createFlueInstrumentation, instrumentPostgresJsSql } from '@sentry/server-utils'; export type { FlueOptions } from '@sentry/server-utils'; diff --git a/packages/cloudflare/test/tunnelExports.test.ts b/packages/cloudflare/test/tunnelExports.test.ts new file mode 100644 index 000000000000..c6de5ddce836 --- /dev/null +++ b/packages/cloudflare/test/tunnelExports.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as Sentry from '../src'; +import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src'; + +describe('managed tunnel exports', () => { + it.each([ + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', + 'getTunnelPath', + 'handleTunnelRequest', + ])('exports %s for wiring the tunnel by hand', name => { + expect(typeof (Sentry as Record)[name]).toBe('function'); + }); + + it('exports the tunnel types', () => { + const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] }; + const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' }; + const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') }; + + expect([tunnelOptions, request, handleOptions]).toHaveLength(3); + }); +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f0efd1331582..d26507f6988a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -111,7 +111,16 @@ export { consoleLoggingIntegration, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { instrumentPostgresJsSql, prismaIntegration, diff --git a/packages/deno/test/exports.test.ts b/packages/deno/test/exports.test.ts index 76b4cce1cd18..6ac1c5d92ddf 100644 --- a/packages/deno/test/exports.test.ts +++ b/packages/deno/test/exports.test.ts @@ -13,8 +13,13 @@ const EXPECTED_FUNCTION_EXPORTS = [ 'growthbookIntegration', 'moduleMetadataIntegration', 'parameterize', + 'getTunnelPath', + 'handleTunnelRequest', // @sentry/core/server 'trpcMiddleware', + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', // @sentry/server-utils 'prismaIntegration', 'instrumentOpenAiClient', diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 3965a6b8113e..cd588df14aae 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -148,6 +148,10 @@ export { spanToBaggageHeader, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, systemErrorIntegration, @@ -194,6 +198,7 @@ export { eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node'; export { captureConsoleIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 37cd9f5365d2..53f1b781d45c 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -175,7 +175,16 @@ export { featureFlagsIntegration, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export type { Breadcrumb, diff --git a/packages/node/test/tunnelExports.test.ts b/packages/node/test/tunnelExports.test.ts new file mode 100644 index 000000000000..c6de5ddce836 --- /dev/null +++ b/packages/node/test/tunnelExports.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as Sentry from '../src'; +import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src'; + +describe('managed tunnel exports', () => { + it.each([ + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', + 'getTunnelPath', + 'handleTunnelRequest', + ])('exports %s for wiring the tunnel by hand', name => { + expect(typeof (Sentry as Record)[name]).toBe('function'); + }); + + it('exports the tunnel types', () => { + const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] }; + const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' }; + const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') }; + + expect([tunnelOptions, request, handleOptions]).toHaveLength(3); + }); +}); diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 582607b00d58..a1aaa76b57aa 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -122,6 +122,10 @@ export { tediousIntegration, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 2b12f240379c..270abc2ae43c 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -127,6 +127,10 @@ export { tediousIntegration, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index e13be39ce91e..850fa3c8763e 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -82,6 +82,10 @@ export { startSpanManual, trpcMiddleware, serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, withActiveSpan, withIsolationScope, withMonitor, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 47abe7da7218..3177c2774979 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -105,7 +105,16 @@ export { withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { + handleTunnelRequestIfMatched, + isTunnelRequest, + serveTunnelIntegration, + trpcMiddleware, + wrapMcpServerWithSentry, +} from '@sentry/core/server'; +export type { TunnelRequestLike } from '@sentry/core/server'; +export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; export { openTelemetryIntegration, getOtlpTracesEndpoint, diff --git a/packages/vercel-edge/test/tunnelExports.test.ts b/packages/vercel-edge/test/tunnelExports.test.ts new file mode 100644 index 000000000000..c6de5ddce836 --- /dev/null +++ b/packages/vercel-edge/test/tunnelExports.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as Sentry from '../src'; +import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src'; + +describe('managed tunnel exports', () => { + it.each([ + 'serveTunnelIntegration', + 'handleTunnelRequestIfMatched', + 'isTunnelRequest', + 'getTunnelPath', + 'handleTunnelRequest', + ])('exports %s for wiring the tunnel by hand', name => { + expect(typeof (Sentry as Record)[name]).toBe('function'); + }); + + it('exports the tunnel types', () => { + const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] }; + const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' }; + const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') }; + + expect([tunnelOptions, request, handleOptions]).toHaveLength(3); + }); +}); From a5fc7f8df590a4e7bcdb2598e90a888e815c45b0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 15:43:18 -0400 Subject: [PATCH 02/17] ref(core): Drop the unread `headers` from `TunnelRequestLike` --- packages/core/src/utils/tunnel.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 5c0c805e531a..5826701708d5 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -76,7 +76,6 @@ export interface TunnelRequestLike { method?: string; /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ url?: string; - headers?: Headers | Record; } /** From bdf9e35657e3b33e2a2fa05ef425095d9e23a117 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 15:57:55 -0400 Subject: [PATCH 03/17] feat(core): Take the request first in `handleTunnelRequest`, and accept a Node request and response The released options object form keeps working and is deprecated. --- packages/core/src/utils/tunnelForward.ts | 109 ++++++++++- packages/core/test/lib/utils/tunnel.test.ts | 182 +++++++++++------- packages/nextjs/src/tunnel/index.ts | 2 +- .../src/server/tunnelRoute.ts | 5 +- .../test/server/tunnelRoute.test.ts | 11 +- 5 files changed, 217 insertions(+), 92 deletions(-) diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 5334c53fa2ac..3fea68af2e7d 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -5,23 +5,114 @@ import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import { resolveAllowedDsns } from './tunnel'; +/** + * Options for {@link handleTunnelRequest}. + */ export interface HandleTunnelRequestOptions { - /** Incoming request containing the Sentry envelope as its body */ - request: Request; + /** + * The incoming request. + * + * @deprecated Pass the request as the first argument of `handleTunnelRequest` instead. + */ + request?: Request; /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ allowedDsns?: Array; } /** - * Core Sentry tunnel handler - framework agnostic. - * - * Validates the envelope DSN against allowed DSNs, then forwards the - * envelope to the Sentry ingest endpoint. + * The parts of Node's `IncomingMessage` the tunnel reads. + */ +interface NodeTunnelRequest extends AsyncIterable { + url?: string; + headers: Record; +} + +/** + * The parts of Node's `ServerResponse` the tunnel writes. + */ +interface NodeTunnelResponse { + writeHead(statusCode: number, headers?: Record): unknown; + end(chunk?: Uint8Array): unknown; +} + +/** + * Forwards a tunnel request to Sentry, if its envelope's DSN is the active client's or one of `allowedDsns`. + * It doesn't check the path: route the tunnel requests to it, e.g. on `getTunnelPath(dsn)`. * - * @returns A `Response` — either the upstream Sentry response on success, or an error response. + * @returns Sentry's response, or the response rejecting the request. */ -export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - return forwardTunnelRequest(options.request, resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn())); +export function handleTunnelRequest(request: Request, options?: HandleTunnelRequestOptions): Promise; +/** + * Forwards a tunnel request from a Node server, like Express or `node:http`, and writes the answer to `response`. + * Mount it before body parsers, which would read the envelope first. + */ +export function handleTunnelRequest( + request: NodeTunnelRequest, + response: NodeTunnelResponse, + options?: HandleTunnelRequestOptions, +): Promise; +/** + * @deprecated Use `handleTunnelRequest(request, options)` instead. + */ +export function handleTunnelRequest(options: HandleTunnelRequestOptions & { request: Request }): Promise; +export function handleTunnelRequest( + requestOrOptions: Request | NodeTunnelRequest | (HandleTunnelRequestOptions & { request: Request }), + responseOrOptions?: NodeTunnelResponse | HandleTunnelRequestOptions, + nodeOptions?: HandleTunnelRequestOptions, +): Promise { + if (isNodeTunnelResponse(responseOrOptions)) { + return handleNodeTunnelRequest(requestOrOptions as NodeTunnelRequest, responseOrOptions, nodeOptions); + } + + // The released `handleTunnelRequest({ request })` form. A `Request` has a `method`, the options object doesn't. + const isLegacyCall = 'request' in requestOrOptions && !('method' in requestOrOptions); + // oxlint-disable-next-line typescript/no-deprecated -- reads the released form to keep it working + const request = isLegacyCall ? requestOrOptions.request : (requestOrOptions as Request); + const options = isLegacyCall ? requestOrOptions : responseOrOptions; + + return forwardTunnelRequest(request, resolveAllowedDsns(options?.allowedDsns, getClient()?.getDsn())); +} + +function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { + return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; +} + +async function handleNodeTunnelRequest( + request: NodeTunnelRequest, + response: NodeTunnelResponse, + options: HandleTunnelRequestOptions | undefined, +): Promise { + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined) { + headers.set(name, Array.isArray(value) ? value.join(', ') : value); + } + } + + const iterator = request[Symbol.asyncIterator](); + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async pull(controller) { + const { done, value } = await iterator.next(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(typeof value === 'string' ? encoder.encode(value) : value); + }, + }); + + const fetchRequest = new Request(`http://${headers.get('host') ?? 'localhost'}${request.url ?? '/'}`, { + method: 'POST', + headers, + body, + duplex: 'half', + } as RequestInit); + + const fetchResponse = await handleTunnelRequest(fetchRequest, options); + response.writeHead(fetchResponse.status, Object.fromEntries(fetchResponse.headers)); + response.end(new Uint8Array(await fetchResponse.arrayBuffer())); } function withDecodedBodyHeaders(response: Response): Response { diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 05dfa054e91b..de9c61641237 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,3 +1,5 @@ +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; import { getCurrentScope, setCurrentClient } from '../../../src'; @@ -72,6 +74,73 @@ function makeEndlessRequest(head: string, onCancel?: () => void): Request { } as RequestInit); } +describe('handleTunnelRequest call forms', () => { + // Captured before the stub, so the test can reach its own server. + const realFetch = globalThis.fetch; + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(() => Promise.resolve(new Response('forwarded', { status: 202 }))); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('still takes the released options object', async () => { + // oxlint-disable-next-line typescript/no-deprecated -- the form under test + const response = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }); + + expect(response.status).toBe(202); + }); + + it("forwards from a Node server's request and writes the answer to its response", async () => { + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const response = await realFetch(`http://localhost:${port}/tunnel`, { method: 'POST', body: envelope }); + + expect(response.status).toBe(202); + expect(await response.text()).toBe('forwarded'); + const [url, init] = fetchMock.mock.lastCall as unknown as [string, RequestInit]; + expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); + expect(await new Response(init.body).text()).toBe(envelope); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + + it('rejects a Node request whose body a parser already read', async () => { + const server = http.createServer(async (req, res) => { + for await (const _chunk of req) { + // Like a body parser that ran first. + } + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const response = await realFetch(`http://localhost:${port}/tunnel`, { method: 'POST', body: envelope }); + + expect(response.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); +}); + describe('handleTunnelRequest', () => { let fetchMock: ReturnType; @@ -83,8 +152,7 @@ describe('handleTunnelRequest', () => { it('leaves the size limit to Sentry and passes its 413 back', async () => { fetchMock.mockResolvedValueOnce(new Response('Envelope too large', { status: 413 })); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, 1024 * 1024 * 1024), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, 1024 * 1024 * 1024), { allowedDsns: [TEST_DSN], }); @@ -93,13 +161,13 @@ describe('handleTunnelRequest', () => { }); it('returns 411 when the request has no Content-Length', async () => { - const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { + const result = await handleTunnelRequest( + new Request('http://localhost/tunnel', { method: 'POST', body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, }), - allowedDsns: [TEST_DSN], - }); + { allowedDsns: [TEST_DSN] }, + ); expect(result.status).toBe(411); expect(fetchMock).not.toHaveBeenCalled(); @@ -114,10 +182,7 @@ describe('handleTunnelRequest', () => { const upstreamResponse = new Response('ok', { status: 200 }); fetchMock.mockResolvedValueOnce(upstreamResponse); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN] }); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0]!; @@ -135,10 +200,7 @@ describe('handleTunnelRequest', () => { 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 forwarded = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN] }); expect(forwarded.headers.get('Content-Type')).toBe('application/json'); // The original encoding would make the browser decode it twice. @@ -152,14 +214,14 @@ describe('handleTunnelRequest', () => { ])('forwards the client IP from %s', async (_, incomingHeaders, forwardedFor) => { fetchMock.mockResolvedValueOnce(new Response('ok')); - await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { + await handleTunnelRequest( + new Request('http://localhost/tunnel', { method: 'POST', body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, headers: { ...incomingHeaders, 'content-length': '1' }, }), - allowedDsns: [TEST_DSN], - }); + { allowedDsns: [TEST_DSN] }, + ); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), @@ -181,14 +243,14 @@ describe('handleTunnelRequest', () => { referer: 'https://example.com/page', }; - await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { + await handleTunnelRequest( + new Request('http://localhost/tunnel', { method: 'POST', body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, headers: { ...clientHeaders, cookie: 'session=secret', 'content-length': '1' }, }), - allowedDsns: [TEST_DSN], - }); + { allowedDsns: [TEST_DSN] }, + ); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), @@ -199,10 +261,10 @@ describe('handleTunnelRequest', () => { }); 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], - }); + const result = await handleTunnelRequest( + makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), + { allowedDsns: [TEST_DSN] }, + ); expect(result.status).toBe(403); expect(fetchMock).not.toHaveBeenCalled(); @@ -211,10 +273,10 @@ describe('handleTunnelRequest', () => { 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], - }); + await handleTunnelRequest( + makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`, onCancel), + { allowedDsns: [TEST_DSN] }, + ); expect(onCancel).toHaveBeenCalledOnce(); }); @@ -223,8 +285,7 @@ describe('handleTunnelRequest', () => { const upstreamResponse = new Response('ok'); fetchMock.mockResolvedValueOnce(upstreamResponse); - const result = await handleTunnelRequest({ - request: makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), + const result = await handleTunnelRequest(makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), { allowedDsns: [TEST_DSN], }); @@ -232,20 +293,14 @@ describe('handleTunnelRequest', () => { }); it('returns 400 when the envelope header line exceeds the limit', async () => { - const result = await handleTunnelRequest({ - request: makeEndlessRequest(''), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest(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 }), - allowedDsns: [], - }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [] }); expect(result).toBeInstanceOf(Response); expect(result.status).toBe(500); @@ -254,10 +309,7 @@ describe('handleTunnelRequest', () => { }); it('returns 400 when the envelope has no DSN in the header', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({}), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest(makeEnvelopeRequest({}), { allowedDsns: [TEST_DSN] }); expect(result).toBeInstanceOf(Response); expect(result.status).toBe(400); @@ -266,14 +318,14 @@ describe('handleTunnelRequest', () => { }); it('returns 400 when the envelope body contains malformed JSON', async () => { - const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { + const result = await handleTunnelRequest( + new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{', headers: { 'content-length': '26' }, }), - allowedDsns: [TEST_DSN], - }); + { allowedDsns: [TEST_DSN] }, + ); expect(result).toBeInstanceOf(Response); expect(result.status).toBe(400); @@ -282,8 +334,7 @@ describe('handleTunnelRequest', () => { }); it('returns 403 when the envelope DSN is not in allowedDsns', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }), { allowedDsns: [TEST_DSN], }); @@ -296,8 +347,7 @@ describe('handleTunnelRequest', () => { it('ignores an allowed DSN that cannot be parsed', async () => { const malformedDsn = 'not-a-valid-dsn'; - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: malformedDsn }), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: malformedDsn }), { allowedDsns: [malformedDsn], }); @@ -308,8 +358,7 @@ describe('handleTunnelRequest', () => { it('matches an allowed DSN written with a secret key, which the envelope header leaves out', async () => { fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN.replace('@', ':secret@')], }); @@ -322,8 +371,7 @@ describe('handleTunnelRequest', () => { const upstreamResponse = new Response('ok', { status: 200 }); fetchMock.mockResolvedValueOnce(upstreamResponse); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [otherDsn, TEST_DSN], }); @@ -336,10 +384,7 @@ describe('handleTunnelRequest', () => { it('returns 500 when fetch throws a network error', async () => { fetchMock.mockRejectedValueOnce(new Error('Network failure')); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN] }); expect(result).toBeInstanceOf(Response); expect(result.status).toBe(500); @@ -354,7 +399,7 @@ describe('handleTunnelRequest', () => { return Promise.resolve(new Response('ok')); }); - await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); + await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: [TEST_DSN] }); expect(suppressed).toBe(true); }); @@ -363,14 +408,13 @@ describe('handleTunnelRequest', () => { 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' }), + const ownDsnResult = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }), { allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], }); + const unlistedDsnResult = await handleTunnelRequest( + 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); @@ -380,7 +424,7 @@ describe('handleTunnelRequest', () => { setTestClient(TEST_DSN); fetchMock.mockResolvedValueOnce(new Response('ok')); - const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN })); expect(result.status).toBe(200); expect(fetchMock).toHaveBeenCalledWith( @@ -390,7 +434,7 @@ describe('handleTunnelRequest', () => { }); it('returns 500 when allowedDsns is omitted and there is no client', async () => { - const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + const result = await handleTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN })); expect(result.status).toBe(500); expect(fetchMock).not.toHaveBeenCalled(); diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index 09a2c7cdcc43..d86dd2d0a99d 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -43,7 +43,7 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions } // Like the SDK transports, so that the forward creates no spans, breadcrumbs or trace headers. - return suppressTracing(() => handleTunnelRequest({ request, allowedDsns })); + return suppressTracing(() => handleTunnelRequest(request, { allowedDsns })); }, }; } diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 7aa46d8b3037..aca6acccc7b5 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -92,10 +92,7 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) ); } - return handleTunnelRequest({ - request, - allowedDsns, - }); + return handleTunnelRequest(request, { allowedDsns }); }, }, }; diff --git a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts index 0016be5c0785..c4ef0481b765 100644 --- a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts @@ -39,12 +39,7 @@ describe('createSentryTunnelRoute', () => { const result = await route.handlers.POST({ request }); expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); - const [options] = handleTunnelRequestSpy.mock.calls[0]!; - expect(options).toEqual({ - request, - allowedDsns, - }); - expect(options.allowedDsns).toBe(allowedDsns); + expect(handleTunnelRequestSpy).toHaveBeenCalledWith(request, { allowedDsns }); expect(result).toBe(response); }); @@ -71,9 +66,7 @@ describe('createSentryTunnelRoute', () => { const result = await route.handlers.POST({ request }); expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); - const [options] = handleTunnelRequestSpy.mock.calls[0]!; - expect(options).toEqual({ - request, + expect(handleTunnelRequestSpy).toHaveBeenCalledWith(request, { allowedDsns: ['http://public@localhost:3031/1337'], }); expect(result).toBe(response); From 7ba896a54d8c54ec6225c4ff88865e49a242fd3b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 16:07:38 -0400 Subject: [PATCH 04/17] feat(core): Default `handleTunnelRequest` to the DSNs `serveTunnelIntegration` allows --- packages/core/src/utils/tunnel.ts | 7 +++++++ packages/core/src/utils/tunnelForward.ts | 14 +++++++++++--- packages/core/test/lib/utils/tunnel.test.ts | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 5826701708d5..0d784c0884ee 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -86,6 +86,13 @@ export function getTunnelPaths(): string[] { return getServedTunnel()?.paths ?? []; } +/** + * Returns the DSNs the active server client's managed tunnel forwards, its own DSN included. + */ +export function getTunnelAllowedDsns(): string[] | undefined { + return getServedTunnel()?.allowedDsns; +} + /** * The paths a managed tunnel is served at: the fixed `path`, or else one per allowed DSN. */ diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 3fea68af2e7d..8c67e8d65b45 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -3,7 +3,7 @@ import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; -import { resolveAllowedDsns } from './tunnel'; +import { getTunnelAllowedDsns, resolveAllowedDsns } from './tunnel'; /** * Options for {@link handleTunnelRequest}. @@ -15,7 +15,10 @@ export interface HandleTunnelRequestOptions { * @deprecated Pass the request as the first argument of `handleTunnelRequest` instead. */ request?: Request; - /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ + /** + * DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. Defaults to the DSNs + * `serveTunnelIntegration` allows. + */ allowedDsns?: Array; } @@ -70,7 +73,12 @@ export function handleTunnelRequest( const request = isLegacyCall ? requestOrOptions.request : (requestOrOptions as Request); const options = isLegacyCall ? requestOrOptions : responseOrOptions; - return forwardTunnelRequest(request, resolveAllowedDsns(options?.allowedDsns, getClient()?.getDsn())); + // Without `allowedDsns`, the DSNs `serveTunnelIntegration` allows, so its options apply here too. + const allowedDsns = options?.allowedDsns + ? resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn()) + : (getTunnelAllowedDsns() ?? resolveAllowedDsns(undefined, getClient()?.getDsn())); + + return forwardTunnelRequest(request, allowedDsns); } function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index de9c61641237..bac24e248f1d 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -88,6 +88,20 @@ describe('handleTunnelRequest call forms', () => { vi.unstubAllGlobals(); }); + it('forwards the DSNs `serveTunnelIntegration` allows when no `allowedDsns` are passed', async () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn] }); + + const fromIntegration = await handleTunnelRequest(makeEnvelopeRequest({ dsn: browserDsn })); + const explicit = await handleTunnelRequest(makeEnvelopeRequest({ dsn: browserDsn }), { + allowedDsns: ['https://other@o2.ingest.sentry.io/7'], + }); + getCurrentScope().setClient(undefined); + + expect(fromIntegration.status).toBe(202); + expect(explicit.status).toBe(403); + }); + it('still takes the released options object', async () => { // oxlint-disable-next-line typescript/no-deprecated -- the form under test const response = await handleTunnelRequest({ From 39e6f3321ade84140e7de1f55a4ca7454ef2c134 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 16:14:51 -0400 Subject: [PATCH 05/17] feat(core): Let the tunnel helpers take options, then fall back to `serveTunnelIntegration`, then the own DSN `isTunnelRequest`, `handleTunnelRequestIfMatched` and `getTunnelPath` work without the integration now, and `handleTunnelRequestIfMatched` also takes a Node request and response. The framework SDKs keep serving only with the integration, through internal served-only versions. --- packages/astro/src/server/middleware.ts | 7 +- packages/astro/test/server/middleware.test.ts | 2 +- packages/core/src/server.ts | 8 +- packages/core/src/utils/tunnel.ts | 75 ++++++++++--- packages/core/src/utils/tunnelForward.ts | 37 +++++- packages/core/test/lib/utils/tunnel.test.ts | 106 ++++++++++++++++-- packages/nitro/src/runtime/plugins/server.ts | 4 +- .../runtime/plugins/tunnel-legacy.server.ts | 8 +- .../nuxt/src/runtime/plugins/tunnel.server.ts | 4 +- .../react-router/src/server/tunnelRoute.ts | 4 +- packages/remix/src/v3/server/instrument.ts | 4 +- packages/remix/src/v3/server/middleware.ts | 4 +- packages/remix/test/v3/instrument.test.ts | 2 +- packages/remix/test/v3/middleware.test.ts | 10 +- .../sveltekit/src/server-common/handle.ts | 4 +- .../src/server/tunnelRoute.ts | 8 +- 16 files changed, 231 insertions(+), 56 deletions(-) diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index e993fb34561b..410fe0c9a8be 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -26,7 +26,10 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import { flushIfServerless, handleTunnelRequestIfMatched } from '@sentry/core/server'; +import { + flushIfServerless, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, +} from '@sentry/core/server'; import { captureException, continueTrace, @@ -87,7 +90,7 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = return next(); } - const tunnelResponse = handleTunnelRequestIfMatched(ctx.request); + const tunnelResponse = handleServedTunnelRequest(ctx.request); if (tunnelResponse) { return tunnelResponse; } diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index de4026d0b70f..fd9f0dc2a139 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -39,7 +39,7 @@ describe('sentryMiddleware with the managed tunnel', () => { const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); const tunnelResponse = new Response('forwarded'); const matcherSpy = vi - .spyOn(SentryCoreServer, 'handleTunnelRequestIfMatched') + .spyOn(SentryCoreServer, '_INTERNAL_handleServedTunnelRequest') .mockReturnValue(Promise.resolve(tunnelResponse)); const next = vi.fn(); const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 6452fc7b81a9..08c2b3e99a55 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -9,10 +9,14 @@ export { ServerRuntimeClient } from './server-runtime-client'; export type { TunnelRequestLike } from './utils/tunnel'; export { getTunnelPaths as _INTERNAL_getTunnelPaths, - handleTunnelRequestIfMatched, + handleServedTunnelRequest as _INTERNAL_handleServedTunnelRequest, + isServedTunnelRequest as _INTERNAL_isServedTunnelRequest, isTunnelRequest, } from './utils/tunnel'; -export { forwardTunnelRequest as _INTERNAL_forwardTunnelRequest } from './utils/tunnelForward'; +export { + forwardTunnelRequest as _INTERNAL_forwardTunnelRequest, + handleTunnelRequestIfMatched, +} from './utils/tunnelForward'; export { serveTunnelIntegration } from './integrations/serveTunnel'; export { _INTERNAL_resetManagedTunnelDefaults, diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 0d784c0884ee..73e5b4d520a3 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -60,11 +60,11 @@ export function applyTunnelDefaults( } /** - * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. Browser and server derive it independently, - * and as a hash it gives ad-blocker filter lists nothing to match. + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`, by default the active client's. Browser and server + * derive it independently, and as a hash it gives ad-blocker filter lists nothing to match. */ -export function getTunnelPath(dsn: DsnLike): string | undefined { - const components = makeDsn(dsn); +export function getTunnelPath(dsn?: DsnLike): string | undefined { + const components = dsn ? makeDsn(dsn) : getClient()?.getDsn(); return components ? `/${fnv1a32(dsnToString(components)).toString(36)}` : undefined; } @@ -139,29 +139,74 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string { } /** - * Returns true if the request targets the active server client's managed tunnel. + * The paths, base path and allowed DSNs of a tunnel to match requests against. */ -export function isTunnelRequest(request: TunnelRequestLike): boolean { - if (request.method !== 'POST' || !request.url) { - return false; +interface TunnelMatch { + paths: string[]; + basePath?: string; + allowedDsns: string[]; +} + +/** + * Resolves the tunnel the public helpers match: `options`, else the one `serveTunnelIntegration` serves, else the + * path of the active client's own DSN. + */ +export function resolveTunnelMatch(options?: ServerTunnelOptions): TunnelMatch { + const client = getClient(); + const servedTunnel = getServedTunnel(); + if (!options && servedTunnel) { + return { ...servedTunnel, basePath: client?.getOptions()._managedTunnel?.basePath }; } - const tunnelOptions = getClient()?.getOptions()._managedTunnel; - if (!tunnelOptions) { + const allowedDsns = resolveAllowedDsns(options?.allowedDsns, client?.getDsn()); + + return { paths: resolveTunnelPaths(options ?? {}, allowedDsns), basePath: options?.basePath, allowedDsns }; +} + +/** + * Returns true if the request is a POST to one of the tunnel's paths. + */ +export function matchesTunnel(request: TunnelRequestLike, tunnel: Omit): boolean { + if (request.method !== 'POST' || !request.url) { return false; } - const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); + const path = stripBasePath(parseUrl(request.url).path || '/', tunnel.basePath); + + return path !== undefined && tunnel.paths.includes(path.replace(/\/$/, '')); +} + +/** + * Returns true if the request is a POST to the managed tunnel. The tunnel comes from `options`, else from + * `serveTunnelIntegration`, else it's the path of the active client's own DSN. + */ +export function isTunnelRequest(request: TunnelRequestLike, options?: ServerTunnelOptions): boolean { + return matchesTunnel(request, resolveTunnelMatch(options)); +} + +/** + * Returns true if the request targets the tunnel `serveTunnelIntegration` serves. Unlike {@link isTunnelRequest}, + * there's no fallback, so the framework SDKs serve nothing without the integration. + */ +export function isServedTunnelRequest(request: TunnelRequestLike): boolean { + const servedTunnel = getServedTunnel(); - return path !== undefined && getTunnelPaths().includes(path.replace(/\/$/, '')); + return ( + !!servedTunnel && + matchesTunnel(request, { + paths: servedTunnel.paths, + basePath: getClient()?.getOptions()._managedTunnel?.basePath, + }) + ); } /** - * Forwards the request if it targets the managed tunnel, otherwise returns `undefined` for the app to handle. + * Forwards the request if it targets the tunnel `serveTunnelIntegration` serves, otherwise returns `undefined`. The + * framework SDKs answer tunnel requests with it. */ -export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { +export function handleServedTunnelRequest(request: Request): Promise | undefined { const servedTunnel = getServedTunnel(); - if (!servedTunnel?.allowedDsns.length || !isTunnelRequest(request)) { + if (!servedTunnel?.allowedDsns.length || !isServedTunnelRequest(request)) { return undefined; } diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 8c67e8d65b45..5ce53c97cdac 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -3,7 +3,8 @@ import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; -import { getTunnelAllowedDsns, resolveAllowedDsns } from './tunnel'; +import type { ServerTunnelOptions } from '../types/options'; +import { getTunnelAllowedDsns, matchesTunnel, resolveAllowedDsns, resolveTunnelMatch } from './tunnel'; /** * Options for {@link handleTunnelRequest}. @@ -26,6 +27,7 @@ export interface HandleTunnelRequestOptions { * The parts of Node's `IncomingMessage` the tunnel reads. */ interface NodeTunnelRequest extends AsyncIterable { + method?: string; url?: string; headers: Record; } @@ -81,6 +83,39 @@ export function handleTunnelRequest( return forwardTunnelRequest(request, allowedDsns); } +/** + * Forwards the request if it's a POST to the managed tunnel, otherwise returns `undefined` for the app to handle. The + * tunnel comes from `options`, else from `serveTunnelIntegration`, else it's the path of the active client's own DSN. + */ +export function handleTunnelRequestIfMatched( + request: Request, + options?: ServerTunnelOptions, +): Promise | undefined; +/** + * Forwards a tunnel request from a Node server, like Express or `node:http`, and writes the answer to `response`. + * Returns `undefined` for anything else, so the app can handle it. + */ +export function handleTunnelRequestIfMatched( + request: NodeTunnelRequest, + response: NodeTunnelResponse, + options?: ServerTunnelOptions, +): Promise | undefined; +export function handleTunnelRequestIfMatched( + request: Request | NodeTunnelRequest, + responseOrOptions?: NodeTunnelResponse | ServerTunnelOptions, + nodeOptions?: ServerTunnelOptions, +): Promise | undefined { + const response = isNodeTunnelResponse(responseOrOptions) ? responseOrOptions : undefined; + const tunnel = resolveTunnelMatch(response ? nodeOptions : (responseOrOptions as ServerTunnelOptions | undefined)); + if (!tunnel.allowedDsns.length || !matchesTunnel(request, tunnel)) { + return undefined; + } + + return response + ? handleNodeTunnelRequest(request as NodeTunnelRequest, response, { allowedDsns: tunnel.allowedDsns }) + : forwardTunnelRequest(request as Request, tunnel.allowedDsns); +} + function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index bac24e248f1d..caafa67c5d88 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -11,13 +11,18 @@ import { getTunnelPath, getTunnelIgnoreSpanFilters, getTunnelPaths, - handleTunnelRequestIfMatched, + handleServedTunnelRequest, + isServedTunnelRequest, isTunnelRequest, normalizeTunnelBasePath, resolveAllowedDsns, resolveTunnelPaths, } from '../../../src/utils/tunnel'; -import { forwardTunnelRequest, handleTunnelRequest } from '../../../src/utils/tunnelForward'; +import { + forwardTunnelRequest, + handleTunnelRequest, + handleTunnelRequestIfMatched, +} from '../../../src/utils/tunnelForward'; import { serveTunnelIntegration } from '../../../src/integrations/serveTunnel'; import { _INTERNAL_resetManagedTunnelDefaults, @@ -456,6 +461,14 @@ describe('handleTunnelRequest', () => { }); describe('getTunnelPath', () => { + it("defaults to the active client's DSN", () => { + setTestClient(TEST_DSN); + + expect(getTunnelPath()).toBe(getTunnelPath(TEST_DSN)); + getCurrentScope().setClient(undefined); + expect(getTunnelPath()).toBeUndefined(); + }); + it('returns a short opaque path that does not leak the DSN', () => { const path = getTunnelPath(TEST_DSN); @@ -509,12 +522,27 @@ describe('isTunnelRequest', () => { ); }); - it('uses the managed tunnel config of the active client when no options are passed', () => { + it("falls back to the active client's own DSN without `serveTunnelIntegration`, unlike the served-only matcher", () => { setTestClient(TEST_DSN); - expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); - setTestClient(TEST_DSN, {}); expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(true); + expect(isServedTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + }); + + it('matches the tunnel the options describe, with no client at all', () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + + expect( + isTunnelRequest( + { method: 'POST', url: `/app${getTunnelPath(browserDsn)}` }, + { + allowedDsns: [browserDsn], + basePath: '/app', + }, + ), + ).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(browserDsn)! }, { path: '/monitor' })).toBe(false); }); it('matches nothing without a client', () => { @@ -685,6 +713,55 @@ describe('handleTunnelRequestIfMatched', () => { expect(request.bodyUsed).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); + it('forwards to the own DSN path without `serveTunnelIntegration`, which the framework SDKs ignore', async () => { + fetchMock.mockResolvedValue(new Response('ok')); + setTestClient(TEST_DSN); + const tunnelUrl = `http://localhost${getTunnelPath(TEST_DSN)}`; + + expect((await handleTunnelRequestIfMatched(makeEnvelopeRequest({ dsn: TEST_DSN }, tunnelUrl)))?.status).toBe(200); + expect(handleServedTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }, tunnelUrl))).toBeUndefined(); + }); + + it('forwards the tunnel the options describe', async () => { + fetchMock.mockResolvedValue(new Response('ok')); + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN); + + const response = await handleTunnelRequestIfMatched( + makeEnvelopeRequest({ dsn: browserDsn }, 'http://localhost/monitor'), + { path: '/monitor', allowedDsns: [browserDsn] }, + ); + + expect(response?.status).toBe(200); + expect( + handleTunnelRequestIfMatched(makeEnvelopeRequest({ dsn: browserDsn }, 'http://localhost/other'), { + path: '/monitor', + }), + ).toBeUndefined(); + }); + + it("answers a Node server's tunnel requests and leaves the rest to the app", async () => { + fetchMock.mockResolvedValue(new Response('forwarded', { status: 202 })); + setTestClient(TEST_DSN); + const server = http.createServer((req, res) => { + if (!handleTunnelRequestIfMatched(req, res)) { + res.writeHead(200).end('app'); + } + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const tunnel = await nodeRequest(port, getTunnelPath(TEST_DSN)!, envelope); + const other = await nodeRequest(port, '/api', envelope); + + expect(tunnel).toEqual({ status: 202, body: 'forwarded' }); + expect(other).toEqual({ status: 200, body: 'app' }); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); }); describe('getTunnelIgnoreSpanFilter', () => { @@ -777,7 +854,7 @@ describe('serveTunnelIntegration', () => { 'fetch', vi.fn(() => Promise.resolve(new Response('forwarded'))), ); - const response = await bundledTunnel.handleTunnelRequestIfMatched( + const response = await bundledTunnel.handleServedTunnelRequest( makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost/app${getTunnelPath(TEST_DSN)}`), ); vi.unstubAllGlobals(); @@ -793,9 +870,7 @@ describe('serveTunnelIntegration', () => { expect(getTunnelPaths()).toEqual([]); expect( - handleTunnelRequestIfMatched( - makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`), - ), + handleServedTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`)), ).toBeUndefined(); }); }); @@ -931,3 +1006,16 @@ function isTunnelRequestFor( return isTunnelRequest(request); } + +/** POSTs `body` to a local server with `node:http`, since `fetch` is stubbed in these tests. */ +function nodeRequest(port: number, path: string, body: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request({ port, path, method: 'POST' }, res => { + let data = ''; + res.on('data', chunk => (data += chunk)); + res.on('end', () => resolve({ status: res.statusCode!, body: data })); + }); + req.on('error', reject); + req.end(body); + }); +} diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 0b61322284aa..44ced3c5b3c3 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,5 +1,5 @@ import { - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { definePlugin } from 'nitro'; @@ -14,5 +14,5 @@ export default definePlugin(nitroApp => { // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); + nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index e5ae50026c53..89b1a04e8958 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,6 +1,6 @@ import { - handleTunnelRequestIfMatched, - isTunnelRequest, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { eventHandler, toWebRequest } from 'h3'; @@ -22,12 +22,12 @@ export default (nitroApp => { route: '', handler: eventHandler(event => { // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isTunnelRequest({ method: event.method, url: event.path })) { + if (!isServedTunnelRequest({ method: event.method, url: event.path })) { return undefined; } // A returned `Response` is sent and ends the request, while `undefined` hands it to the next layer. - return handleTunnelRequestIfMatched(toWebRequest(event)); + return handleServedTunnelRequest(toWebRequest(event)); }), }); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index e27471be638f..ae4b451edc32 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,5 +1,5 @@ import { - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { useRuntimeConfig } from 'nitro/runtime-config'; @@ -16,5 +16,5 @@ export default ((nitroApp: NitroApp) => { // up a `NUXT_APP_BASE_URL` set at runtime. setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); const appFetch = nitroApp.fetch; - nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); + nitroApp.fetch = request => handleServedTunnelRequest(request) ?? appFetch(request); }) satisfies NitroAppPlugin; diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts index 821640eed21c..18b03a6d2c13 100644 --- a/packages/react-router/src/server/tunnelRoute.ts +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -1,7 +1,7 @@ import { debug, getClient } from '@sentry/core'; import { _INTERNAL_getTunnelPaths as getTunnelPaths, - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -63,7 +63,7 @@ function makeTunnelRoute(id: string, getPath: () => string): Record - handleTunnelRequestIfMatched(request) ?? new Response(null, { status: 404 }), + handleServedTunnelRequest(request) ?? new Response(null, { status: 404 }), loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }), }, }; diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index b70fe91e9b4c..f63578e2f0f9 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -1,7 +1,7 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; import { consoleSandbox } from '@sentry/core'; -import { handleTunnelRequestIfMatched } from '@sentry/core/server'; +import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import type { OrchestrionChannelContext } from '@sentry/server-utils'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; @@ -95,7 +95,7 @@ function wrapFetchHandler(args: unknown[]): void { } args[0] = function sentryFetchHandler(this: unknown, request: Request, ...rest: unknown[]): unknown { - return handleTunnelRequestIfMatched(request) ?? handler.call(this, request, ...rest); + return handleServedTunnelRequest(request) ?? handler.call(this, request, ...rest); }; } diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index b5803af44dcb..4ca54f648383 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -10,7 +10,7 @@ import { updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; -import { handleTunnelRequestIfMatched } from '@sentry/core/server'; +import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server'; import { formatRouteTiming } from '../routeTiming'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; @@ -26,7 +26,7 @@ import { resolveRoutePattern } from './route'; */ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { - const tunnelResponse = handleTunnelRequestIfMatched(context.request); + const tunnelResponse = handleServedTunnelRequest(context.request); if (tunnelResponse) { return tunnelResponse; } diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index c0d984e763f7..85133e8b3ecd 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -18,7 +18,7 @@ const tunnelResponse = new Response(null, { status: 200 }); // Answers only `TUNNEL_URL`, like a server with the managed tunnel at that path. vi.mock('@sentry/core/server', () => ({ - handleTunnelRequestIfMatched: (request: Request) => + _INTERNAL_handleServedTunnelRequest: (request: Request) => request.url === TUNNEL_URL ? Promise.resolve(tunnelResponse) : undefined, })); diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index c86d426793e9..e0f1866e78fd 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Trace data as the SDK would produce it for the active request; empty when nothing is active. const getTraceData = vi.hoisted(() => vi.fn<() => { 'sentry-trace'?: string; baggage?: string }>(() => ({}))); -const handleTunnelRequestIfMatched = vi.hoisted(() => +const handleServedTunnelRequest = vi.hoisted(() => vi.fn<(request: Request) => Promise | undefined>(() => undefined), ); @@ -16,7 +16,7 @@ vi.mock('@sentry/core', async importOriginal => ({ vi.mock('@sentry/core/server', async importOriginal => ({ ...(await importOriginal()), - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest: handleServedTunnelRequest, })); import { sentryRemixMiddleware } from '../../src/v3/server/middleware'; @@ -37,19 +37,19 @@ function contextFor(url: string, headers: Record = {}): RequestC describe('sentryRemixMiddleware', () => { beforeEach(() => { getTraceData.mockReturnValue({}); - handleTunnelRequestIfMatched.mockReturnValue(undefined); + handleServedTunnelRequest.mockReturnValue(undefined); }); it('answers tunnel requests before route matching and the rest of the chain', async () => { const tunnelResponse = new Response('forwarded'); - handleTunnelRequestIfMatched.mockReturnValue(Promise.resolve(tunnelResponse)); + handleServedTunnelRequest.mockReturnValue(Promise.resolve(tunnelResponse)); const next = vi.fn(); const context = contextFor('http://x/abc123'); const response = await sentryRemixMiddleware(matcherFor('/'))(context, next); expect(response).toBe(tunnelResponse); - expect(handleTunnelRequestIfMatched).toHaveBeenCalledWith(context.request); + expect(handleServedTunnelRequest).toHaveBeenCalledWith(context.request); expect(next).not.toHaveBeenCalled(); }); diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 09df6d82e8c3..a910add9b0d1 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -21,7 +21,7 @@ import { } from '@sentry/core'; import { flushIfServerless, - handleTunnelRequestIfMatched, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { getTunnelBasePath } from '../common/tunnel'; @@ -300,7 +300,7 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { - const tunnelResponse = handleTunnelRequestIfMatched(input.event.request); + const tunnelResponse = handleServedTunnelRequest(input.event.request); if (tunnelResponse) { return tunnelResponse; } diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index aca6acccc7b5..1d4d09b80553 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -3,8 +3,8 @@ import type { ServerTunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; import { _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, - handleTunnelRequestIfMatched, - isTunnelRequest, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import type { ServerEntry } from './wrapFetchWithSentry'; @@ -130,11 +130,11 @@ export function wrapServerEntryWithSentryTunnel( // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. value: (request: Request, ...rest: unknown[]) => { // Start would render the app for any other method on a tunnel path, running its root loaders. - if (request.method !== 'POST' && isTunnelRequest({ method: 'POST', url: request.url })) { + if (request.method !== 'POST' && isServedTunnelRequest({ method: 'POST', url: request.url })) { return new Response(null, { status: 405, headers: { Allow: 'POST' } }); } - return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); + return handleServedTunnelRequest(request) ?? entry.fetch(request, ...rest); }, writable: true, configurable: true, From 139d00e20aa93cd2a70098b197bd8ae0ddadc0fe Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 16:21:33 -0400 Subject: [PATCH 06/17] fix(core): Deprecate the `request` option instead of the whole `handleTunnelRequest` overload The deprecated overload made every re-export of the function fail the deprecation lint. --- packages/core/src/utils/tunnelForward.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 5ce53c97cdac..f34c9a255514 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -57,7 +57,7 @@ export function handleTunnelRequest( options?: HandleTunnelRequestOptions, ): Promise; /** - * @deprecated Use `handleTunnelRequest(request, options)` instead. + * The released form, kept until v12. Its `request` option is deprecated: pass the request as the first argument. */ export function handleTunnelRequest(options: HandleTunnelRequestOptions & { request: Request }): Promise; export function handleTunnelRequest( From f057ec84732a6a8063ad4fd4a7c5846cbff99211 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 16:43:17 -0400 Subject: [PATCH 07/17] feat(solidstart, elysia): Export the managed tunnel API from their explicit export lists --- packages/elysia/src/index.ts | 6 ++++++ packages/solidstart/src/server/index.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 76c135d6f3f4..abe6212f2728 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -143,6 +143,11 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, supabaseIntegration, instrumentSupabaseClient, @@ -182,6 +187,7 @@ export { type BunRuntimeMetricsOptions, makeFetchTransport, } from '@sentry/bun'; +export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/bun'; export type { Breadcrumb, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 0e7cec54fa4f..b2d128847df8 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -126,6 +126,11 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, + getTunnelPath, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, updateSpanName, withActiveSpan, withIsolationScope, From b2dcf4cfa3d4774ec3b76736978396273bc0834d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 17:00:35 -0400 Subject: [PATCH 08/17] fix(core): Serve the tunnel over HTTP/2, and from copies of core that did not set it up The Node form skips HTTP/2 pseudo-headers, which fetch rejects. A copy of core without the served tunnel, like the one in a Next.js middleware bundle, rebuilds it from the client config and the integration. --- packages/core/src/integrations/serveTunnel.ts | 3 +- .../core/src/utils/managedTunnelDefaults.ts | 7 +-- packages/core/src/utils/tunnel.ts | 27 +++++++++- packages/core/src/utils/tunnelForward.ts | 7 ++- packages/core/test/lib/utils/tunnel.test.ts | 51 +++++++++++++++++++ 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/packages/core/src/integrations/serveTunnel.ts b/packages/core/src/integrations/serveTunnel.ts index 74b38499dff2..9ee2d81334c1 100644 --- a/packages/core/src/integrations/serveTunnel.ts +++ b/packages/core/src/integrations/serveTunnel.ts @@ -1,7 +1,8 @@ import { defineIntegration } from '../integration'; import type { IntegrationFn } from '../types/integration'; import type { ServerTunnelOptions } from '../types/options'; -import { enableManagedTunnel, SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/managedTunnelDefaults'; +import { enableManagedTunnel } from '../utils/managedTunnelDefaults'; +import { SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/tunnel'; import { forwardTunnelRequest } from '../utils/tunnelForward'; const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index f9ee1c933169..1e46bc85e090 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -4,6 +4,7 @@ import { getClient } from '../currentScopes'; import type { IgnoreSpanFilter, ServerTunnelOptions } from '../types/options'; import type { TunnelForwarder } from './tunnel'; import { + SERVE_TUNNEL_INTEGRATION_NAME, getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, resolveAllowedDsns, @@ -29,12 +30,6 @@ interface ManagedTunnelDefaults { basePath?: string; } -/** - * The name of `serveTunnelIntegration`, which carries the forwarder. Kept here so this module doesn't import the - * integration, and with it the forwarder, into apps that don't use it. - */ -export const SERVE_TUNNEL_INTEGRATION_NAME = 'ServeTunnel'; - let managedTunnelDefaults: ManagedTunnelDefaults | undefined; const tunnelForwarders = new WeakMap(); diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 73e5b4d520a3..f3e0c66adcfa 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,4 +1,5 @@ import type { Client } from '../client'; +import type { Integration } from '../types/integration'; import { getClient } from '../currentScopes'; import { URL_PATH } from '@sentry/conventions/attributes'; import type { DsnComponents, DsnLike } from '../types/dsn'; @@ -23,6 +24,12 @@ interface ServedTunnel { const servedTunnels = new WeakMap(); +/** + * The name of `serveTunnelIntegration`, which carries the forwarder. Kept here so this module doesn't import the + * integration, and with it the forwarder, into apps that don't use it. + */ +export const SERVE_TUNNEL_INTEGRATION_NAME = 'ServeTunnel'; + /** * Stores a server client's computed tunnel paths and allowed DSNs. */ @@ -33,7 +40,25 @@ export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): voi function getServedTunnel(): ServedTunnel | undefined { const client = getClient(); - return client && servedTunnels.get(client); + return client && (servedTunnels.get(client) ?? getServedTunnelFromClient(client)); +} + +/** + * Rebuilds the served tunnel from what the client carries, for a copy of this module that didn't set it up. Bundlers + * can give a framework's request hook its own copy, like Next.js's middleware next to its `instrumentation`. + */ +function getServedTunnelFromClient(client: Client): ServedTunnel | undefined { + const tunnelOptions = client.getOptions()._managedTunnel; + const forward = client.getIntegrationByName?.( + SERVE_TUNNEL_INTEGRATION_NAME, + )?.forward; + if (!tunnelOptions || !forward) { + return undefined; + } + + const allowedDsns = resolveAllowedDsns(tunnelOptions.allowedDsns, client.getDsn()); + + return { paths: resolveTunnelPaths(tunnelOptions, allowedDsns), allowedDsns, forward }; } /** diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index f34c9a255514..eec201742843 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -127,10 +127,13 @@ async function handleNodeTunnelRequest( ): Promise { const headers = new Headers(); for (const [name, value] of Object.entries(request.headers)) { - if (value !== undefined) { + // HTTP/2 pseudo-headers like `:authority` aren't valid fetch header names. + if (value !== undefined && !name.startsWith(':')) { headers.set(name, Array.isArray(value) ? value.join(', ') : value); } } + const authority = request.headers[':authority']; + const host = headers.get('host') ?? (typeof authority === 'string' ? authority : 'localhost'); const iterator = request[Symbol.asyncIterator](); const encoder = new TextEncoder(); @@ -146,7 +149,7 @@ async function handleNodeTunnelRequest( }, }); - const fetchRequest = new Request(`http://${headers.get('host') ?? 'localhost'}${request.url ?? '/'}`, { + const fetchRequest = new Request(`http://${host}${request.url ?? '/'}`, { method: 'POST', headers, body, diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index caafa67c5d88..eddf15ebede2 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,4 +1,5 @@ import * as http from 'node:http'; +import * as http2 from 'node:http2'; import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; @@ -138,6 +139,34 @@ describe('handleTunnelRequest call forms', () => { } }); + it('forwards from a Node HTTP/2 server, whose request carries pseudo-headers', async () => { + const server = http2.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const client = http2.connect(`http://localhost:${(server.address() as AddressInfo).port}`); + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const { status, body } = await new Promise<{ status: unknown; body: string }>(resolve => { + const req = client.request({ ':method': 'POST', ':path': '/tunnel', 'content-length': envelope.length }); + let responseStatus: unknown; + let responseBody = ''; + req.on('response', headers => (responseStatus = headers[':status'])); + req.on('data', chunk => (responseBody += chunk)); + req.on('end', () => resolve({ status: responseStatus, body: responseBody })); + req.end(envelope); + }); + + expect(status).toBe(202); + expect(body).toBe('forwarded'); + expect(fetchMock).toHaveBeenCalledOnce(); + } finally { + client.close(); + await new Promise(resolve => server.close(resolve)); + } + }); + it('rejects a Node request whose body a parser already read', async () => { const server = http.createServer(async (req, res) => { for await (const _chunk of req) { @@ -863,6 +892,28 @@ describe('serveTunnelIntegration', () => { expect(await response?.text()).toBe('forwarded'); }); + it('serves the tunnel from a copy of core that never set it up, like a separately bundled request hook', async () => { + const client = new ServerRuntimeClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, integrations: [serveTunnelIntegration({ basePath: '/app' })] }), + ); + setCurrentClient(client); + client.init(); + + vi.resetModules(); + const bundledTunnel = await import('../../../src/utils/tunnel'); + const tunnelUrl = `http://localhost/app${getTunnelPath(TEST_DSN)}`; + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const response = await bundledTunnel.handleServedTunnelRequest(makeEnvelopeRequest({ dsn: TEST_DSN }, tunnelUrl)); + vi.unstubAllGlobals(); + + expect(bundledTunnel.isServedTunnelRequest({ method: 'POST', url: tunnelUrl })).toBe(true); + expect(bundledTunnel.isTunnelRequest({ method: 'POST', url: tunnelUrl })).toBe(true); + expect(await response?.text()).toBe('forwarded'); + }); + it('serves nothing for a `_managedTunnel` set without it, since only the integration brings the forwarder', () => { const client = new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })); setCurrentClient(client); From f0f95f3cef7cbb8dc1d64414da0f772276441945 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 17:09:33 -0400 Subject: [PATCH 09/17] fix(core): Drop connection headers when a Node server answers a tunnel request HTTP/2 servers reject connection-specific headers like keep-alive, which Sentry's answer carries. --- packages/core/src/utils/tunnelForward.ts | 21 ++++++++++++++++++++- packages/core/test/lib/utils/tunnel.test.ts | 5 ++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index eec201742843..d859ffe4b70d 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -116,6 +116,19 @@ export function handleTunnelRequestIfMatched( : forwardTunnelRequest(request as Request, tunnel.allowedDsns); } +/** + * Headers about the connection to Sentry, not about the answer. Node sets its own on HTTP/1, and HTTP/2 rejects them. + */ +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-connection', + 'transfer-encoding', + 'upgrade', + 'te', + 'trailer', +]); + function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; } @@ -157,7 +170,13 @@ async function handleNodeTunnelRequest( } as RequestInit); const fetchResponse = await handleTunnelRequest(fetchRequest, options); - response.writeHead(fetchResponse.status, Object.fromEntries(fetchResponse.headers)); + const responseHeaders: Record = {}; + fetchResponse.headers.forEach((value, name) => { + if (!HOP_BY_HOP_HEADERS.has(name)) { + responseHeaders[name] = value; + } + }); + response.writeHead(fetchResponse.status, responseHeaders); response.end(new Uint8Array(await fetchResponse.arrayBuffer())); } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index eddf15ebede2..583c71523bc7 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -139,7 +139,10 @@ describe('handleTunnelRequest call forms', () => { } }); - it('forwards from a Node HTTP/2 server, whose request carries pseudo-headers', async () => { + it('forwards from a Node HTTP/2 server, which rejects the connection headers of the answer', async () => { + fetchMock.mockResolvedValue( + new Response('forwarded', { status: 202, headers: { connection: 'keep-alive', 'keep-alive': 'timeout=5' } }), + ); const server = http2.createServer((req, res) => { void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); }); From 6a2c97417f89b3a52feca3ea7ca577a981eae4d1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 17:29:02 -0400 Subject: [PATCH 10/17] ref(core): Trim the tunnel helpers Reuse resolveTunnelMatch for the allowed DSNs and the served match, stream the Node body with ReadableStream.from, and write back only the headers the browser SDK reads. --- packages/core/src/utils/tunnel.ts | 15 +------ packages/core/src/utils/tunnelForward.ts | 48 +++++++-------------- packages/core/test/lib/utils/tunnel.test.ts | 5 +++ 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index f3e0c66adcfa..6aa29af0a6aa 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -111,13 +111,6 @@ export function getTunnelPaths(): string[] { return getServedTunnel()?.paths ?? []; } -/** - * Returns the DSNs the active server client's managed tunnel forwards, its own DSN included. - */ -export function getTunnelAllowedDsns(): string[] | undefined { - return getServedTunnel()?.allowedDsns; -} - /** * The paths a managed tunnel is served at: the fixed `path`, or else one per allowed DSN. */ @@ -216,13 +209,7 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: ServerTunn export function isServedTunnelRequest(request: TunnelRequestLike): boolean { const servedTunnel = getServedTunnel(); - return ( - !!servedTunnel && - matchesTunnel(request, { - paths: servedTunnel.paths, - basePath: getClient()?.getOptions()._managedTunnel?.basePath, - }) - ); + return !!servedTunnel && matchesTunnel(request, resolveTunnelMatch()); } /** diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index d859ffe4b70d..e6a5502a970c 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -1,10 +1,9 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; -import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import type { ServerTunnelOptions } from '../types/options'; -import { getTunnelAllowedDsns, matchesTunnel, resolveAllowedDsns, resolveTunnelMatch } from './tunnel'; +import { matchesTunnel, resolveTunnelMatch } from './tunnel'; /** * Options for {@link handleTunnelRequest}. @@ -26,7 +25,7 @@ export interface HandleTunnelRequestOptions { /** * The parts of Node's `IncomingMessage` the tunnel reads. */ -interface NodeTunnelRequest extends AsyncIterable { +interface NodeTunnelRequest extends AsyncIterable { method?: string; url?: string; headers: Record; @@ -76,9 +75,7 @@ export function handleTunnelRequest( const options = isLegacyCall ? requestOrOptions : responseOrOptions; // Without `allowedDsns`, the DSNs `serveTunnelIntegration` allows, so its options apply here too. - const allowedDsns = options?.allowedDsns - ? resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn()) - : (getTunnelAllowedDsns() ?? resolveAllowedDsns(undefined, getClient()?.getDsn())); + const { allowedDsns } = resolveTunnelMatch(options?.allowedDsns && { allowedDsns: options.allowedDsns }); return forwardTunnelRequest(request, allowedDsns); } @@ -117,17 +114,14 @@ export function handleTunnelRequestIfMatched( } /** - * Headers about the connection to Sentry, not about the answer. Node sets its own on HTTP/1, and HTTP/2 rejects them. + * The headers of Sentry's answer the browser SDK reads. The rest describe the connection to Sentry, which HTTP/2 + * rejects. */ -const HOP_BY_HOP_HEADERS = new Set([ - 'connection', - 'keep-alive', - 'proxy-connection', - 'transfer-encoding', - 'upgrade', - 'te', - 'trailer', -]); +const FORWARDED_RESPONSE_HEADERS = ['content-type', 'retry-after', 'x-sentry-rate-limits']; + +interface ReadableStreamWithFrom { + from(iterable: AsyncIterable): ReadableStream; +} function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; @@ -148,19 +142,8 @@ async function handleNodeTunnelRequest( const authority = request.headers[':authority']; const host = headers.get('host') ?? (typeof authority === 'string' ? authority : 'localhost'); - const iterator = request[Symbol.asyncIterator](); - const encoder = new TextEncoder(); - const body = new ReadableStream({ - async pull(controller) { - const { done, value } = await iterator.next(); - if (done) { - controller.close(); - return; - } - - controller.enqueue(typeof value === 'string' ? encoder.encode(value) : value); - }, - }); + // In every runtime the Node form runs on, but not in core's TypeScript lib. + const body = (ReadableStream as unknown as ReadableStreamWithFrom).from(request); const fetchRequest = new Request(`http://${host}${request.url ?? '/'}`, { method: 'POST', @@ -171,11 +154,12 @@ async function handleNodeTunnelRequest( const fetchResponse = await handleTunnelRequest(fetchRequest, options); const responseHeaders: Record = {}; - fetchResponse.headers.forEach((value, name) => { - if (!HOP_BY_HOP_HEADERS.has(name)) { + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = fetchResponse.headers.get(name); + if (value) { responseHeaders[name] = value; } - }); + } response.writeHead(fetchResponse.status, responseHeaders); response.end(new Uint8Array(await fetchResponse.arrayBuffer())); } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 583c71523bc7..3044f0313a94 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -119,6 +119,9 @@ describe('handleTunnelRequest call forms', () => { }); it("forwards from a Node server's request and writes the answer to its response", async () => { + fetchMock.mockResolvedValue( + new Response('forwarded', { status: 202, headers: { 'x-sentry-rate-limits': '60:error', 'retry-after': '60' } }), + ); const server = http.createServer((req, res) => { void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); }); @@ -130,6 +133,8 @@ describe('handleTunnelRequest call forms', () => { const response = await realFetch(`http://localhost:${port}/tunnel`, { method: 'POST', body: envelope }); expect(response.status).toBe(202); + expect(response.headers.get('x-sentry-rate-limits')).toBe('60:error'); + expect(response.headers.get('retry-after')).toBe('60'); expect(await response.text()).toBe('forwarded'); const [url, init] = fetchMock.mock.lastCall as unknown as [string, RequestInit]; expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); From e32379d97474072267b244119f1a471eefdf4c88 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 17:41:16 -0400 Subject: [PATCH 11/17] fix(core): Stream the Node tunnel body without ReadableStream.from, which Bun 1.3 lacks --- packages/core/src/utils/tunnelForward.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index e6a5502a970c..079da2c81a41 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -25,7 +25,7 @@ export interface HandleTunnelRequestOptions { /** * The parts of Node's `IncomingMessage` the tunnel reads. */ -interface NodeTunnelRequest extends AsyncIterable { +interface NodeTunnelRequest extends AsyncIterable { method?: string; url?: string; headers: Record; @@ -119,10 +119,6 @@ export function handleTunnelRequestIfMatched( */ const FORWARDED_RESPONSE_HEADERS = ['content-type', 'retry-after', 'x-sentry-rate-limits']; -interface ReadableStreamWithFrom { - from(iterable: AsyncIterable): ReadableStream; -} - function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; } @@ -142,8 +138,20 @@ async function handleNodeTunnelRequest( const authority = request.headers[':authority']; const host = headers.get('host') ?? (typeof authority === 'string' ? authority : 'localhost'); - // In every runtime the Node form runs on, but not in core's TypeScript lib. - const body = (ReadableStream as unknown as ReadableStreamWithFrom).from(request); + const iterator = request[Symbol.asyncIterator](); + const encoder = new TextEncoder(); + // Not `ReadableStream.from`, which Bun 1.3 lacks. + const body = new ReadableStream({ + async pull(controller) { + const { done, value } = await iterator.next(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(typeof value === 'string' ? encoder.encode(value) : value); + }, + }); const fetchRequest = new Request(`http://${host}${request.url ?? '/'}`, { method: 'POST', From 32fdf4fa6a77604041521cb752435305f0074c4a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 17:45:25 -0400 Subject: [PATCH 12/17] ref(core): Resolve the served tunnel and allowed DSNs once per request Caches the served tunnel a second module copy rebuilds, stores its base path so it matches directly, and splits the Node tunnel path into request and response conversions so both entry points forward with DSNs they already resolved. --- .../core/src/utils/managedTunnelDefaults.ts | 15 +--- packages/core/src/utils/tunnel.ts | 70 ++++++++++++------- packages/core/src/utils/tunnelForward.ts | 46 +++++++----- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index 1e46bc85e090..c78cdd55136d 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -1,14 +1,11 @@ import type { Client } from '../client'; -import type { Integration } from '../types/integration'; import { getClient } from '../currentScopes'; import type { IgnoreSpanFilter, ServerTunnelOptions } from '../types/options'; import type { TunnelForwarder } from './tunnel'; import { - SERVE_TUNNEL_INTEGRATION_NAME, + getServeTunnelForwarder, getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, - resolveAllowedDsns, - resolveTunnelPaths, setServedTunnel, } from './tunnel'; @@ -73,11 +70,7 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void { const tunnelOptions: ServerTunnelOptions | undefined = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; // A framework SDK can bundle its own copy of this module, which only reaches the forwarder through the client. const forward = - client && - (tunnelForwarders.get(client) ?? - client.getIntegrationByName?.(SERVE_TUNNEL_INTEGRATION_NAME) - ?.forward ?? - managedTunnelDefaults?.forward); + client && (tunnelForwarders.get(client) ?? getServeTunnelForwarder(client) ?? managedTunnelDefaults?.forward); if (!client || !options || !tunnelOptions || !forward) { return; } @@ -88,9 +81,7 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void { ? { ...tunnelOptions, basePath } : tunnelOptions; options._managedTunnel = resolved; - const allowedDsns = resolveAllowedDsns(resolved.allowedDsns, client.getDsn()); - const paths = resolveTunnelPaths(resolved, allowedDsns); - setServedTunnel(client, { paths, allowedDsns, forward }); + const { paths } = setServedTunnel(client, resolved, forward); const previousFilters = installedSpanFilters.get(client) ?? []; const spanFilters = getTunnelIgnoreSpanFilters(resolved, paths); diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 6aa29af0a6aa..62144a180134 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -14,11 +14,9 @@ import { parseUrl } from './url'; export type TunnelForwarder = (request: Request, allowedDsns: Array) => Promise; /** - * A server client's tunnel paths, allowed DSNs and forwarder, computed once per client. + * A server client's tunnel paths, base path, allowed DSNs and forwarder, computed once per client. */ -interface ServedTunnel { - paths: string[]; - allowedDsns: string[]; +interface ServedTunnel extends TunnelMatch { forward: TunnelForwarder; } @@ -31,34 +29,53 @@ const servedTunnels = new WeakMap(); export const SERVE_TUNNEL_INTEGRATION_NAME = 'ServeTunnel'; /** - * Stores a server client's computed tunnel paths and allowed DSNs. + * Computes a server client's served tunnel from its resolved options, and stores it. */ -export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { +export function setServedTunnel( + client: Client, + tunnelOptions: ServerTunnelOptions, + forward: TunnelForwarder, +): ServedTunnel { + const allowedDsns = resolveAllowedDsns(tunnelOptions.allowedDsns, client.getDsn()); + const servedTunnel = { + paths: resolveTunnelPaths(tunnelOptions, allowedDsns), + basePath: tunnelOptions.basePath, + allowedDsns, + forward, + }; servedTunnels.set(client, servedTunnel); -} -function getServedTunnel(): ServedTunnel | undefined { - const client = getClient(); + return servedTunnel; +} - return client && (servedTunnels.get(client) ?? getServedTunnelFromClient(client)); +/** + * The forwarder `serveTunnelIntegration` carries. Any copy of this module can reach it through the client. + */ +export function getServeTunnelForwarder(client: Client): TunnelForwarder | undefined { + return client.getIntegrationByName?.(SERVE_TUNNEL_INTEGRATION_NAME) + ?.forward; } /** - * Rebuilds the served tunnel from what the client carries, for a copy of this module that didn't set it up. Bundlers - * can give a framework's request hook its own copy, like Next.js's middleware next to its `instrumentation`. + * The active client's served tunnel. A copy of this module that didn't set it up rebuilds it once from what the + * client carries. Bundlers can give a framework's request hook its own copy, like Next.js's middleware next to its + * `instrumentation`. */ -function getServedTunnelFromClient(client: Client): ServedTunnel | undefined { - const tunnelOptions = client.getOptions()._managedTunnel; - const forward = client.getIntegrationByName?.( - SERVE_TUNNEL_INTEGRATION_NAME, - )?.forward; - if (!tunnelOptions || !forward) { +function getServedTunnel(): ServedTunnel | undefined { + const client = getClient(); + if (!client) { return undefined; } - const allowedDsns = resolveAllowedDsns(tunnelOptions.allowedDsns, client.getDsn()); + const servedTunnel = servedTunnels.get(client); + if (servedTunnel) { + return servedTunnel; + } - return { paths: resolveTunnelPaths(tunnelOptions, allowedDsns), allowedDsns, forward }; + const tunnelOptions = client.getOptions()._managedTunnel; + const forward = getServeTunnelForwarder(client); + + return tunnelOptions && forward ? setServedTunnel(client, tunnelOptions, forward) : undefined; } /** @@ -170,13 +187,12 @@ interface TunnelMatch { * path of the active client's own DSN. */ export function resolveTunnelMatch(options?: ServerTunnelOptions): TunnelMatch { - const client = getClient(); - const servedTunnel = getServedTunnel(); - if (!options && servedTunnel) { - return { ...servedTunnel, basePath: client?.getOptions()._managedTunnel?.basePath }; + const servedTunnel = !options && getServedTunnel(); + if (servedTunnel) { + return servedTunnel; } - const allowedDsns = resolveAllowedDsns(options?.allowedDsns, client?.getDsn()); + const allowedDsns = resolveAllowedDsns(options?.allowedDsns, getClient()?.getDsn()); return { paths: resolveTunnelPaths(options ?? {}, allowedDsns), basePath: options?.basePath, allowedDsns }; } @@ -209,7 +225,7 @@ export function isTunnelRequest(request: TunnelRequestLike, options?: ServerTunn export function isServedTunnelRequest(request: TunnelRequestLike): boolean { const servedTunnel = getServedTunnel(); - return !!servedTunnel && matchesTunnel(request, resolveTunnelMatch()); + return !!servedTunnel && matchesTunnel(request, servedTunnel); } /** @@ -218,7 +234,7 @@ export function isServedTunnelRequest(request: TunnelRequestLike): boolean { */ export function handleServedTunnelRequest(request: Request): Promise | undefined { const servedTunnel = getServedTunnel(); - if (!servedTunnel?.allowedDsns.length || !isServedTunnelRequest(request)) { + if (!servedTunnel?.allowedDsns.length || !matchesTunnel(request, servedTunnel)) { return undefined; } diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 079da2c81a41..d438229ff29f 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -1,9 +1,10 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import { getClient } from '../currentScopes'; import { suppressTracing } from '../tracing/trace'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import type { ServerTunnelOptions } from '../types/options'; -import { matchesTunnel, resolveTunnelMatch } from './tunnel'; +import { matchesTunnel, resolveAllowedDsns, resolveTunnelMatch } from './tunnel'; /** * Options for {@link handleTunnelRequest}. @@ -65,7 +66,12 @@ export function handleTunnelRequest( nodeOptions?: HandleTunnelRequestOptions, ): Promise { if (isNodeTunnelResponse(responseOrOptions)) { - return handleNodeTunnelRequest(requestOrOptions as NodeTunnelRequest, responseOrOptions, nodeOptions); + const nodeResponse = responseOrOptions; + + return forwardTunnelRequest( + toFetchRequest(requestOrOptions as NodeTunnelRequest), + getAllowedDsns(nodeOptions), + ).then(fetchResponse => writeNodeResponse(nodeResponse, fetchResponse)); } // The released `handleTunnelRequest({ request })` form. A `Request` has a `method`, the options object doesn't. @@ -74,10 +80,7 @@ export function handleTunnelRequest( const request = isLegacyCall ? requestOrOptions.request : (requestOrOptions as Request); const options = isLegacyCall ? requestOrOptions : responseOrOptions; - // Without `allowedDsns`, the DSNs `serveTunnelIntegration` allows, so its options apply here too. - const { allowedDsns } = resolveTunnelMatch(options?.allowedDsns && { allowedDsns: options.allowedDsns }); - - return forwardTunnelRequest(request, allowedDsns); + return forwardTunnelRequest(request, getAllowedDsns(options)); } /** @@ -108,9 +111,23 @@ export function handleTunnelRequestIfMatched( return undefined; } - return response - ? handleNodeTunnelRequest(request as NodeTunnelRequest, response, { allowedDsns: tunnel.allowedDsns }) - : forwardTunnelRequest(request as Request, tunnel.allowedDsns); + if (!response) { + return forwardTunnelRequest(request as Request, tunnel.allowedDsns); + } + + return forwardTunnelRequest(toFetchRequest(request as NodeTunnelRequest), tunnel.allowedDsns).then(fetchResponse => + writeNodeResponse(response, fetchResponse), + ); +} + +/** + * `allowedDsns` plus the server's own DSN. Without `allowedDsns`, the DSNs `serveTunnelIntegration` allows, so its + * options apply here too. + */ +function getAllowedDsns(options: HandleTunnelRequestOptions | undefined): string[] { + return options?.allowedDsns + ? resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn()) + : resolveTunnelMatch().allowedDsns; } /** @@ -123,11 +140,7 @@ function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; } -async function handleNodeTunnelRequest( - request: NodeTunnelRequest, - response: NodeTunnelResponse, - options: HandleTunnelRequestOptions | undefined, -): Promise { +function toFetchRequest(request: NodeTunnelRequest): Request { const headers = new Headers(); for (const [name, value] of Object.entries(request.headers)) { // HTTP/2 pseudo-headers like `:authority` aren't valid fetch header names. @@ -153,14 +166,15 @@ async function handleNodeTunnelRequest( }, }); - const fetchRequest = new Request(`http://${host}${request.url ?? '/'}`, { + return new Request(`http://${host}${request.url ?? '/'}`, { method: 'POST', headers, body, duplex: 'half', } as RequestInit); +} - const fetchResponse = await handleTunnelRequest(fetchRequest, options); +async function writeNodeResponse(response: NodeTunnelResponse, fetchResponse: Response): Promise { const responseHeaders: Record = {}; for (const name of FORWARDED_RESPONSE_HEADERS) { const value = fetchResponse.headers.get(name); From 5e0a515d206e99eb03314a8cb4e70326735a653e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 17:48:44 -0400 Subject: [PATCH 13/17] fix(core): Build the Node tunnel request on a fixed URL, not the client's Host header Node accepts Host headers that aren't valid URL hosts, which made the Request constructor throw. --- packages/core/src/utils/tunnelForward.ts | 7 +++--- packages/core/test/lib/utils/tunnel.test.ts | 24 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index d438229ff29f..7d994417b11d 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -148,9 +148,6 @@ function toFetchRequest(request: NodeTunnelRequest): Request { headers.set(name, Array.isArray(value) ? value.join(', ') : value); } } - const authority = request.headers[':authority']; - const host = headers.get('host') ?? (typeof authority === 'string' ? authority : 'localhost'); - const iterator = request[Symbol.asyncIterator](); const encoder = new TextEncoder(); // Not `ReadableStream.from`, which Bun 1.3 lacks. @@ -166,7 +163,9 @@ function toFetchRequest(request: NodeTunnelRequest): Request { }, }); - return new Request(`http://${host}${request.url ?? '/'}`, { + // The forward reads only the headers and the body. A URL built from the request's `Host` would throw on one + // Node accepts, like `a b`. + return new Request('http://localhost/', { method: 'POST', headers, body, diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 3044f0313a94..897eb8d6b513 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,5 +1,6 @@ import * as http from 'node:http'; import * as http2 from 'node:http2'; +import * as net from 'node:net'; import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; @@ -175,6 +176,29 @@ describe('handleTunnelRequest call forms', () => { } }); + it('forwards a Node request whose Host header is no valid URL host', async () => { + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const statusLine = await new Promise((resolve, reject) => { + const socket = net.connect((server.address() as AddressInfo).port, () => { + socket.end(`POST /tunnel HTTP/1.1\r\nHost: a b<>\r\nContent-Length: ${envelope.length}\r\n\r\n${envelope}`); + }); + socket.on('data', data => resolve(String(data).split('\r\n')[0]!)); + socket.on('error', reject); + }); + + expect(statusLine).toBe('HTTP/1.1 202 Accepted'); + expect(fetchMock).toHaveBeenCalledOnce(); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + it('rejects a Node request whose body a parser already read', async () => { const server = http.createServer(async (req, res) => { for await (const _chunk of req) { From d3a9544718511b34d2dae7683a85de6b55f9accc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 17:52:41 -0400 Subject: [PATCH 14/17] fix(core): Answer a Node tunnel request with a 500 when Sentry's answer breaks off --- packages/core/src/utils/tunnelForward.ts | 10 +++++++- packages/core/test/lib/utils/tunnel.test.ts | 26 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 7d994417b11d..fa2b84b30b34 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -174,6 +174,14 @@ function toFetchRequest(request: NodeTunnelRequest): Request { } async function writeNodeResponse(response: NodeTunnelResponse, fetchResponse: Response): Promise { + // Read before writing, so an answer from Sentry that breaks off still ends the browser's request. + const body = await fetchResponse.arrayBuffer().catch(() => undefined); + if (!body) { + response.writeHead(500); + response.end(); + return; + } + const responseHeaders: Record = {}; for (const name of FORWARDED_RESPONSE_HEADERS) { const value = fetchResponse.headers.get(name); @@ -182,7 +190,7 @@ async function writeNodeResponse(response: NodeTunnelResponse, fetchResponse: Re } } response.writeHead(fetchResponse.status, responseHeaders); - response.end(new Uint8Array(await fetchResponse.arrayBuffer())); + response.end(new Uint8Array(body)); } function withDecodedBodyHeaders(response: Response): Response { diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 897eb8d6b513..70e6e3558587 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -199,6 +199,32 @@ describe('handleTunnelRequest call forms', () => { } }); + it("answers a Node request with a 500 when Sentry's answer breaks off", async () => { + fetchMock.mockResolvedValue( + new Response( + new ReadableStream({ + pull(controller) { + controller.error(new Error('connection to Sentry dropped')); + }, + }), + ), + ); + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const envelope = serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, [])) as string; + + try { + const response = await realFetch(`http://localhost:${port}/tunnel`, { method: 'POST', body: envelope }); + + expect(response.status).toBe(500); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + it('rejects a Node request whose body a parser already read', async () => { const server = http.createServer(async (req, res) => { for await (const _chunk of req) { From e80e9999c52979022dcae3437dd083b60ba4ef0a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 17:56:07 -0400 Subject: [PATCH 15/17] fix(solidstart): Serve the tunnel only with serveTunnelIntegration, like the other framework hooks --- packages/solidstart/src/nitro/tunnelPlugin.ts | 8 ++++---- .../solidstart/test/nitro/tunnelPlugin.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts index 6832dd657fae..c6882b74082d 100644 --- a/packages/solidstart/src/nitro/tunnelPlugin.ts +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -1,7 +1,7 @@ import { GLOBAL_OBJ } from '@sentry/core'; import { - handleTunnelRequestIfMatched, - isTunnelRequest, + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults, } from '@sentry/core/server'; import { eventHandler, toWebRequest } from 'h3'; @@ -25,13 +25,13 @@ export default (nitroApp => { route: '', handler: eventHandler(event => { // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. - if (!isTunnelRequest({ method: event.method, url: event.path })) { + if (!isServedTunnelRequest({ method: event.method, url: event.path })) { return undefined; } // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which // aren't Node streams. A returned `Response` is sent and ends the request, `undefined` hands it on. - return handleTunnelRequestIfMatched(toWebRequest(event)); + return handleServedTunnelRequest(toWebRequest(event)); }), }); }) satisfies NitroAppPlugin; diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts index 1ad7099f278b..0f03cef43745 100644 --- a/packages/solidstart/test/nitro/tunnelPlugin.test.ts +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -67,6 +67,20 @@ describe('SolidStart v1 tunnel plugin', () => { expect(onError).not.toHaveBeenCalled(); }); + it('serves no tunnel without `serveTunnelIntegration`', async () => { + const options = { dsn: DSN }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; + + const response = await fetchApp(getTunnelPath(DSN)!, { + method: 'POST', + body: envelope, + headers: { 'content-length': String(envelope.length) }, + }); + + expect(await response.text()).toBe('app'); + }); + it('leaves every other request to the app', async () => { const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); From c4850c7b13c0112bec3fa9447f09bda8c18fb682 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Oct 2026 18:06:49 -0400 Subject: [PATCH 16/17] fix(core): Reject a non-string envelope DSN, keep the released handleTunnelRequest options type, and accept HTTP/2 responses --- packages/core/src/utils/tunnelForward.ts | 29 ++++++++++------- packages/core/test/lib/utils/tunnel.test.ts | 36 +++++++++++++++++++-- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index fa2b84b30b34..909fe7d297f7 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -15,7 +15,7 @@ export interface HandleTunnelRequestOptions { * * @deprecated Pass the request as the first argument of `handleTunnelRequest` instead. */ - request?: Request; + request: Request; /** * DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. Defaults to the DSNs * `serveTunnelIntegration` allows. @@ -23,6 +23,11 @@ export interface HandleTunnelRequestOptions { allowedDsns?: Array; } +/** + * The options of {@link handleTunnelRequest} that take the request as an argument. + */ +type TunnelForwardOptions = Omit; + /** * The parts of Node's `IncomingMessage` the tunnel reads. */ @@ -37,7 +42,7 @@ interface NodeTunnelRequest extends AsyncIterable { */ interface NodeTunnelResponse { writeHead(statusCode: number, headers?: Record): unknown; - end(chunk?: Uint8Array): unknown; + end(chunk: Uint8Array): unknown; } /** @@ -46,7 +51,7 @@ interface NodeTunnelResponse { * * @returns Sentry's response, or the response rejecting the request. */ -export function handleTunnelRequest(request: Request, options?: HandleTunnelRequestOptions): Promise; +export function handleTunnelRequest(request: Request, options?: TunnelForwardOptions): Promise; /** * Forwards a tunnel request from a Node server, like Express or `node:http`, and writes the answer to `response`. * Mount it before body parsers, which would read the envelope first. @@ -54,16 +59,16 @@ export function handleTunnelRequest(request: Request, options?: HandleTunnelRequ export function handleTunnelRequest( request: NodeTunnelRequest, response: NodeTunnelResponse, - options?: HandleTunnelRequestOptions, + options?: TunnelForwardOptions, ): Promise; /** * The released form, kept until v12. Its `request` option is deprecated: pass the request as the first argument. */ -export function handleTunnelRequest(options: HandleTunnelRequestOptions & { request: Request }): Promise; +export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise; export function handleTunnelRequest( - requestOrOptions: Request | NodeTunnelRequest | (HandleTunnelRequestOptions & { request: Request }), - responseOrOptions?: NodeTunnelResponse | HandleTunnelRequestOptions, - nodeOptions?: HandleTunnelRequestOptions, + requestOrOptions: Request | NodeTunnelRequest | HandleTunnelRequestOptions, + responseOrOptions?: NodeTunnelResponse | TunnelForwardOptions, + nodeOptions?: TunnelForwardOptions, ): Promise { if (isNodeTunnelResponse(responseOrOptions)) { const nodeResponse = responseOrOptions; @@ -124,7 +129,7 @@ export function handleTunnelRequestIfMatched( * `allowedDsns` plus the server's own DSN. Without `allowedDsns`, the DSNs `serveTunnelIntegration` allows, so its * options apply here too. */ -function getAllowedDsns(options: HandleTunnelRequestOptions | undefined): string[] { +function getAllowedDsns(options: TunnelForwardOptions | undefined): string[] { return options?.allowedDsns ? resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn()) : resolveTunnelMatch().allowedDsns; @@ -178,7 +183,7 @@ async function writeNodeResponse(response: NodeTunnelResponse, fetchResponse: Re const body = await fetchResponse.arrayBuffer().catch(() => undefined); if (!body) { response.writeHead(500); - response.end(); + response.end(new Uint8Array()); return; } @@ -253,7 +258,7 @@ async function forwardEnvelope(request: Request, allowedDsns: Array): Pr * Returns the ingest URL for an envelope header line, or the rejecting response. */ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { - let envelopeHeader: { dsn?: string } | null; + let envelopeHeader: { dsn?: unknown } | null; try { envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); } catch { @@ -265,7 +270,7 @@ function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): s } const dsn = envelopeHeader.dsn; - if (!dsn) { + if (!dsn || typeof dsn !== 'string') { return new Response('Invalid envelope: missing DSN', { status: 400 }); } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 70e6e3558587..30c5f7372fc1 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -20,6 +20,7 @@ import { resolveAllowedDsns, resolveTunnelPaths, } from '../../../src/utils/tunnel'; +import type { HandleTunnelRequestOptions } from '../../../src/utils/tunnelForward'; import { forwardTunnelRequest, handleTunnelRequest, @@ -110,11 +111,13 @@ describe('handleTunnelRequest call forms', () => { }); it('still takes the released options object', async () => { + // A typed variable, like code written against the released type. // oxlint-disable-next-line typescript/no-deprecated -- the form under test - const response = await handleTunnelRequest({ + const options: HandleTunnelRequestOptions = { request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN], - }); + }; + const response = await handleTunnelRequest(options); expect(response.status).toBe(202); }); @@ -225,6 +228,35 @@ describe('handleTunnelRequest call forms', () => { } }); + it('rejects an envelope whose DSN is no string, also from a Node server', async () => { + const body = '{"dsn":{"toString":1}}\n{}'; + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + + try { + const fetchForm = await handleTunnelRequest( + new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': `${body.length}` }, + }), + { allowedDsns: [TEST_DSN] }, + ); + const nodeForm = await realFetch(`http://localhost:${(server.address() as AddressInfo).port}/tunnel`, { + method: 'POST', + body, + }); + + expect(fetchForm.status).toBe(400); + expect(nodeForm.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + it('rejects a Node request whose body a parser already read', async () => { const server = http.createServer(async (req, res) => { for await (const _chunk of req) { From 1c77ce4f718923a452ec999cc36e748550b76541 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 28 Oct 2026 09:28:06 -0400 Subject: [PATCH 17/17] fix(core): Drain the rest of a rejected Node tunnel request Unread body bytes stalled the next request on a keep-alive connection. --- packages/core/src/utils/tunnelForward.ts | 6 +++++ packages/core/test/lib/utils/tunnel.test.ts | 28 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 909fe7d297f7..e30d7e21bfe4 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -166,6 +166,12 @@ function toFetchRequest(request: NodeTunnelRequest): Request { controller.enqueue(typeof value === 'string' ? encoder.encode(value) : value); }, + // Node only reads the rest of a body nobody started reading, and unread bytes stall a keep-alive connection. + async cancel() { + while (!(await iterator.next()).done) { + // Drain. + } + }, }); // The forward reads only the headers and the body. A URL built from the request's `Host` would throw on one diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 30c5f7372fc1..f35bbf595bb5 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -257,6 +257,34 @@ describe('handleTunnelRequest call forms', () => { } }); + it('reads the rest of a rejected Node request, so the next request on the connection gets an answer', async () => { + const server = http.createServer((req, res) => { + void handleTunnelRequest(req, res, { allowedDsns: [TEST_DSN] }); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + const post = (body: string): Promise => + new Promise((resolve, reject) => { + http + .request({ port, agent, method: 'POST', path: '/tunnel' }, res => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }) + .on('error', reject) + .end(body); + }); + const foreign = serializeEnvelope(createEnvelope({ dsn: SERVER_DSN }, [])) as string; + + try { + expect(await post(`${foreign}\n${'a'.repeat(2 * 1024 * 1024)}`)).toBe(403); + expect(await post(foreign)).toBe(403); + } finally { + agent.destroy(); + await new Promise(resolve => server.close(resolve)); + } + }); + it('rejects a Node request whose body a parser already read', async () => { const server = http.createServer(async (req, res) => { for await (const _chunk of req) {