Skip to content

Commit 3674106

Browse files
authored
feat(core): Add framework-agnostic tunnel handler (#18892)
Adds a request -> response handler for accepting and forwarding Sentry envelope requests from a client SDK to Sentry. Only forwards requests to DSNs matching a list of allowed DSNs. This will be used as a base for more framework-specific handlers, middleware, etc to simplify tunneling setup.
1 parent 76dffa2 commit 3674106

File tree

3 files changed

+214
-0
lines changed

3 files changed

+214
-0
lines changed

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export { hasSpansEnabled } from './utils/hasSpansEnabled';
7171
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
7272
export { handleCallbackErrors } from './utils/handleCallbackErrors';
7373
export { parameterize, fmt } from './utils/parameterize';
74+
export type { HandleTunnelRequestOptions } from './utils/tunnel';
75+
export { handleTunnelRequest } from './utils/tunnel';
7476

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

packages/core/src/utils/tunnel.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api';
2+
import { debug } from './debug-logger';
3+
import { makeDsn } from './dsn';
4+
import { parseEnvelope } from './envelope';
5+
6+
export interface HandleTunnelRequestOptions {
7+
/** Incoming request containing the Sentry envelope as its body */
8+
request: Request;
9+
/** Pre-parsed array of allowed DSN strings */
10+
allowedDsns: Array<string>;
11+
}
12+
13+
/**
14+
* Core Sentry tunnel handler - framework agnostic.
15+
*
16+
* Validates the envelope DSN against allowed DSNs, then forwards the
17+
* envelope to the Sentry ingest endpoint.
18+
*
19+
* @returns A `Response` — either the upstream Sentry response on success, or an error response.
20+
*/
21+
export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise<Response> {
22+
const { request, allowedDsns } = options;
23+
24+
if (allowedDsns.length === 0) {
25+
return new Response('Tunnel not configured', { status: 500 });
26+
}
27+
28+
const body = new Uint8Array(await request.arrayBuffer());
29+
30+
let envelopeHeader;
31+
try {
32+
[envelopeHeader] = parseEnvelope(body);
33+
} catch {
34+
return new Response('Invalid envelope', { status: 400 });
35+
}
36+
37+
if (!envelopeHeader) {
38+
return new Response('Invalid envelope: missing header', { status: 400 });
39+
}
40+
41+
const dsn = envelopeHeader.dsn;
42+
if (!dsn) {
43+
return new Response('Invalid envelope: missing DSN', { status: 400 });
44+
}
45+
46+
// SECURITY: Validate that the envelope DSN matches one of the allowed DSNs
47+
// This prevents SSRF attacks where attackers send crafted envelopes
48+
// with malicious DSNs pointing to arbitrary hosts
49+
const isAllowed = allowedDsns.some(allowed => allowed === dsn);
50+
51+
if (!isAllowed) {
52+
debug.warn(`Sentry tunnel: rejected request with unauthorized DSN (${dsn})`);
53+
return new Response('DSN not allowed', { status: 403 });
54+
}
55+
56+
const dsnComponents = makeDsn(dsn);
57+
if (!dsnComponents) {
58+
debug.warn(`Could not extract DSN Components from: ${dsn}`);
59+
return new Response('Invalid DSN', { status: 403 });
60+
}
61+
62+
const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents);
63+
64+
try {
65+
return await fetch(sentryIngestUrl, {
66+
method: 'POST',
67+
headers: {
68+
'Content-Type': 'application/x-sentry-envelope',
69+
},
70+
body,
71+
});
72+
} catch (error) {
73+
debug.error('Sentry tunnel: failed to forward envelope', error);
74+
return new Response('Failed to forward envelope to Sentry', { status: 500 });
75+
}
76+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api';
3+
import { makeDsn } from '../../../src/utils/dsn';
4+
import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope';
5+
import { handleTunnelRequest } from '../../../src/utils/tunnel';
6+
7+
const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337';
8+
9+
function makeEnvelopeRequest(envelopeHeader: Record<string, unknown>): Request {
10+
const envelope = createEnvelope(envelopeHeader, []);
11+
const body = serializeEnvelope(envelope);
12+
return new Request('http://localhost/tunnel', { method: 'POST', body });
13+
}
14+
15+
describe('handleTunnelRequest', () => {
16+
let fetchMock: ReturnType<typeof vi.fn>;
17+
18+
beforeEach(() => {
19+
fetchMock = vi.fn();
20+
vi.stubGlobal('fetch', fetchMock);
21+
});
22+
23+
afterEach(() => {
24+
vi.restoreAllMocks();
25+
});
26+
27+
it('forwards the envelope to Sentry and returns the upstream response', async () => {
28+
const upstreamResponse = new Response('ok', { status: 200 });
29+
fetchMock.mockResolvedValueOnce(upstreamResponse);
30+
31+
const result = await handleTunnelRequest({
32+
request: makeEnvelopeRequest({ dsn: TEST_DSN }),
33+
allowedDsns: [TEST_DSN],
34+
});
35+
36+
expect(fetchMock).toHaveBeenCalledOnce();
37+
const [url, init] = fetchMock.mock.calls[0]!;
38+
expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!));
39+
expect(init.method).toBe('POST');
40+
expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' });
41+
expect(init.body).toBeInstanceOf(Uint8Array);
42+
43+
expect(result).toBe(upstreamResponse);
44+
});
45+
46+
it('returns 500 when allowedDsns is empty', async () => {
47+
const result = await handleTunnelRequest({
48+
request: makeEnvelopeRequest({ dsn: TEST_DSN }),
49+
allowedDsns: [],
50+
});
51+
52+
expect(result).toBeInstanceOf(Response);
53+
expect(result.status).toBe(500);
54+
expect(await result.text()).toBe('Tunnel not configured');
55+
expect(fetchMock).not.toHaveBeenCalled();
56+
});
57+
58+
it('returns 400 when the envelope has no DSN in the header', async () => {
59+
const result = await handleTunnelRequest({
60+
request: makeEnvelopeRequest({}),
61+
allowedDsns: [TEST_DSN],
62+
});
63+
64+
expect(result).toBeInstanceOf(Response);
65+
expect(result.status).toBe(400);
66+
expect(await result.text()).toBe('Invalid envelope: missing DSN');
67+
expect(fetchMock).not.toHaveBeenCalled();
68+
});
69+
70+
it('returns 400 when the envelope body contains malformed JSON', async () => {
71+
const result = await handleTunnelRequest({
72+
request: new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{' }),
73+
allowedDsns: [TEST_DSN],
74+
});
75+
76+
expect(result).toBeInstanceOf(Response);
77+
expect(result.status).toBe(400);
78+
expect(await result.text()).toBe('Invalid envelope');
79+
expect(fetchMock).not.toHaveBeenCalled();
80+
});
81+
82+
it('returns 403 when the envelope DSN is not in allowedDsns', async () => {
83+
const result = await handleTunnelRequest({
84+
request: makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }),
85+
allowedDsns: [TEST_DSN],
86+
});
87+
88+
expect(result).toBeInstanceOf(Response);
89+
expect(result.status).toBe(403);
90+
expect(await result.text()).toBe('DSN not allowed');
91+
expect(fetchMock).not.toHaveBeenCalled();
92+
});
93+
94+
it('returns 403 when the DSN string cannot be parsed into components', async () => {
95+
const malformedDsn = 'not-a-valid-dsn';
96+
97+
const result = await handleTunnelRequest({
98+
request: makeEnvelopeRequest({ dsn: malformedDsn }),
99+
allowedDsns: [malformedDsn],
100+
});
101+
102+
expect(result).toBeInstanceOf(Response);
103+
expect(result.status).toBe(403);
104+
expect(await result.text()).toBe('Invalid DSN');
105+
expect(fetchMock).not.toHaveBeenCalled();
106+
});
107+
108+
it('forwards the envelope when multiple DSNs are configured', async () => {
109+
const otherDsn = 'https://other@example.com/9999';
110+
const upstreamResponse = new Response('ok', { status: 200 });
111+
fetchMock.mockResolvedValueOnce(upstreamResponse);
112+
113+
const result = await handleTunnelRequest({
114+
request: makeEnvelopeRequest({ dsn: TEST_DSN }),
115+
allowedDsns: [otherDsn, TEST_DSN],
116+
});
117+
118+
expect(fetchMock).toHaveBeenCalledOnce();
119+
const [url] = fetchMock.mock.calls[0]!;
120+
expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!));
121+
expect(result).toBe(upstreamResponse);
122+
});
123+
124+
it('returns 500 when fetch throws a network error', async () => {
125+
fetchMock.mockRejectedValueOnce(new Error('Network failure'));
126+
127+
const result = await handleTunnelRequest({
128+
request: makeEnvelopeRequest({ dsn: TEST_DSN }),
129+
allowedDsns: [TEST_DSN],
130+
});
131+
132+
expect(result).toBeInstanceOf(Response);
133+
expect(result.status).toBe(500);
134+
expect(await result.text()).toBe('Failed to forward envelope to Sentry');
135+
});
136+
});

0 commit comments

Comments
 (0)