From 171f927e7b8331d03559cd4ee1b9450709e39514 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Mon, 19 Jan 2026 16:39:00 -0500 Subject: [PATCH 1/2] 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 2/2] 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 {