From 171f927e7b8331d03559cd4ee1b9450709e39514 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Mon, 19 Jan 2026 16:39:00 -0500 Subject: [PATCH 01/10] feat(tunnel): framework-agnostic tunnel handler + tanstack start adapter --- packages/core/src/index.ts | 2 + packages/core/src/utils/tunnel.ts | 104 ++++++++++++++++++ .../tanstackstart-react/src/client/index.ts | 10 ++ .../tanstackstart-react/src/index.types.ts | 1 + .../src/server/createTunnelHandler.ts | 38 +++++++ .../tanstackstart-react/src/server/index.ts | 1 + 6 files changed, 156 insertions(+) create mode 100644 packages/core/src/utils/tunnel.ts create mode 100644 packages/tanstackstart-react/src/server/createTunnelHandler.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0fdd328a42d2..bf15e1152600 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,6 +69,8 @@ export { hasSpansEnabled } from './utils/hasSpansEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; +export type { TunnelResult } from './utils/tunnel'; +export { handleTunnelRequest } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts new file mode 100644 index 000000000000..207b912d1e98 --- /dev/null +++ b/packages/core/src/utils/tunnel.ts @@ -0,0 +1,104 @@ +import type { DsnComponents } from '../types-hoist/dsn'; +import { debug } from './debug-logger'; +import { makeDsn } from './dsn'; + +export interface TunnelResult { + status: number; + body: string; + contentType: string; +} + +/** + * Core Sentry tunnel handler - framework agnostic. + * + * Validates the envelope DSN against allowed DSNs and forwards to Sentry. + * + * @param body - Raw request body (Sentry envelope) + * @param allowedDsnComponents - Pre-parsed array of allowed DsnComponents + * @returns Promise resolving to status, body, and contentType + */ +export async function handleTunnelRequest( + body: string, + allowedDsnComponents: Array, +): Promise { + if (allowedDsnComponents.length === 0) { + return { + status: 500, + body: 'Tunnel not configured', + contentType: 'text/plain', + }; + } + + // Sentry envelope format: first line is JSON header with DSN + const [headerLine] = body.split('\n'); + if (!headerLine) { + return { + status: 400, + body: 'Invalid envelope: missing header', + contentType: 'text/plain', + }; + } + + let envelopeHeader: { dsn?: string }; + try { + envelopeHeader = JSON.parse(headerLine); + } catch { + return { + status: 400, + body: 'Invalid envelope: malformed header JSON', + contentType: 'text/plain', + }; + } + + const dsn = envelopeHeader.dsn; + if (!dsn) { + return { + status: 400, + body: 'Invalid envelope: missing DSN', + contentType: 'text/plain', + }; + } + + const dsnComponents = makeDsn(dsn); + if (!dsnComponents) { + return { + status: 400, + body: 'Invalid DSN format', + contentType: 'text/plain', + }; + } + + // SECURITY: Validate that the envelope DSN matches one of the allowed DSNs + // This prevents SSRF attacks where attackers send crafted envelopes + // with malicious DSNs pointing to arbitrary hosts + const isAllowed = allowedDsnComponents.some( + allowed => allowed.host === dsnComponents.host && allowed.projectId === dsnComponents.projectId, + ); + + if (!isAllowed) { + debug.warn( + `Sentry tunnel: rejected request with unauthorized DSN (host: ${dsnComponents.host}, project: ${dsnComponents.projectId})`, + ); + return { + status: 403, + body: 'DSN not allowed', + contentType: 'text/plain', + }; + } + + const sentryIngestUrl = `https://${dsnComponents.host}/api/${dsnComponents.projectId}/envelope/`; + + const response = await fetch(sentryIngestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body, + }); + + return { + status: response.status, + body: await response.text(), + contentType: response.headers.get('Content-Type') || 'text/plain', + }; +} diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index b2b9add0d06b..609c5f1cc6bc 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -14,3 +14,13 @@ export { init } from './sdk'; export function wrapMiddlewaresWithSentry(middlewares: Record): T[] { return Object.values(middlewares); } + +/** + * No-op stub for client-side builds. + * The actual implementation is server-only, but this stub is needed to prevent build errors. + */ +export function createTunnelHandler( + _allowedDsns: Array, +): (args: { request: Request }) => Promise { + return async () => new Response('Tunnel handler is not available on the client', { status: 500 }); +} diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index ca41d6ce05ee..1477510bd17f 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -37,3 +37,4 @@ export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewaresWithSentry; +export declare const createTunnelHandler: typeof serverSdk.createTunnelHandler; diff --git a/packages/tanstackstart-react/src/server/createTunnelHandler.ts b/packages/tanstackstart-react/src/server/createTunnelHandler.ts new file mode 100644 index 000000000000..134e69b514e5 --- /dev/null +++ b/packages/tanstackstart-react/src/server/createTunnelHandler.ts @@ -0,0 +1,38 @@ +import { type DsnComponents, handleTunnelRequest, makeDsn } from '@sentry/core'; + +/** + * Creates a Sentry tunnel handler for TanStack Start. + * + * @param allowedDsns - Array of DSN strings that this tunnel will accept. + * @returns TanStack Start compatible request handler + * + * @example + * const handler = createSentryTunnelHandler([process.env.SENTRY_DSN]) + * export const Route = createFileRoute('/tunnel')({ + * server: { handlers: { POST: handler } } + * }) + */ +export function createTunnelHandler( + allowedDsns: Array, +): (args: { request: Request }) => Promise { + const allowedDsnComponents = allowedDsns.map(makeDsn).filter((c): c is DsnComponents => c !== undefined); + + if (allowedDsnComponents.length === 0) { + // eslint-disable-next-line no-console + console.warn('Sentry tunnel: No valid DSNs provided. All requests will be rejected.'); + } + + return async ({ request }: { request: Request }): Promise => { + try { + const body = await request.text(); + const result = await handleTunnelRequest(body, allowedDsnComponents); + + return new Response(result.body, { + status: result.status, + headers: { 'Content-Type': result.contentType }, + }); + } catch (error) { + return new Response('Internal server error', { status: 500 }); + } + }; +} diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 5765114cd28b..1adf32654b42 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -6,6 +6,7 @@ export * from '@sentry/node'; export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; +export { createTunnelHandler } from './createTunnelHandler'; /** * A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors From 64add32a4dfcf7e77f3eb319f6c106f909589ea0 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 20 Jan 2026 09:20:45 -0500 Subject: [PATCH 02/10] feat(tunnel): using parseEnvelope instead of manually extracting envelope header --- packages/core/src/utils/tunnel.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 207b912d1e98..67fbe5c3db19 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,6 +1,7 @@ import type { DsnComponents } from '../types-hoist/dsn'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; +import { parseEnvelope } from './envelope'; export interface TunnelResult { status: number; @@ -18,7 +19,7 @@ export interface TunnelResult { * @returns Promise resolving to status, body, and contentType */ export async function handleTunnelRequest( - body: string, + body: string | Uint8Array, allowedDsnComponents: Array, ): Promise { if (allowedDsnComponents.length === 0) { @@ -29,9 +30,8 @@ export async function handleTunnelRequest( }; } - // Sentry envelope format: first line is JSON header with DSN - const [headerLine] = body.split('\n'); - if (!headerLine) { + const [envelopeHeader] = parseEnvelope(body); + if (!envelopeHeader) { return { status: 400, body: 'Invalid envelope: missing header', @@ -39,17 +39,6 @@ export async function handleTunnelRequest( }; } - let envelopeHeader: { dsn?: string }; - try { - envelopeHeader = JSON.parse(headerLine); - } catch { - return { - status: 400, - body: 'Invalid envelope: malformed header JSON', - contentType: 'text/plain', - }; - } - const dsn = envelopeHeader.dsn; if (!dsn) { return { From 99f39eb4c5f185456b0b471e55e98bfb398db0aa Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 17 Feb 2026 15:17:52 -0500 Subject: [PATCH 03/10] feat(tunnel): removing createTunnelHandler from tanstackstart-react package This will be added in a different PR, separate from the core util function --- .../tanstackstart-react/src/index.types.ts | 1 - .../src/server/createTunnelHandler.ts | 38 ------------------- .../tanstackstart-react/src/server/index.ts | 1 - 3 files changed, 40 deletions(-) delete mode 100644 packages/tanstackstart-react/src/server/createTunnelHandler.ts diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 1477510bd17f..ca41d6ce05ee 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -37,4 +37,3 @@ export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewaresWithSentry; -export declare const createTunnelHandler: typeof serverSdk.createTunnelHandler; diff --git a/packages/tanstackstart-react/src/server/createTunnelHandler.ts b/packages/tanstackstart-react/src/server/createTunnelHandler.ts deleted file mode 100644 index 134e69b514e5..000000000000 --- a/packages/tanstackstart-react/src/server/createTunnelHandler.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type DsnComponents, handleTunnelRequest, makeDsn } from '@sentry/core'; - -/** - * Creates a Sentry tunnel handler for TanStack Start. - * - * @param allowedDsns - Array of DSN strings that this tunnel will accept. - * @returns TanStack Start compatible request handler - * - * @example - * const handler = createSentryTunnelHandler([process.env.SENTRY_DSN]) - * export const Route = createFileRoute('/tunnel')({ - * server: { handlers: { POST: handler } } - * }) - */ -export function createTunnelHandler( - allowedDsns: Array, -): (args: { request: Request }) => Promise { - const allowedDsnComponents = allowedDsns.map(makeDsn).filter((c): c is DsnComponents => c !== undefined); - - if (allowedDsnComponents.length === 0) { - // eslint-disable-next-line no-console - console.warn('Sentry tunnel: No valid DSNs provided. All requests will be rejected.'); - } - - return async ({ request }: { request: Request }): Promise => { - try { - const body = await request.text(); - const result = await handleTunnelRequest(body, allowedDsnComponents); - - return new Response(result.body, { - status: result.status, - headers: { 'Content-Type': result.contentType }, - }); - } catch (error) { - return new Response('Internal server error', { status: 500 }); - } - }; -} diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 1adf32654b42..5765114cd28b 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -6,7 +6,6 @@ export * from '@sentry/node'; export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; -export { createTunnelHandler } from './createTunnelHandler'; /** * A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors From d4d56343d991e4b0ea89b7393e55193e60488cc2 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 17 Feb 2026 15:24:03 -0500 Subject: [PATCH 04/10] refactor(tunnel): renamed handleTunnelRequest and made it receive Request and return Response --- packages/core/src/index.ts | 4 +- packages/core/src/utils/tunnel.ts | 83 ++++++++++--------------------- 2 files changed, 29 insertions(+), 58 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bf15e1152600..fbe53a2a7875 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,8 +69,8 @@ export { hasSpansEnabled } from './utils/hasSpansEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; -export type { TunnelResult } from './utils/tunnel'; -export { handleTunnelRequest } from './utils/tunnel'; +export type { HandleTunnelRequestOptions } from './utils/tunnel'; +export { createTunnelRequest } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 67fbe5c3db19..bbe450675e61 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,93 +1,64 @@ -import type { DsnComponents } from '../types-hoist/dsn'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; -export interface TunnelResult { - status: number; - body: string; - contentType: string; +export interface HandleTunnelRequestOptions { + /** Incoming request containing the Sentry envelope as its body */ + request: Request; + /** Pre-parsed array of allowed DSN strings */ + allowedDsns: Array; } /** * Core Sentry tunnel handler - framework agnostic. * - * Validates the envelope DSN against allowed DSNs and forwards to Sentry. + * Validates the envelope DSN against allowed DSNs and constructs a + * forwarding request to the Sentry ingest endpoint. * - * @param body - Raw request body (Sentry envelope) - * @param allowedDsnComponents - Pre-parsed array of allowed DsnComponents - * @returns Promise resolving to status, body, and contentType + * @returns A `Request` to forward to Sentry on success, or a `Response` with an error. */ -export async function handleTunnelRequest( - body: string | Uint8Array, - allowedDsnComponents: Array, -): Promise { - if (allowedDsnComponents.length === 0) { - return { - status: 500, - body: 'Tunnel not configured', - contentType: 'text/plain', - }; +export async function createTunnelRequest(options: HandleTunnelRequestOptions): Promise { + const { request, allowedDsns } = options; + + if (allowedDsns.length === 0) { + return new Response('Tunnel not configured', { status: 500 }); } + const body = new Uint8Array(await request.arrayBuffer()); + const [envelopeHeader] = parseEnvelope(body); if (!envelopeHeader) { - return { - status: 400, - body: 'Invalid envelope: missing header', - contentType: 'text/plain', - }; + return new Response('Invalid envelope: missing header', { status: 400 }); } const dsn = envelopeHeader.dsn; if (!dsn) { - return { - status: 400, - body: 'Invalid envelope: missing DSN', - contentType: 'text/plain', - }; - } - - const dsnComponents = makeDsn(dsn); - if (!dsnComponents) { - return { - status: 400, - body: 'Invalid DSN format', - contentType: 'text/plain', - }; + return new Response('Invalid envelope: missing DSN', { status: 400 }); } // SECURITY: Validate that the envelope DSN matches one of the allowed DSNs // This prevents SSRF attacks where attackers send crafted envelopes // with malicious DSNs pointing to arbitrary hosts - const isAllowed = allowedDsnComponents.some( - allowed => allowed.host === dsnComponents.host && allowed.projectId === dsnComponents.projectId, - ); + const isAllowed = allowedDsns.some(allowed => allowed === dsn); if (!isAllowed) { - debug.warn( - `Sentry tunnel: rejected request with unauthorized DSN (host: ${dsnComponents.host}, project: ${dsnComponents.projectId})`, - ); - return { - status: 403, - body: 'DSN not allowed', - contentType: 'text/plain', - }; + debug.warn(`Sentry tunnel: rejected request with unauthorized DSN (${dsn})`); + return new Response('DSN not allowed', { status: 403 }); + } + + const dsnComponents = makeDsn(dsn); + if (!dsnComponents) { + debug.warn(`Could not extract DSN Components from: ${dsn}`); + return new Response('Invalid DSN', { status: 403 }); } const sentryIngestUrl = `https://${dsnComponents.host}/api/${dsnComponents.projectId}/envelope/`; - const response = await fetch(sentryIngestUrl, { + return new Request(sentryIngestUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-sentry-envelope', }, body, }); - - return { - status: response.status, - body: await response.text(), - contentType: response.headers.get('Content-Type') || 'text/plain', - }; } From 1407dd648bdcab7e2963e1202af6081b3d4e2809 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 17 Feb 2026 15:53:01 -0500 Subject: [PATCH 05/10] test(tunnel): adding unit tests for createTunnelRequest --- packages/core/test/lib/utils/tunnel.test.ts | 93 +++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 packages/core/test/lib/utils/tunnel.test.ts diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts new file mode 100644 index 000000000000..39e612f42312 --- /dev/null +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; +import { createTunnelRequest } from '../../../src/utils/tunnel'; + +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +function makeEnvelopeRequest(envelopeHeader: Record): Request { + const envelope = createEnvelope(envelopeHeader, []); + const body = serializeEnvelope(envelope); + return new Request('http://localhost/tunnel', { method: 'POST', body }); +} + +describe('createTunnelRequest', () => { + it('returns a forwarding Request for a valid, allowed DSN', async () => { + const result = await createTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBeInstanceOf(Request); + + const req = result as Request; + expect(req.url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/'); + expect(req.method).toBe('POST'); + expect(req.headers.get('Content-Type')).toBe('application/x-sentry-envelope'); + }); + + it('returns 500 when allowedDsns is empty', async () => { + const result = await createTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [], + }); + + expect(result).toBeInstanceOf(Response); + + const res = result as Response; + expect(res.status).toBe(500); + expect(await res.text()).toBe('Tunnel not configured'); + }); + + it('returns 400 when the envelope has no DSN in the header', async () => { + const result = await createTunnelRequest({ + request: makeEnvelopeRequest({}), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBeInstanceOf(Response); + + const res = result as Response; + expect(res.status).toBe(400); + expect(await res.text()).toBe('Invalid envelope: missing DSN'); + }); + + it('returns 403 when the envelope DSN is not in allowedDsns', async () => { + const result = await createTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBeInstanceOf(Response); + + const res = result as Response; + expect(res.status).toBe(403); + expect(await res.text()).toBe('DSN not allowed'); + }); + + it('returns 403 when the DSN string cannot be parsed into components', async () => { + const malformedDsn = 'not-a-valid-dsn'; + + const result = await createTunnelRequest({ + request: makeEnvelopeRequest({ dsn: malformedDsn }), + allowedDsns: [malformedDsn], + }); + + expect(result).toBeInstanceOf(Response); + + const res = result as Response; + expect(res.status).toBe(403); + expect(await res.text()).toBe('Invalid DSN'); + }); + + it('allows the DSN when multiple DSNs are configured', async () => { + const otherDsn = 'https://other@example.com/9999'; + + const result = await createTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [otherDsn, TEST_DSN], + }); + + expect(result).toBeInstanceOf(Request); + expect((result as Request).url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/'); + }); +}); From a68c65324f376a3522be0490a579c92300d55f5a Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Wed, 18 Feb 2026 12:08:22 -0500 Subject: [PATCH 06/10] refactor(tunnel): refactoring core tunnel back to handle the request instead of returning a request --- packages/core/src/index.ts | 2 +- packages/core/src/utils/tunnel.ts | 27 +++--- packages/core/test/lib/utils/tunnel.test.ts | 97 +++++++++++++-------- 3 files changed, 80 insertions(+), 46 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fbe53a2a7875..c719850d7cdb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -70,7 +70,7 @@ export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; -export { createTunnelRequest } from './utils/tunnel'; +export { handleTunnelRequest } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index bbe450675e61..c1e391c76c2a 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -12,12 +12,12 @@ export interface HandleTunnelRequestOptions { /** * Core Sentry tunnel handler - framework agnostic. * - * Validates the envelope DSN against allowed DSNs and constructs a - * forwarding request to the Sentry ingest endpoint. + * Validates the envelope DSN against allowed DSNs, then forwards the + * envelope to the Sentry ingest endpoint. * - * @returns A `Request` to forward to Sentry on success, or a `Response` with an error. + * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ -export async function createTunnelRequest(options: HandleTunnelRequestOptions): Promise { +export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { const { request, allowedDsns } = options; if (allowedDsns.length === 0) { @@ -54,11 +54,16 @@ export async function createTunnelRequest(options: HandleTunnelRequestOptions): const sentryIngestUrl = `https://${dsnComponents.host}/api/${dsnComponents.projectId}/envelope/`; - return new Request(sentryIngestUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }); + try { + return await fetch(sentryIngestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body, + }); + } catch (error) { + debug.error('Sentry tunnel: failed to forward envelope', error); + return new Response('Failed to forward envelope to Sentry', { status: 500 }); + } } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 39e612f42312..ee31e43b7ad3 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { createTunnelRequest } from '../../../src/utils/tunnel'; +import { handleTunnelRequest } from '../../../src/utils/tunnel'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; @@ -10,84 +10,113 @@ function makeEnvelopeRequest(envelopeHeader: Record): Request { return new Request('http://localhost/tunnel', { method: 'POST', body }); } -describe('createTunnelRequest', () => { - it('returns a forwarding Request for a valid, allowed DSN', async () => { - const result = await createTunnelRequest({ +describe('handleTunnelRequest', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forwards the envelope to Sentry and returns the upstream response', async () => { + const upstreamResponse = new Response('ok', { status: 200 }); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN], }); - expect(result).toBeInstanceOf(Request); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/'); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); + expect(init.body).toBeInstanceOf(Uint8Array); - const req = result as Request; - expect(req.url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/'); - expect(req.method).toBe('POST'); - expect(req.headers.get('Content-Type')).toBe('application/x-sentry-envelope'); + expect(result).toBe(upstreamResponse); }); it('returns 500 when allowedDsns is empty', async () => { - const result = await createTunnelRequest({ + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [], }); expect(result).toBeInstanceOf(Response); - - const res = result as Response; - expect(res.status).toBe(500); - expect(await res.text()).toBe('Tunnel not configured'); + expect(result.status).toBe(500); + expect(await result.text()).toBe('Tunnel not configured'); + expect(fetchMock).not.toHaveBeenCalled(); }); it('returns 400 when the envelope has no DSN in the header', async () => { - const result = await createTunnelRequest({ + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({}), allowedDsns: [TEST_DSN], }); expect(result).toBeInstanceOf(Response); - - const res = result as Response; - expect(res.status).toBe(400); - expect(await res.text()).toBe('Invalid envelope: missing DSN'); + expect(result.status).toBe(400); + expect(await result.text()).toBe('Invalid envelope: missing DSN'); + expect(fetchMock).not.toHaveBeenCalled(); }); it('returns 403 when the envelope DSN is not in allowedDsns', async () => { - const result = await createTunnelRequest({ + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }), allowedDsns: [TEST_DSN], }); expect(result).toBeInstanceOf(Response); - - const res = result as Response; - expect(res.status).toBe(403); - expect(await res.text()).toBe('DSN not allowed'); + expect(result.status).toBe(403); + expect(await result.text()).toBe('DSN not allowed'); + expect(fetchMock).not.toHaveBeenCalled(); }); it('returns 403 when the DSN string cannot be parsed into components', async () => { const malformedDsn = 'not-a-valid-dsn'; - const result = await createTunnelRequest({ + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: malformedDsn }), allowedDsns: [malformedDsn], }); expect(result).toBeInstanceOf(Response); - - const res = result as Response; - expect(res.status).toBe(403); - expect(await res.text()).toBe('Invalid DSN'); + expect(result.status).toBe(403); + expect(await result.text()).toBe('Invalid DSN'); + expect(fetchMock).not.toHaveBeenCalled(); }); - it('allows the DSN when multiple DSNs are configured', async () => { + it('forwards the envelope when multiple DSNs are configured', async () => { const otherDsn = 'https://other@example.com/9999'; + const upstreamResponse = new Response('ok', { status: 200 }); + fetchMock.mockResolvedValueOnce(upstreamResponse); - const result = await createTunnelRequest({ + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [otherDsn, TEST_DSN], }); - expect(result).toBeInstanceOf(Request); - expect((result as Request).url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/'); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/'); + expect(result).toBe(upstreamResponse); + }); + + 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], + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(500); + expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); }); From 105b75a4c7a3e6fa1c255d577eb36bae067cbe0b Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Wed, 18 Feb 2026 12:44:16 -0500 Subject: [PATCH 07/10] fix(tunnel): use getEnvelopeEndpointWithUrlEncodedAuth for proper ingest URL The tunnel handler was manually constructing the Sentry ingest URL without the required auth query parameters (sentry_key, sentry_version), causing requests to fail authentication. Use getEnvelopeEndpointWithUrlEncodedAuth which properly constructs the URL with all required parameters. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/utils/tunnel.ts | 3 ++- packages/core/test/lib/utils/tunnel.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index c1e391c76c2a..b73a1fc5f3a0 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,3 +1,4 @@ +import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; @@ -52,7 +53,7 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid DSN', { status: 403 }); } - const sentryIngestUrl = `https://${dsnComponents.host}/api/${dsnComponents.projectId}/envelope/`; + const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); try { return await fetch(sentryIngestUrl, { diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index ee31e43b7ad3..c9bd462d3b82 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; +import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; import { handleTunnelRequest } from '../../../src/utils/tunnel'; @@ -33,7 +35,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/'); + expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); expect(init.method).toBe('POST'); expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); expect(init.body).toBeInstanceOf(Uint8Array); @@ -103,7 +105,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchMock.mock.calls[0]!; - expect(url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/'); + expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); expect(result).toBe(upstreamResponse); }); From ecda7c2089fb07b49965562ed4ece6a452f5fef2 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Wed, 18 Feb 2026 12:46:54 -0500 Subject: [PATCH 08/10] fix(tunnel): handle malformed envelope JSON with 400 response Wrap parseEnvelope in try-catch so malformed request bodies return a 400 Bad Request instead of throwing an unhandled error. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/utils/tunnel.ts | 8 +++++++- packages/core/test/lib/utils/tunnel.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index b73a1fc5f3a0..5645633ff45e 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -27,7 +27,13 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): const body = new Uint8Array(await request.arrayBuffer()); - const [envelopeHeader] = parseEnvelope(body); + let envelopeHeader; + try { + [envelopeHeader] = parseEnvelope(body); + } catch { + return new Response('Invalid envelope', { status: 400 }); + } + if (!envelopeHeader) { return new Response('Invalid envelope: missing header', { status: 400 }); } diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index c9bd462d3b82..2b499ce754ae 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -67,6 +67,18 @@ describe('handleTunnelRequest', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('returns 400 when the envelope body contains malformed JSON', async () => { + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{' }), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(400); + expect(await result.text()).toBe('Invalid envelope'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('returns 403 when the envelope DSN is not in allowedDsns', async () => { const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }), From c56c38ba80a61ce27badf49cf6d5d0aa611f3a93 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Wed, 18 Feb 2026 13:23:20 -0500 Subject: [PATCH 09/10] fix(tanstackstart-react): linting error in client/index.ts --- packages/tanstackstart-react/src/client/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 2adc0e37bc87..7536f144e862 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -1,11 +1,11 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import type { TanStackMiddlewareBase } from "../common/types"; +import type { TanStackMiddlewareBase } from '../common/types'; -export * from "@sentry/react"; +export * from '@sentry/react'; -export { init } from "./sdk"; +export { init } from './sdk'; /** * No-op stub for client-side builds. @@ -22,7 +22,7 @@ export function wrapMiddlewaresWithSentry( * The actual implementation is server-only, but this stub is needed to prevent rendering errors. */ export const sentryGlobalRequestMiddleware: TanStackMiddlewareBase = { - "~types": undefined, + '~types': undefined, options: {}, }; @@ -31,6 +31,6 @@ export const sentryGlobalRequestMiddleware: TanStackMiddlewareBase = { * The actual implementation is server-only, but this stub is needed to prevent rendering errors. */ export const sentryGlobalFunctionMiddleware: TanStackMiddlewareBase = { - "~types": undefined, + '~types': undefined, options: {}, }; From 81e14fbb22acf549c14bf450a6a4b426d57439a3 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Wed, 18 Feb 2026 13:35:52 -0500 Subject: [PATCH 10/10] fix(tanstackstart-react): client/index.ts formatting --- packages/tanstackstart-react/src/client/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 7536f144e862..4c220c04e7cb 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -11,9 +11,7 @@ export { init } from './sdk'; * No-op stub for client-side builds. * The actual implementation is server-only, but this stub is needed to prevent build errors. */ -export function wrapMiddlewaresWithSentry( - middlewares: Record, -): T[] { +export function wrapMiddlewaresWithSentry(middlewares: Record): T[] { return Object.values(middlewares); }