From 2bbb7d51020544ab4329c4f0e71ea5a4c8f86c18 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 27 Apr 2026 15:54:54 +0200 Subject: [PATCH 1/4] feat(aws-serverless): Validate extension tunnel DSN against `SENTRY_DSN` If this is set (which should generally be the case when using the layer), we want to only allow this DSN to be forwarded. --- .../lambda-extension/aws-lambda-extension.ts | 65 +++++++++++++++++-- .../test/aws-lambda-extension.test.ts | 34 ++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 packages/aws-serverless/test/aws-lambda-extension.test.ts diff --git a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts index ff2228fffabe..f046b6d6fd4d 100644 --- a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts +++ b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts @@ -1,6 +1,13 @@ import * as http from 'node:http'; import { buffer } from 'node:stream/consumers'; -import { debug, dsnFromString, getEnvelopeEndpointWithUrlEncodedAuth } from '@sentry/core'; +import { + consoleSandbox, + debug, + type DsnComponents, + dsnToString, + getEnvelopeEndpointWithUrlEncodedAuth, + makeDsn, +} from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; /** @@ -94,6 +101,19 @@ export class AwsLambdaExtension { * Starts the Sentry tunnel. */ public startSentryTunnel(): void { + const allowedDsnComponents = getSentryDSNFromEnv(); + + if (!allowedDsnComponents) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + 'Sentry Lambda extension: SENTRY_DSN is not set or is invalid. The /envelope tunnel will forward ' + + 'any DSN in the envelope header without allowlist validation. Set SENTRY_DSN to the same DSN as ' + + 'your SDK to restrict outbound requests.', + ); + }); + } + const server = http.createServer(async (req, res) => { if (req.method === 'POST' && req.url?.startsWith('/envelope')) { try { @@ -104,12 +124,30 @@ export class AwsLambdaExtension { const envelope = new TextDecoder().decode(envelopeBytes); const piece = envelope.split('\n')[0]; const header = JSON.parse(piece || '{}') as { dsn?: string }; - if (!header.dsn) { - throw new Error('DSN is not set'); + const envelopeDsn = header.dsn; + if (!envelopeDsn) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid envelope: missing DSN' })); + return; + } + + // When SENTRY_DSN is set, same allowlist check as handleTunnelRequest in @sentry/core (SSRF protection). + // If not set, we allow any DSN (but warn about this once, above) + if (allowedDsnComponents) { + if (dsnToString(allowedDsnComponents) !== envelopeDsn) { + DEBUG_BUILD && + debug.warn(`Sentry Lambda extension tunnel: rejected request with unauthorized DSN (${envelopeDsn})`); + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'DSN not allowed' })); + return; + } } - const dsn = dsnFromString(header.dsn); + + const dsn = allowedDsnComponents || makeDsn(envelopeDsn); if (!dsn) { - throw new Error('Invalid DSN'); + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid DSN' })); + return; } const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn); @@ -143,3 +181,20 @@ export class AwsLambdaExtension { }); } } + +/** + * DSN components allowed for the Lambda extension `/envelope` tunnel, derived from `SENTRY_DSN`. + * + * Exported only for testing purposes. + */ +export function getSentryDSNFromEnv(): DsnComponents | undefined { + const raw = process.env.SENTRY_DSN?.trim(); + if (!raw) { + return undefined; + } + const components = makeDsn(raw); + if (!components) { + return undefined; + } + return components; +} diff --git a/packages/aws-serverless/test/aws-lambda-extension.test.ts b/packages/aws-serverless/test/aws-lambda-extension.test.ts new file mode 100644 index 000000000000..40be52e8457e --- /dev/null +++ b/packages/aws-serverless/test/aws-lambda-extension.test.ts @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { getSentryDSNFromEnv } from '../src/lambda-extension/aws-lambda-extension'; + +describe('getSentryDSNFromEnv', () => { + afterEach(() => { + delete process.env.SENTRY_DSN; + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + test('returns undefined when SENTRY_DSN is unset', () => { + expect(getSentryDSNFromEnv()).toEqual(undefined); + }); + + test('returns canonical dsn string when SENTRY_DSN is valid', () => { + process.env.SENTRY_DSN = 'https://public@o1.ingest.sentry.io/1'; + + expect(getSentryDSNFromEnv()).toEqual({ + protocol: 'https', + publicKey: 'public', + host: 'o1.ingest.sentry.io', + projectId: '1', + }); + }); + + test('returns undefined when SENTRY_DSN is invalid', () => { + process.env.SENTRY_DSN = 'not-a-dsn'; + + expect(getSentryDSNFromEnv()).toEqual(undefined); + }); +}); From f5ce600b72ade41fd454779a47bd9b4f47e842dd Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 27 Apr 2026 17:07:16 +0200 Subject: [PATCH 2/4] fix test --- packages/aws-serverless/test/aws-lambda-extension.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/aws-serverless/test/aws-lambda-extension.test.ts b/packages/aws-serverless/test/aws-lambda-extension.test.ts index 40be52e8457e..4c3143eea442 100644 --- a/packages/aws-serverless/test/aws-lambda-extension.test.ts +++ b/packages/aws-serverless/test/aws-lambda-extension.test.ts @@ -23,6 +23,9 @@ describe('getSentryDSNFromEnv', () => { publicKey: 'public', host: 'o1.ingest.sentry.io', projectId: '1', + pass: '', + path: '', + port: '', }); }); From 33bba825399b3a50e86f7df3d82a381a91aba192 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 28 Apr 2026 13:27:39 +0200 Subject: [PATCH 3/4] add tests --- .../lambda-functions-layer/Tunnel/index.js | 38 +++++++++ .../TunnelNoDsn/index.js | 38 +++++++++ .../aws-serverless-layer/src/stack.ts | 5 +- .../aws-serverless-layer/tests/layer.test.ts | 79 ++++++++++++++++++- 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js new file mode 100644 index 000000000000..5a25387cfe10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js @@ -0,0 +1,38 @@ +function makeHex(length) { + return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join(''); +} + +exports.handler = async event => { + const dsn = event?.dsn ?? process.env.SENTRY_DSN ?? process.env.TUNNEL_TEST_DSN; + + const envelopeHeader = event?.omitDsn + ? {} + : { + dsn, + }; + const envelopeItemHeader = { type: 'event' }; + const envelopeItemPayload = { + event_id: makeHex(32), + message: event?.marker ?? 'lambda-extension-tunnel-test', + level: 'info', + }; + const envelope = `${JSON.stringify(envelopeHeader)}\n${JSON.stringify(envelopeItemHeader)}\n${JSON.stringify( + envelopeItemPayload, + )}\n`; + + const response = await fetch('http://localhost:9000/envelope', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: envelope, + }); + + const responseBody = await response.text(); + + return { + attemptedDsn: dsn, + status: response.status, + responseBody, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js new file mode 100644 index 000000000000..c4751d2aa1fd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js @@ -0,0 +1,38 @@ +function makeHex(length) { + return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join(''); +} + +exports.handler = async event => { + const dsn = event?.dsn ?? process.env.TUNNEL_TEST_DSN; + + const envelopeHeader = event?.omitDsn + ? {} + : { + dsn, + }; + const envelopeItemHeader = { type: 'event' }; + const envelopeItemPayload = { + event_id: makeHex(32), + message: event?.marker ?? 'lambda-extension-tunnel-no-dsn-test', + level: 'info', + }; + const envelope = `${JSON.stringify(envelopeHeader)}\n${JSON.stringify(envelopeItemHeader)}\n${JSON.stringify( + envelopeItemPayload, + )}\n`; + + const response = await fetch('http://localhost:9000/envelope', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: envelope, + }); + + const responseBody = await response.text(); + + return { + attemptedDsn: dsn, + status: response.status, + responseBody, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts index 8475ee0a328a..5d35a9f6fcc1 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts @@ -75,10 +75,13 @@ export class LocalLambdaStack extends Stack { Layers: [{ Ref: this.sentryLayer.logicalId }], Environment: { Variables: { - SENTRY_DSN: dsn, SENTRY_TRACES_SAMPLE_RATE: 1.0, SENTRY_DEBUG: true, NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`, + // We only set SENTRY_DSN if not running TunnelNoDsn, because there + // we want to test that the extension tunnel forwards requests when SENTRY_DSN is missing. + TUNNEL_TEST_DSN: dsn, + ...(lambdaDir !== 'TunnelNoDsn' ? { SENTRY_DSN: dsn } : {}), }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts index c32dbfea7435..560f676cfd07 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts @@ -1,7 +1,21 @@ -import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { waitForTransaction, waitForError, waitForRequest } from '@sentry-internal/test-utils'; import { InvokeCommand } from '@aws-sdk/client-lambda'; import { test, expect } from './lambda-fixtures'; +interface TunnelInvokeResult { + attemptedDsn?: string; + status: number; + responseBody: string; +} + +function parseLambdaPayload(payload: Uint8Array | undefined): TunnelInvokeResult { + if (!payload) { + throw new Error('Missing Lambda payload'); + } + + return JSON.parse(Buffer.from(payload).toString('utf8')) as TunnelInvokeResult; +} + test.describe('Lambda layer', () => { test('tracing in CJS works', async ({ lambdaClient }) => { const transactionEventPromise = waitForTransaction('aws-serverless-layer', transactionEvent => { @@ -242,4 +256,67 @@ test.describe('Lambda layer', () => { }), ); }); + + test('extension tunnel validates DSN allowlist and rejects invalid envelopes', async ({ lambdaClient }) => { + const matchingMarker = `extension-tunnel-matching-${Date.now()}`; + const matchingRequestPromise = waitForRequest('aws-serverless-layer', requestData => { + return requestData.rawProxyRequestBody.includes(matchingMarker); + }); + + const matchingResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + marker: matchingMarker, + }), + }), + ); + const matchingResult = parseLambdaPayload(matchingResponse.Payload); + expect(matchingResult.status).toBe(200); + await matchingRequestPromise; + + const mismatchedResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + // Keep host/project/port valid but change public key, so DSN stays valid and fails allowlist match. + dsn: String(matchingResult.attemptedDsn).replace('://public@', '://unauthorized@'), + }), + }), + ); + const mismatchedResult = parseLambdaPayload(mismatchedResponse.Payload); + expect(mismatchedResult.status).toBe(403); + expect(mismatchedResult.responseBody).toContain('DSN not allowed'); + + const missingDsnResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + omitDsn: true, + }), + }), + ); + const missingDsnResult = parseLambdaPayload(missingDsnResponse.Payload); + expect(missingDsnResult.status).toBe(400); + expect(missingDsnResult.responseBody).toContain('missing DSN'); + }); + + test('extension tunnel forwards requests when SENTRY_DSN is missing', async ({ lambdaClient }) => { + const marker = `extension-tunnel-no-sentry-dsn-${Date.now()}`; + const noDsnRequestPromise = waitForRequest('aws-serverless-layer', requestData => { + return requestData.rawProxyRequestBody.includes(marker); + }); + + const noDsnResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnelNoDsn', + Payload: JSON.stringify({ + marker, + }), + }), + ); + const noDsnResult = parseLambdaPayload(noDsnResponse.Payload); + expect(noDsnResult.status).toBe(200); + await noDsnRequestPromise; + }); }); From 0f95d642241ce728f2cb5f1ae2b841e1e8592551 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 10:26:42 +0200 Subject: [PATCH 4/4] Apply suggestion from @andreiborza Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- .../src/lambda-extension/aws-lambda-extension.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts index f046b6d6fd4d..586027233ad5 100644 --- a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts +++ b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts @@ -189,12 +189,5 @@ export class AwsLambdaExtension { */ export function getSentryDSNFromEnv(): DsnComponents | undefined { const raw = process.env.SENTRY_DSN?.trim(); - if (!raw) { - return undefined; - } - const components = makeDsn(raw); - if (!components) { - return undefined; - } - return components; + return raw ? makeDsn(raw) : undefined; }