From ab449ecbf76bfa0e8519c56eecbc8151bd135d80 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:01:59 +0200 Subject: [PATCH] feat(core): Limit the body size of tunnel requests --- packages/core/src/utils/tunnel.ts | 12 ++++ packages/core/test/lib/utils/tunnel.test.ts | 61 +++++++++++++++++++-- packages/nextjs/test/tunnel/index.test.ts | 6 +- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 5645633ff45e..4831d6f48ea8 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -3,6 +3,9 @@ import { debug } from './debug-logger'; import { makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; +// Bounds the memory of each request. SDK envelopes stay far below it. +export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; + export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; @@ -25,6 +28,15 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Tunnel not configured', { status: 500 }); } + // The HTTP server ends the body at `Content-Length`, so the header bounds what `arrayBuffer()` reads. + const contentLength = Number(request.headers.get('content-length')); + if (!contentLength) { + return new Response('Length required', { status: 411 }); + } + if (contentLength > MAX_TUNNEL_BODY_SIZE) { + return new Response('Envelope too large', { status: 413 }); + } + const body = new Uint8Array(await request.arrayBuffer()); let envelopeHeader; diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 2b499ce754ae..7e56a5badfca 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest } from '../../../src/utils/tunnel'; +import { handleTunnelRequest, MAX_TUNNEL_BODY_SIZE } from '../../../src/utils/tunnel'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record): Request { +function makeEnvelopeRequest(envelopeHeader: Record, contentLength?: number): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); - return new Request('http://localhost/tunnel', { method: 'POST', body }); + const byteLength = typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength; + return new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': String(contentLength ?? byteLength) }, + }); } describe('handleTunnelRequest', () => { @@ -69,7 +74,11 @@ describe('handleTunnelRequest', () => { it('returns 400 when the envelope body contains malformed JSON', async () => { const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{' }), + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: 'not valid envelope data{{{', + headers: { 'content-length': '26' }, + }), allowedDsns: [TEST_DSN], }); @@ -133,4 +142,48 @@ describe('handleTunnelRequest', () => { expect(result.status).toBe(500); expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); + + describe('body size limit', () => { + it('forwards the envelope when Content-Length equals the limit', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(200); + }); + + it('returns 413 when Content-Length is above the limit', async () => { + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE + 1), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(413); + expect(await result.text()).toBe('Envelope too large'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 411 when the request has no Content-Length', async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ dsn: TEST_DSN })}\n`)); + controller.close(); + }, + }); + // Node.js requires `duplex` for a stream body, but the DOM types do not have it yet. + const init: RequestInit & { duplex: 'half' } = { method: 'POST', body, duplex: 'half' }; + + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', init), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(411); + expect(await result.text()).toBe('Length required'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 1e86f6925f1f..0df557baa736 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -22,7 +22,11 @@ function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Arra const body = typeof envelope === 'string' ? new TextEncoder().encode(envelope) : envelope; const request = new Request('https://shop.example.com/monitoring', { method: 'POST', - headers: { cookie: 'session=8f2c1d', 'content-type': 'text/plain;charset=UTF-8' }, + headers: { + cookie: 'session=8f2c1d', + 'content-type': 'text/plain;charset=UTF-8', + 'content-length': String(body.byteLength), + }, body, }); return { request, body };