Skip to content

Commit f71a0ca

Browse files
authored
feat(core): Limit the body size of tunnel requests (#25515)
The tunnel route handlers (`@sentry/nextjs/tunnel` and TanStack Start's `createSentryTunnelRoute`) now check the request size before they read the body. A request larger than 20 MiB gets a 413, and a request without `Content-Length` gets a 411. So one request can no longer make the server hold more than 20 MiB in memory. SDK envelopes are much smaller than this limit, so normal traffic does not change. Checking the header is enough, because the HTTP server stops reading the body at `Content-Length`. Stacked on #25456.
1 parent 554668c commit f71a0ca

3 files changed

Lines changed: 74 additions & 5 deletions

File tree

‎packages/core/src/utils/tunnel.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { debug } from './debug-logger';
33
import { makeDsn } from './dsn';
44
import { parseEnvelope } from './envelope';
55

6+
// Bounds the memory of each request. SDK envelopes stay far below it.
7+
export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024;
8+
69
export interface HandleTunnelRequestOptions {
710
/** Incoming request containing the Sentry envelope as its body */
811
request: Request;
@@ -25,6 +28,15 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions):
2528
return new Response('Tunnel not configured', { status: 500 });
2629
}
2730

31+
// The HTTP server ends the body at `Content-Length`, so the header bounds what `arrayBuffer()` reads.
32+
const contentLength = Number(request.headers.get('content-length'));
33+
if (!contentLength) {
34+
return new Response('Length required', { status: 411 });
35+
}
36+
if (contentLength > MAX_TUNNEL_BODY_SIZE) {
37+
return new Response('Envelope too large', { status: 413 });
38+
}
39+
2840
const body = new Uint8Array(await request.arrayBuffer());
2941

3042
let envelopeHeader;

‎packages/core/test/lib/utils/tunnel.test.ts‎

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api';
33
import { makeDsn } from '../../../src/utils/dsn';
44
import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope';
5-
import { handleTunnelRequest } from '../../../src/utils/tunnel';
5+
import { handleTunnelRequest, MAX_TUNNEL_BODY_SIZE } from '../../../src/utils/tunnel';
66

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

9-
function makeEnvelopeRequest(envelopeHeader: Record<string, unknown>): Request {
9+
function makeEnvelopeRequest(envelopeHeader: Record<string, unknown>, contentLength?: number): Request {
1010
const envelope = createEnvelope(envelopeHeader, []);
1111
const body = serializeEnvelope(envelope);
12-
return new Request('http://localhost/tunnel', { method: 'POST', body });
12+
const byteLength = typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength;
13+
return new Request('http://localhost/tunnel', {
14+
method: 'POST',
15+
body,
16+
headers: { 'content-length': String(contentLength ?? byteLength) },
17+
});
1318
}
1419

1520
describe('handleTunnelRequest', () => {
@@ -69,7 +74,11 @@ describe('handleTunnelRequest', () => {
6974

7075
it('returns 400 when the envelope body contains malformed JSON', async () => {
7176
const result = await handleTunnelRequest({
72-
request: new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{' }),
77+
request: new Request('http://localhost/tunnel', {
78+
method: 'POST',
79+
body: 'not valid envelope data{{{',
80+
headers: { 'content-length': '26' },
81+
}),
7382
allowedDsns: [TEST_DSN],
7483
});
7584

@@ -133,4 +142,48 @@ describe('handleTunnelRequest', () => {
133142
expect(result.status).toBe(500);
134143
expect(await result.text()).toBe('Failed to forward envelope to Sentry');
135144
});
145+
146+
describe('body size limit', () => {
147+
it('forwards the envelope when Content-Length equals the limit', async () => {
148+
fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 }));
149+
150+
const result = await handleTunnelRequest({
151+
request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE),
152+
allowedDsns: [TEST_DSN],
153+
});
154+
155+
expect(result.status).toBe(200);
156+
});
157+
158+
it('returns 413 when Content-Length is above the limit', async () => {
159+
const result = await handleTunnelRequest({
160+
request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE + 1),
161+
allowedDsns: [TEST_DSN],
162+
});
163+
164+
expect(result.status).toBe(413);
165+
expect(await result.text()).toBe('Envelope too large');
166+
expect(fetchMock).not.toHaveBeenCalled();
167+
});
168+
169+
it('returns 411 when the request has no Content-Length', async () => {
170+
const body = new ReadableStream<Uint8Array>({
171+
start(controller) {
172+
controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ dsn: TEST_DSN })}\n`));
173+
controller.close();
174+
},
175+
});
176+
// Node.js requires `duplex` for a stream body, but the DOM types do not have it yet.
177+
const init: RequestInit & { duplex: 'half' } = { method: 'POST', body, duplex: 'half' };
178+
179+
const result = await handleTunnelRequest({
180+
request: new Request('http://localhost/tunnel', init),
181+
allowedDsns: [TEST_DSN],
182+
});
183+
184+
expect(result.status).toBe(411);
185+
expect(await result.text()).toBe('Length required');
186+
expect(fetchMock).not.toHaveBeenCalled();
187+
});
188+
});
136189
});

‎packages/nextjs/test/tunnel/index.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Arra
2222
const body = typeof envelope === 'string' ? new TextEncoder().encode(envelope) : envelope;
2323
const request = new Request('https://shop.example.com/monitoring', {
2424
method: 'POST',
25-
headers: { cookie: 'session=8f2c1d', 'content-type': 'text/plain;charset=UTF-8' },
25+
headers: {
26+
cookie: 'session=8f2c1d',
27+
'content-type': 'text/plain;charset=UTF-8',
28+
'content-length': String(body.byteLength),
29+
},
2630
body,
2731
});
2832
return { request, body };

0 commit comments

Comments
 (0)