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; + }); }); 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..586027233ad5 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; } - const dsn = dsnFromString(header.dsn); + + // 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 = 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,13 @@ 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(); + return raw ? makeDsn(raw) : undefined; +} 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..4c3143eea442 --- /dev/null +++ b/packages/aws-serverless/test/aws-lambda-extension.test.ts @@ -0,0 +1,37 @@ +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', + pass: '', + path: '', + port: '', + }); + }); + + test('returns undefined when SENTRY_DSN is invalid', () => { + process.env.SENTRY_DSN = 'not-a-dsn'; + + expect(getSentryDSNFromEnv()).toEqual(undefined); + }); +});