Skip to content

Commit 171f927

Browse files
committed
feat(tunnel): framework-agnostic tunnel handler + tanstack start adapter
1 parent 8661a66 commit 171f927

6 files changed

Lines changed: 156 additions & 0 deletions

File tree

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export { hasSpansEnabled } from './utils/hasSpansEnabled';
6969
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
7070
export { handleCallbackErrors } from './utils/handleCallbackErrors';
7171
export { parameterize, fmt } from './utils/parameterize';
72+
export type { TunnelResult } from './utils/tunnel';
73+
export { handleTunnelRequest } from './utils/tunnel';
7274

7375
export { addAutoIpAddressToSession } from './utils/ipAddress';
7476
// eslint-disable-next-line deprecation/deprecation

packages/core/src/utils/tunnel.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { DsnComponents } from '../types-hoist/dsn';
2+
import { debug } from './debug-logger';
3+
import { makeDsn } from './dsn';
4+
5+
export interface TunnelResult {
6+
status: number;
7+
body: string;
8+
contentType: string;
9+
}
10+
11+
/**
12+
* Core Sentry tunnel handler - framework agnostic.
13+
*
14+
* Validates the envelope DSN against allowed DSNs and forwards to Sentry.
15+
*
16+
* @param body - Raw request body (Sentry envelope)
17+
* @param allowedDsnComponents - Pre-parsed array of allowed DsnComponents
18+
* @returns Promise resolving to status, body, and contentType
19+
*/
20+
export async function handleTunnelRequest(
21+
body: string,
22+
allowedDsnComponents: Array<DsnComponents>,
23+
): Promise<TunnelResult> {
24+
if (allowedDsnComponents.length === 0) {
25+
return {
26+
status: 500,
27+
body: 'Tunnel not configured',
28+
contentType: 'text/plain',
29+
};
30+
}
31+
32+
// Sentry envelope format: first line is JSON header with DSN
33+
const [headerLine] = body.split('\n');
34+
if (!headerLine) {
35+
return {
36+
status: 400,
37+
body: 'Invalid envelope: missing header',
38+
contentType: 'text/plain',
39+
};
40+
}
41+
42+
let envelopeHeader: { dsn?: string };
43+
try {
44+
envelopeHeader = JSON.parse(headerLine);
45+
} catch {
46+
return {
47+
status: 400,
48+
body: 'Invalid envelope: malformed header JSON',
49+
contentType: 'text/plain',
50+
};
51+
}
52+
53+
const dsn = envelopeHeader.dsn;
54+
if (!dsn) {
55+
return {
56+
status: 400,
57+
body: 'Invalid envelope: missing DSN',
58+
contentType: 'text/plain',
59+
};
60+
}
61+
62+
const dsnComponents = makeDsn(dsn);
63+
if (!dsnComponents) {
64+
return {
65+
status: 400,
66+
body: 'Invalid DSN format',
67+
contentType: 'text/plain',
68+
};
69+
}
70+
71+
// SECURITY: Validate that the envelope DSN matches one of the allowed DSNs
72+
// This prevents SSRF attacks where attackers send crafted envelopes
73+
// with malicious DSNs pointing to arbitrary hosts
74+
const isAllowed = allowedDsnComponents.some(
75+
allowed => allowed.host === dsnComponents.host && allowed.projectId === dsnComponents.projectId,
76+
);
77+
78+
if (!isAllowed) {
79+
debug.warn(
80+
`Sentry tunnel: rejected request with unauthorized DSN (host: ${dsnComponents.host}, project: ${dsnComponents.projectId})`,
81+
);
82+
return {
83+
status: 403,
84+
body: 'DSN not allowed',
85+
contentType: 'text/plain',
86+
};
87+
}
88+
89+
const sentryIngestUrl = `https://${dsnComponents.host}/api/${dsnComponents.projectId}/envelope/`;
90+
91+
const response = await fetch(sentryIngestUrl, {
92+
method: 'POST',
93+
headers: {
94+
'Content-Type': 'application/x-sentry-envelope',
95+
},
96+
body,
97+
});
98+
99+
return {
100+
status: response.status,
101+
body: await response.text(),
102+
contentType: response.headers.get('Content-Type') || 'text/plain',
103+
};
104+
}

packages/tanstackstart-react/src/client/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,13 @@ export { init } from './sdk';
1414
export function wrapMiddlewaresWithSentry<T extends TanStackMiddlewareBase>(middlewares: Record<string, T>): T[] {
1515
return Object.values(middlewares);
1616
}
17+
18+
/**
19+
* No-op stub for client-side builds.
20+
* The actual implementation is server-only, but this stub is needed to prevent build errors.
21+
*/
22+
export function createTunnelHandler(
23+
_allowedDsns: Array<string>,
24+
): (args: { request: Request }) => Promise<Response> {
25+
return async () => new Response('Tunnel handler is not available on the client', { status: 500 });
26+
}

packages/tanstackstart-react/src/index.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ export declare const statsigIntegration: typeof clientSdk.statsigIntegration;
3737
export declare const unleashIntegration: typeof clientSdk.unleashIntegration;
3838

3939
export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewaresWithSentry;
40+
export declare const createTunnelHandler: typeof serverSdk.createTunnelHandler;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { type DsnComponents, handleTunnelRequest, makeDsn } from '@sentry/core';
2+
3+
/**
4+
* Creates a Sentry tunnel handler for TanStack Start.
5+
*
6+
* @param allowedDsns - Array of DSN strings that this tunnel will accept.
7+
* @returns TanStack Start compatible request handler
8+
*
9+
* @example
10+
* const handler = createSentryTunnelHandler([process.env.SENTRY_DSN])
11+
* export const Route = createFileRoute('/tunnel')({
12+
* server: { handlers: { POST: handler } }
13+
* })
14+
*/
15+
export function createTunnelHandler(
16+
allowedDsns: Array<string>,
17+
): (args: { request: Request }) => Promise<Response> {
18+
const allowedDsnComponents = allowedDsns.map(makeDsn).filter((c): c is DsnComponents => c !== undefined);
19+
20+
if (allowedDsnComponents.length === 0) {
21+
// eslint-disable-next-line no-console
22+
console.warn('Sentry tunnel: No valid DSNs provided. All requests will be rejected.');
23+
}
24+
25+
return async ({ request }: { request: Request }): Promise<Response> => {
26+
try {
27+
const body = await request.text();
28+
const result = await handleTunnelRequest(body, allowedDsnComponents);
29+
30+
return new Response(result.body, {
31+
status: result.status,
32+
headers: { 'Content-Type': result.contentType },
33+
});
34+
} catch (error) {
35+
return new Response('Internal server error', { status: 500 });
36+
}
37+
};
38+
}

packages/tanstackstart-react/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from '@sentry/node';
66
export { init } from './sdk';
77
export { wrapFetchWithSentry } from './wrapFetchWithSentry';
88
export { wrapMiddlewaresWithSentry } from './middleware';
9+
export { createTunnelHandler } from './createTunnelHandler';
910

1011
/**
1112
* A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors

0 commit comments

Comments
 (0)