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..67fbe5c3db19 --- /dev/null +++ b/packages/core/src/utils/tunnel.ts @@ -0,0 +1,93 @@ +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; +} + +/** + * 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 | Uint8Array, + allowedDsnComponents: Array, +): Promise { + if (allowedDsnComponents.length === 0) { + return { + status: 500, + body: 'Tunnel not configured', + contentType: 'text/plain', + }; + } + + const [envelopeHeader] = parseEnvelope(body); + if (!envelopeHeader) { + return { + status: 400, + body: 'Invalid envelope: missing header', + 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