Skip to content
Draft
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,3 @@
exports.handler = async () => {
throw new Error('http-proxy-test');
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const LAMBDA_FUNCTION_TIMEOUT = 10;
const LAYER_DIR = './node_modules/@sentry/aws-serverless/';
// SAM allocates runtime container ports in [5000, 9000) before binding its own endpoint.
export const SAM_PORT = Number(process.env.SAM_PORT) || 17120;
export const HTTP_PROXY_PORT = 3032;

/** Match SAM / Docker to this machine so Apple Silicon does not mix arm64 images with an x86_64 template default. */
function samLambdaArchitecture(): 'arm64' | 'x86_64' {
Expand Down Expand Up @@ -48,10 +49,10 @@ export class LocalLambdaStack extends Stack {
const dsn = `http://public@${hostIp}:3031/1337`;
console.log(`[LocalLambdaStack] Using Sentry DSN: ${dsn}`);

this.addLambdaFunctions(dsn);
this.addLambdaFunctions(dsn, hostIp);
}

private addLambdaFunctions(dsn: string) {
private addLambdaFunctions(dsn: string, hostIp: string) {
console.log(`[LocalLambdaStack] Add all Lambda functions defined in ${LAMBDA_FUNCTIONS_DIR} to the SAM stack`);

const lambdaDirs = fs
Expand Down Expand Up @@ -84,6 +85,7 @@ export class LocalLambdaStack extends Stack {
// we want to test that the extension tunnel forwards requests when SENTRY_DSN is missing.
TUNNEL_TEST_DSN: dsn,
...(lambdaDir !== 'TunnelNoDsn' ? { SENTRY_DSN: dsn } : {}),
...(lambdaDir === 'HttpProxy' ? { http_proxy: `http://${hostIp}:${HTTP_PROXY_PORT}` } : {}),
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { waitForTransaction, waitForError, waitForRequest } from '@sentry-internal/test-utils';
import { InvokeCommand } from '@aws-sdk/client-lambda';
import * as http from 'node:http';
import * as net from 'node:net';
import { once } from 'node:events';
import { HTTP_PROXY_PORT } from '../src/stack';
import { test, expect } from './lambda-fixtures';

interface TunnelInvokeResult {
Expand All @@ -8,6 +12,37 @@ interface TunnelInvokeResult {
responseBody: string;
}

/**
* A forward proxy that records each CONNECT target. The Lambda container reaches the host through its host IP, which
* may not resolve on the host itself, so every tunnel goes to the same port on loopback.
*/
async function startConnectProxy(): Promise<{ connects: string[]; close: () => Promise<void> }> {
const connects: string[] = [];
const server = http.createServer();
server.on('connect', (req: http.IncomingMessage, clientSocket: net.Socket) => {
const target = req.url ?? '';
connects.push(target);
const upstream = net.connect(Number(target.split(':').pop()), '127.0.0.1', () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
upstream.pipe(clientSocket);
clientSocket.pipe(upstream);
});
upstream.on('error', () => clientSocket.destroy());
clientSocket.on('error', () => upstream.destroy());
});
server.listen(HTTP_PROXY_PORT, '0.0.0.0');
await once(server, 'listening');

return {
connects,
close: () =>
new Promise(resolve => {
server.closeAllConnections();
server.close(() => resolve());
}),
};
}

function parseLambdaPayload(payload: Uint8Array | undefined): TunnelInvokeResult {
if (!payload) {
throw new Error('Missing Lambda payload');
Expand Down Expand Up @@ -421,6 +456,30 @@ test.describe('Lambda layer', () => {
expect(secondErrorEvent.tags?.invocation_tag).toBeUndefined();
});

test('sends events through the extension and http_proxy', async ({ lambdaClient }) => {
const proxy = await startConnectProxy();

try {
const requestPromise = waitForRequest('aws-serverless-layer', requestData => {
return requestData.rawProxyRequestBody.includes('http-proxy-test');
});

await lambdaClient.send(new InvokeCommand({ FunctionName: 'LayerHttpProxy', Payload: JSON.stringify({}) }));

const request = await requestPromise;

// The SDK only puts the DSN in the envelope header when it sends through a tunnel, i.e. the extension.
const envelopeHeader = JSON.parse(request.rawProxyRequestBody.split('\n')[0] ?? '{}');
expect(envelopeHeader.dsn).toEqual(expect.stringContaining(':3031/1337'));

// The extension went through the proxy, and the SDK reached the extension without it.
expect(proxy.connects.length).toBeGreaterThan(0);
expect(proxy.connects.every(target => target.endsWith(':3031'))).toBe(true);
} finally {
await proxy.close();
}
});

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 => {
Expand Down
51 changes: 12 additions & 39 deletions packages/aws-serverless/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,40 +26,11 @@ const STATIC_ASSET_INVOCATIONS: NonNullable<Options['ignoreSpans']> = [
];

/**
* Checks if proxy environment variables would interfere with the layer extension.
* The layer extension uses localhost:9000, so we need to check if proxy settings would prevent this.
* The Lambda extension only reads proxy settings from env vars, so it can't apply a proxy or CA certificates set in
* `transportOptions`.
*/
function shouldDisableLayerExtensionForProxy(): boolean {
const { http_proxy, no_proxy } = process.env;

// If no http proxy is configured, no interference (https_proxy doesn't affect HTTP requests)
if (!http_proxy) {
return false;
}

// Check if localhost is exempted by no_proxy
if (no_proxy) {
const exemptions = no_proxy.split(',').map(exemption => exemption.trim().toLowerCase());

// Handle common localhost exemption patterns explicitly
// If localhost is exempted, requests to the layer extension will not be proxied
const localhostExemptions = ['*', 'localhost', '127.0.0.1', '::1'];
if (exemptions.some(exemption => localhostExemptions.includes(exemption))) {
return false;
}
}

// If http_proxy is set and no localhost exemption, it would interfere
// The layer extension uses HTTP to localhost:9000, so only http_proxy matters
if (http_proxy) {
DEBUG_BUILD &&
debug.log(
'Disabling useLayerExtension due to http_proxy environment variable. Consider adding localhost to no_proxy to re-enable.',
);
return true;
}

return false;
function hasProxyTransportOptions(options: AwsServerlessOptions): boolean {
return !!(options.transportOptions?.proxy || options.transportOptions?.caCerts);
}

/**
Expand Down Expand Up @@ -107,14 +78,14 @@ export function getIgnoreSpans(options: AwsServerlessOptions): NonNullable<Optio
*/
export function init(options: AwsServerlessOptions = {}): NodeClient | undefined {
const sdkSource = getSDKSource();
const proxyWouldInterfere = shouldDisableLayerExtensionForProxy();
const hasProxyOptions = hasProxyTransportOptions(options);

// Determine useLayerExtension value with the following priority:
// 1. Explicit option value (if provided)
// 2. Environment variable SENTRY_LAYER_EXTENSION (if set)
// 3. Default logic based on sdkSource, tunnel, and proxy settings
// 3. Default logic based on sdkSource, tunnel, and proxy transport options
const useLayerExtensionFromEnv = envToBool(process.env.SENTRY_LAYER_EXTENSION, { strict: true });
const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !options.tunnel && !proxyWouldInterfere;
const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !options.tunnel && !hasProxyOptions;
const useLayerExtension = options.useLayerExtension ?? useLayerExtensionFromEnv ?? defaultUseLayerExtension;

const opts = {
Expand All @@ -127,23 +98,25 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
if (opts.useLayerExtension) {
if (sdkSource !== 'aws-lambda-layer') {
DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.');
} else if (opts.tunnel || proxyWouldInterfere) {
} else if (opts.tunnel || hasProxyOptions) {
if (opts.tunnel) {
DEBUG_BUILD &&
debug.warn(
`Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`,
);
}

if (proxyWouldInterfere) {
if (hasProxyOptions) {
DEBUG_BUILD &&
debug.warn(
'Sentry Lambda extension is disabled due to proxy environment variables (http_proxy/https_proxy). Consider adding localhost to no_proxy to re-enable.',
'Sentry Lambda extension is disabled because `transportOptions` sets a proxy or CA certificates, which the extension cannot apply.',
);
}
} else {
DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension');
opts.tunnel = 'http://localhost:9000/envelope';
// The extension applies proxy env vars itself; requests to it must not go through the proxy.
opts.transportOptions = { ...opts.transportOptions, proxy: false };
}
}

Expand Down
Loading
Loading