Skip to content

Commit ab449ec

Browse files
authored
feat(core): Limit the body size of tunnel requests
1 parent 13f4909 commit ab449ec

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)