Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 { HandleTunnelRequestOptions } from './utils/tunnel';
export { handleTunnelRequest } from './utils/tunnel';

export { addAutoIpAddressToSession } from './utils/ipAddress';
// eslint-disable-next-line deprecation/deprecation
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/utils/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { debug } from './debug-logger';
import { makeDsn } from './dsn';
import { parseEnvelope } from './envelope';

export interface HandleTunnelRequestOptions {
/** Incoming request containing the Sentry envelope as its body */
request: Request;
/** Pre-parsed array of allowed DSN strings */
allowedDsns: Array<string>;
}

/**
* Core Sentry tunnel handler - framework agnostic.
*
* Validates the envelope DSN against allowed DSNs, then forwards the
* envelope to the Sentry ingest endpoint.
*
* @returns A `Response` — either the upstream Sentry response on success, or an error response.
*/
export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise<Response> {
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);
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
Comment thread
nikolovlazar marked this conversation as resolved.
Outdated
if (!envelopeHeader) {
return new Response('Invalid envelope: missing header', { status: 400 });
}
Comment thread
cursor[bot] marked this conversation as resolved.

const dsn = envelopeHeader.dsn;
if (!dsn) {
Comment thread
nikolovlazar marked this conversation as resolved.
return new Response('Invalid envelope: missing DSN', { status: 400 });
}
Comment thread
nikolovlazar marked this conversation as resolved.

// 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 = allowedDsns.some(allowed => allowed === dsn);
Comment thread
nikolovlazar marked this conversation as resolved.

if (!isAllowed) {
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 });
}
Comment thread
nikolovlazar marked this conversation as resolved.

const sentryIngestUrl = `https://${dsnComponents.host}/api/${dsnComponents.projectId}/envelope/`;
Comment thread
nikolovlazar marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

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 });
}
}
Comment thread
nikolovlazar marked this conversation as resolved.
122 changes: 122 additions & 0 deletions packages/core/test/lib/utils/tunnel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope';
import { handleTunnelRequest } from '../../../src/utils/tunnel';

const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337';

function makeEnvelopeRequest(envelopeHeader: Record<string, unknown>): Request {
const envelope = createEnvelope(envelopeHeader, []);
const body = serializeEnvelope(envelope);
return new Request('http://localhost/tunnel', { method: 'POST', body });
}

describe('handleTunnelRequest', () => {
let fetchMock: ReturnType<typeof vi.fn>;

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(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/');
Comment thread
nikolovlazar marked this conversation as resolved.
Outdated
expect(init.method).toBe('POST');
expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' });
expect(init.body).toBeInstanceOf(Uint8Array);

expect(result).toBe(upstreamResponse);
});

it('returns 500 when allowedDsns is empty', async () => {
const result = await handleTunnelRequest({
request: makeEnvelopeRequest({ dsn: TEST_DSN }),
allowedDsns: [],
});

expect(result).toBeInstanceOf(Response);
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 handleTunnelRequest({
request: makeEnvelopeRequest({}),
allowedDsns: [TEST_DSN],
});

expect(result).toBeInstanceOf(Response);
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 handleTunnelRequest({
request: makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }),
allowedDsns: [TEST_DSN],
});

expect(result).toBeInstanceOf(Response);
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 handleTunnelRequest({
request: makeEnvelopeRequest({ dsn: malformedDsn }),
allowedDsns: [malformedDsn],
});

expect(result).toBeInstanceOf(Response);
expect(result.status).toBe(403);
expect(await result.text()).toBe('Invalid DSN');
expect(fetchMock).not.toHaveBeenCalled();
});

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 handleTunnelRequest({
request: makeEnvelopeRequest({ dsn: TEST_DSN }),
allowedDsns: [otherDsn, TEST_DSN],
});

expect(fetchMock).toHaveBeenCalledOnce();
const [url] = fetchMock.mock.calls[0]!;
expect(url).toBe('https://dsn.ingest.sentry.io/api/1337/envelope/');
expect(result).toBe(upstreamResponse);
});

Comment thread
nikolovlazar marked this conversation as resolved.
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');
});
});
Comment thread
nikolovlazar marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions packages/tanstackstart-react/src/client/index.ts
Comment thread
Lms24 marked this conversation as resolved.
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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: is this really the case? Do we have to create stubs from all of our server exports in TSS? (cc @nicohrubec)

_allowedDsns: Array<string>,
): (args: { request: Request }) => Promise<Response> {
return async () => new Response('Tunnel handler is not available on the client', { status: 500 });
}
Comment thread
nikolovlazar marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated