Skip to content

Commit 971aade

Browse files
mydeaandreiborza
andauthored
feat(aws-serverless): Validate extension tunnel DSN against SENTRY_DSN (#20528)
If this is set (which should generally be the case when using the layer), we want to only allow this DSN to be forwarded. If not set, it does not validate but warn that this is not validated. --------- Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com>
1 parent a84b2f1 commit 971aade

6 files changed

Lines changed: 248 additions & 7 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
function makeHex(length) {
2+
return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join('');
3+
}
4+
5+
exports.handler = async event => {
6+
const dsn = event?.dsn ?? process.env.SENTRY_DSN ?? process.env.TUNNEL_TEST_DSN;
7+
8+
const envelopeHeader = event?.omitDsn
9+
? {}
10+
: {
11+
dsn,
12+
};
13+
const envelopeItemHeader = { type: 'event' };
14+
const envelopeItemPayload = {
15+
event_id: makeHex(32),
16+
message: event?.marker ?? 'lambda-extension-tunnel-test',
17+
level: 'info',
18+
};
19+
const envelope = `${JSON.stringify(envelopeHeader)}\n${JSON.stringify(envelopeItemHeader)}\n${JSON.stringify(
20+
envelopeItemPayload,
21+
)}\n`;
22+
23+
const response = await fetch('http://localhost:9000/envelope', {
24+
method: 'POST',
25+
headers: {
26+
'Content-Type': 'application/x-sentry-envelope',
27+
},
28+
body: envelope,
29+
});
30+
31+
const responseBody = await response.text();
32+
33+
return {
34+
attemptedDsn: dsn,
35+
status: response.status,
36+
responseBody,
37+
};
38+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
function makeHex(length) {
2+
return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join('');
3+
}
4+
5+
exports.handler = async event => {
6+
const dsn = event?.dsn ?? process.env.TUNNEL_TEST_DSN;
7+
8+
const envelopeHeader = event?.omitDsn
9+
? {}
10+
: {
11+
dsn,
12+
};
13+
const envelopeItemHeader = { type: 'event' };
14+
const envelopeItemPayload = {
15+
event_id: makeHex(32),
16+
message: event?.marker ?? 'lambda-extension-tunnel-no-dsn-test',
17+
level: 'info',
18+
};
19+
const envelope = `${JSON.stringify(envelopeHeader)}\n${JSON.stringify(envelopeItemHeader)}\n${JSON.stringify(
20+
envelopeItemPayload,
21+
)}\n`;
22+
23+
const response = await fetch('http://localhost:9000/envelope', {
24+
method: 'POST',
25+
headers: {
26+
'Content-Type': 'application/x-sentry-envelope',
27+
},
28+
body: envelope,
29+
});
30+
31+
const responseBody = await response.text();
32+
33+
return {
34+
attemptedDsn: dsn,
35+
status: response.status,
36+
responseBody,
37+
};
38+
};

dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,13 @@ export class LocalLambdaStack extends Stack {
7575
Layers: [{ Ref: this.sentryLayer.logicalId }],
7676
Environment: {
7777
Variables: {
78-
SENTRY_DSN: dsn,
7978
SENTRY_TRACES_SAMPLE_RATE: 1.0,
8079
SENTRY_DEBUG: true,
8180
NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`,
81+
// We only set SENTRY_DSN if not running TunnelNoDsn, because there
82+
// we want to test that the extension tunnel forwards requests when SENTRY_DSN is missing.
83+
TUNNEL_TEST_DSN: dsn,
84+
...(lambdaDir !== 'TunnelNoDsn' ? { SENTRY_DSN: dsn } : {}),
8285
},
8386
},
8487
},

dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1-
import { waitForTransaction, waitForError } from '@sentry-internal/test-utils';
1+
import { waitForTransaction, waitForError, waitForRequest } from '@sentry-internal/test-utils';
22
import { InvokeCommand } from '@aws-sdk/client-lambda';
33
import { test, expect } from './lambda-fixtures';
44

5+
interface TunnelInvokeResult {
6+
attemptedDsn?: string;
7+
status: number;
8+
responseBody: string;
9+
}
10+
11+
function parseLambdaPayload(payload: Uint8Array | undefined): TunnelInvokeResult {
12+
if (!payload) {
13+
throw new Error('Missing Lambda payload');
14+
}
15+
16+
return JSON.parse(Buffer.from(payload).toString('utf8')) as TunnelInvokeResult;
17+
}
18+
519
test.describe('Lambda layer', () => {
620
test('tracing in CJS works', async ({ lambdaClient }) => {
721
const transactionEventPromise = waitForTransaction('aws-serverless-layer', transactionEvent => {
@@ -242,4 +256,67 @@ test.describe('Lambda layer', () => {
242256
}),
243257
);
244258
});
259+
260+
test('extension tunnel validates DSN allowlist and rejects invalid envelopes', async ({ lambdaClient }) => {
261+
const matchingMarker = `extension-tunnel-matching-${Date.now()}`;
262+
const matchingRequestPromise = waitForRequest('aws-serverless-layer', requestData => {
263+
return requestData.rawProxyRequestBody.includes(matchingMarker);
264+
});
265+
266+
const matchingResponse = await lambdaClient.send(
267+
new InvokeCommand({
268+
FunctionName: 'LayerTunnel',
269+
Payload: JSON.stringify({
270+
marker: matchingMarker,
271+
}),
272+
}),
273+
);
274+
const matchingResult = parseLambdaPayload(matchingResponse.Payload);
275+
expect(matchingResult.status).toBe(200);
276+
await matchingRequestPromise;
277+
278+
const mismatchedResponse = await lambdaClient.send(
279+
new InvokeCommand({
280+
FunctionName: 'LayerTunnel',
281+
Payload: JSON.stringify({
282+
// Keep host/project/port valid but change public key, so DSN stays valid and fails allowlist match.
283+
dsn: String(matchingResult.attemptedDsn).replace('://public@', '://unauthorized@'),
284+
}),
285+
}),
286+
);
287+
const mismatchedResult = parseLambdaPayload(mismatchedResponse.Payload);
288+
expect(mismatchedResult.status).toBe(403);
289+
expect(mismatchedResult.responseBody).toContain('DSN not allowed');
290+
291+
const missingDsnResponse = await lambdaClient.send(
292+
new InvokeCommand({
293+
FunctionName: 'LayerTunnel',
294+
Payload: JSON.stringify({
295+
omitDsn: true,
296+
}),
297+
}),
298+
);
299+
const missingDsnResult = parseLambdaPayload(missingDsnResponse.Payload);
300+
expect(missingDsnResult.status).toBe(400);
301+
expect(missingDsnResult.responseBody).toContain('missing DSN');
302+
});
303+
304+
test('extension tunnel forwards requests when SENTRY_DSN is missing', async ({ lambdaClient }) => {
305+
const marker = `extension-tunnel-no-sentry-dsn-${Date.now()}`;
306+
const noDsnRequestPromise = waitForRequest('aws-serverless-layer', requestData => {
307+
return requestData.rawProxyRequestBody.includes(marker);
308+
});
309+
310+
const noDsnResponse = await lambdaClient.send(
311+
new InvokeCommand({
312+
FunctionName: 'LayerTunnelNoDsn',
313+
Payload: JSON.stringify({
314+
marker,
315+
}),
316+
}),
317+
);
318+
const noDsnResult = parseLambdaPayload(noDsnResponse.Payload);
319+
expect(noDsnResult.status).toBe(200);
320+
await noDsnRequestPromise;
321+
});
245322
});

packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import * as http from 'node:http';
22
import { buffer } from 'node:stream/consumers';
3-
import { debug, dsnFromString, getEnvelopeEndpointWithUrlEncodedAuth } from '@sentry/core';
3+
import {
4+
consoleSandbox,
5+
debug,
6+
type DsnComponents,
7+
dsnToString,
8+
getEnvelopeEndpointWithUrlEncodedAuth,
9+
makeDsn,
10+
} from '@sentry/core';
411
import { DEBUG_BUILD } from './debug-build';
512

613
/**
@@ -94,6 +101,19 @@ export class AwsLambdaExtension {
94101
* Starts the Sentry tunnel.
95102
*/
96103
public startSentryTunnel(): void {
104+
const allowedDsnComponents = getSentryDSNFromEnv();
105+
106+
if (!allowedDsnComponents) {
107+
consoleSandbox(() => {
108+
// eslint-disable-next-line no-console
109+
console.warn(
110+
'Sentry Lambda extension: SENTRY_DSN is not set or is invalid. The /envelope tunnel will forward ' +
111+
'any DSN in the envelope header without allowlist validation. Set SENTRY_DSN to the same DSN as ' +
112+
'your SDK to restrict outbound requests.',
113+
);
114+
});
115+
}
116+
97117
const server = http.createServer(async (req, res) => {
98118
if (req.method === 'POST' && req.url?.startsWith('/envelope')) {
99119
try {
@@ -104,12 +124,30 @@ export class AwsLambdaExtension {
104124
const envelope = new TextDecoder().decode(envelopeBytes);
105125
const piece = envelope.split('\n')[0];
106126
const header = JSON.parse(piece || '{}') as { dsn?: string };
107-
if (!header.dsn) {
108-
throw new Error('DSN is not set');
127+
const envelopeDsn = header.dsn;
128+
if (!envelopeDsn) {
129+
res.writeHead(400, { 'Content-Type': 'application/json' });
130+
res.end(JSON.stringify({ error: 'Invalid envelope: missing DSN' }));
131+
return;
109132
}
110-
const dsn = dsnFromString(header.dsn);
133+
134+
// When SENTRY_DSN is set, same allowlist check as handleTunnelRequest in @sentry/core (SSRF protection).
135+
// If not set, we allow any DSN (but warn about this once, above)
136+
if (allowedDsnComponents) {
137+
if (dsnToString(allowedDsnComponents) !== envelopeDsn) {
138+
DEBUG_BUILD &&
139+
debug.warn(`Sentry Lambda extension tunnel: rejected request with unauthorized DSN (${envelopeDsn})`);
140+
res.writeHead(403, { 'Content-Type': 'application/json' });
141+
res.end(JSON.stringify({ error: 'DSN not allowed' }));
142+
return;
143+
}
144+
}
145+
146+
const dsn = allowedDsnComponents || makeDsn(envelopeDsn);
111147
if (!dsn) {
112-
throw new Error('Invalid DSN');
148+
res.writeHead(403, { 'Content-Type': 'application/json' });
149+
res.end(JSON.stringify({ error: 'Invalid DSN' }));
150+
return;
113151
}
114152
const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn);
115153

@@ -143,3 +181,13 @@ export class AwsLambdaExtension {
143181
});
144182
}
145183
}
184+
185+
/**
186+
* DSN components allowed for the Lambda extension `/envelope` tunnel, derived from `SENTRY_DSN`.
187+
*
188+
* Exported only for testing purposes.
189+
*/
190+
export function getSentryDSNFromEnv(): DsnComponents | undefined {
191+
const raw = process.env.SENTRY_DSN?.trim();
192+
return raw ? makeDsn(raw) : undefined;
193+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2+
import { getSentryDSNFromEnv } from '../src/lambda-extension/aws-lambda-extension';
3+
4+
describe('getSentryDSNFromEnv', () => {
5+
afterEach(() => {
6+
delete process.env.SENTRY_DSN;
7+
vi.restoreAllMocks();
8+
});
9+
10+
beforeEach(() => {
11+
vi.spyOn(console, 'error').mockImplementation(() => {});
12+
});
13+
14+
test('returns undefined when SENTRY_DSN is unset', () => {
15+
expect(getSentryDSNFromEnv()).toEqual(undefined);
16+
});
17+
18+
test('returns canonical dsn string when SENTRY_DSN is valid', () => {
19+
process.env.SENTRY_DSN = 'https://public@o1.ingest.sentry.io/1';
20+
21+
expect(getSentryDSNFromEnv()).toEqual({
22+
protocol: 'https',
23+
publicKey: 'public',
24+
host: 'o1.ingest.sentry.io',
25+
projectId: '1',
26+
pass: '',
27+
path: '',
28+
port: '',
29+
});
30+
});
31+
32+
test('returns undefined when SENTRY_DSN is invalid', () => {
33+
process.env.SENTRY_DSN = 'not-a-dsn';
34+
35+
expect(getSentryDSNFromEnv()).toEqual(undefined);
36+
});
37+
});

0 commit comments

Comments
 (0)