Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions packages/core/src/utils/tunnel.ts
Original file line number Diff line number Diff line change
@@ -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<DsnComponents>,
): Promise<TunnelResult> {
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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure it's enough to just check the host matches. We should have a list of allowed DSNs and only forward when they match.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the allowedDsnComponents are passed from the outside and they're exactly that - a list of allowed DSNs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe instead of turning them in components we should pass them as string arrays and do a plain string comparison?

);

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',
};
}
10 changes: 10 additions & 0 deletions packages/tanstackstart-react/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,13 @@ export { init } from './sdk';
export function wrapMiddlewaresWithSentry<T extends TanStackMiddlewareBase>(middlewares: Record<string, T>): 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<string>,
): (args: { request: Request }) => Promise<Response> {
return async () => new Response('Tunnel handler is not available on the client', { status: 500 });
}
1 change: 1 addition & 0 deletions packages/tanstackstart-react/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
38 changes: 38 additions & 0 deletions packages/tanstackstart-react/src/server/createTunnelHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
): (args: { request: Request }) => Promise<Response> {
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<Response> => {
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 });
}
};
}
1 change: 1 addition & 0 deletions packages/tanstackstart-react/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading