Skip to content

Commit 8a34c73

Browse files
committed
feat(core): Keep the tunnel body size checks with the streamed forward
#25515's 411 and 413 checks run before the header line is read, so an oversized or unlengthed request is rejected without reading its body.
1 parent 0ffb89b commit 8a34c73

2 files changed

Lines changed: 59 additions & 5 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ function getServedTunnel(): ServedTunnel | undefined {
3333
return client && servedTunnels.get(client);
3434
}
3535

36+
/**
37+
* Bounds what one tunnel request may send. SDK envelopes stay far below it.
38+
*/
39+
export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024;
40+
3641
export interface HandleTunnelRequestOptions {
3742
/** Incoming request containing the Sentry envelope as its body */
3843
request: Request;
@@ -215,6 +220,15 @@ async function forwardEnvelope(request: Request, allowedDsns: Array<string>): Pr
215220
return new Response('Tunnel not configured', { status: 500 });
216221
}
217222

223+
// The HTTP server ends the body at `Content-Length`, so the header bounds what the forward streams.
224+
const contentLength = Number(request.headers.get('content-length'));
225+
if (!contentLength) {
226+
return new Response('Length required', { status: 411 });
227+
}
228+
if (contentLength > MAX_TUNNEL_BODY_SIZE) {
229+
return new Response('Envelope too large', { status: 413 });
230+
}
231+
218232
const reader = request.body?.getReader();
219233
// A client aborting mid-upload makes the read throw.
220234
const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined));

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

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
resolveAllowedDsns,
1717
resolveServerTunnelOption,
1818
resolveTunnelPaths,
19+
MAX_TUNNEL_BODY_SIZE,
1920
} from '../../../src/utils/tunnel';
2021
import {
2122
_INTERNAL_resetManagedTunnelDefaults,
@@ -39,11 +40,15 @@ function getTunnelIgnoreSpanFilter(
3940
return getTunnelIgnoreSpanFilters(tunnelOptions, paths)[0];
4041
}
4142

42-
function makeEnvelopeRequest(envelopeHeader: Record<string, unknown>, url = 'http://localhost/tunnel'): Request {
43-
const envelope = createEnvelope(envelopeHeader, []);
44-
const body = serializeEnvelope(envelope);
43+
function makeEnvelopeRequest(
44+
envelopeHeader: Record<string, unknown>,
45+
url = 'http://localhost/tunnel',
46+
contentLength?: number,
47+
): Request {
48+
const body = serializeEnvelope(createEnvelope(envelopeHeader, []));
49+
const length = contentLength ?? new TextEncoder().encode(body as string).length;
4550

46-
return new Request(url, { method: 'POST', body });
51+
return new Request(url, { method: 'POST', body, headers: { 'content-length': String(length) } });
4752
}
4853

4954
/**
@@ -56,6 +61,7 @@ function makeEndlessRequest(head: string, onCancel?: () => void): Request {
5661
return new Request('http://localhost/tunnel', {
5762
method: 'POST',
5863
duplex: 'half',
64+
headers: { 'content-length': String(MAX_TUNNEL_BODY_SIZE) },
5965
body: new ReadableStream<Uint8Array>({
6066
pull(controller) {
6167
controller.enqueue(sentHead ? filler : new TextEncoder().encode(head));
@@ -74,6 +80,40 @@ describe('handleTunnelRequest', () => {
7480
vi.stubGlobal('fetch', fetchMock);
7581
});
7682

83+
it('forwards the envelope when Content-Length equals the limit', async () => {
84+
fetchMock.mockResolvedValueOnce(new Response('ok'));
85+
86+
const result = await handleTunnelRequest({
87+
request: makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE),
88+
allowedDsns: [TEST_DSN],
89+
});
90+
91+
expect(result.status).toBe(200);
92+
});
93+
94+
it('returns 413 when Content-Length is above the limit, without reading the body', async () => {
95+
const request = makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE + 1);
96+
97+
const result = await handleTunnelRequest({ request, allowedDsns: [TEST_DSN] });
98+
99+
expect(result.status).toBe(413);
100+
expect(request.bodyUsed).toBe(false);
101+
expect(fetchMock).not.toHaveBeenCalled();
102+
});
103+
104+
it('returns 411 when the request has no Content-Length', async () => {
105+
const result = await handleTunnelRequest({
106+
request: new Request('http://localhost/tunnel', {
107+
method: 'POST',
108+
body: `${JSON.stringify({ dsn: TEST_DSN })}\n`,
109+
}),
110+
allowedDsns: [TEST_DSN],
111+
});
112+
113+
expect(result.status).toBe(411);
114+
expect(fetchMock).not.toHaveBeenCalled();
115+
});
116+
77117
afterEach(() => {
78118
vi.restoreAllMocks();
79119
getCurrentScope().setClient(undefined);
@@ -132,7 +172,7 @@ describe('handleTunnelRequest', () => {
132172
request: new Request('http://localhost/tunnel', {
133173
method: 'POST',
134174
body: `${JSON.stringify({ dsn: TEST_DSN })}\n`,
135-
headers: incomingHeaders,
175+
headers: { ...incomingHeaders, 'content-length': '1' },
136176
}),
137177
allowedDsns: [TEST_DSN],
138178
});

0 commit comments

Comments
 (0)