Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/core/src/utils/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
61 changes: 57 additions & 4 deletions packages/core/test/lib/utils/tunnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Request {
function makeEnvelopeRequest(envelopeHeader: Record<string, unknown>, 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', () => {
Expand Down Expand Up @@ -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],
});

Expand Down Expand Up @@ -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<Uint8Array>({
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();
});
});
});
6 changes: 5 additions & 1 deletion packages/nextjs/test/tunnel/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading