Skip to content

Commit f3885dd

Browse files
committed
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.
1 parent 4c053b6 commit f3885dd

2 files changed

Lines changed: 94 additions & 5 deletions

File tree

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

Lines changed: 60 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;
132+
}
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+
}
109144
}
110-
const dsn = dsnFromString(header.dsn);
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,20 @@ 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+
if (!raw) {
193+
return undefined;
194+
}
195+
const components = makeDsn(raw);
196+
if (!components) {
197+
return undefined;
198+
}
199+
return components;
200+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
});
27+
});
28+
29+
test('returns undefined when SENTRY_DSN is invalid', () => {
30+
process.env.SENTRY_DSN = 'not-a-dsn';
31+
32+
expect(getSentryDSNFromEnv()).toEqual(undefined);
33+
});
34+
});

0 commit comments

Comments
 (0)