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
Original file line number Diff line number Diff line change
@@ -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,
};
};
Original file line number Diff line number Diff line change
@@ -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,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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;
});
});
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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 {
Expand All @@ -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);

Expand Down Expand Up @@ -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;
}
37 changes: 37 additions & 0 deletions packages/aws-serverless/test/aws-lambda-extension.test.ts
Original file line number Diff line number Diff line change
@@ -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: '',
});
Comment thread
cursor[bot] marked this conversation as resolved.
});

test('returns undefined when SENTRY_DSN is invalid', () => {
process.env.SENTRY_DSN = 'not-a-dsn';

expect(getSentryDSNFromEnv()).toEqual(undefined);
});
});
Comment thread
cursor[bot] marked this conversation as resolved.
Loading