From 34d75e2c8af26face317744e4189a08f2ec4846c Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 13 Nov 2026 15:16:11 +0100 Subject: [PATCH 1/2] feat(aws-serverless): Keep the Lambda extension enabled when http_proxy is set The extension now forwards envelopes through the proxy itself, so the SDK no longer falls back to sending directly when `http_proxy` is set. The SDK's own request to the extension skips the proxy via a new `proxy: false` transport option. The extension stays off when `transportOptions` sets a proxy or CA certificates, since it can't apply those. Co-Authored-By: Claude Opus 5.5 --- packages/aws-serverless/src/init.ts | 51 +---- packages/aws-serverless/test/init.test.ts | 246 +++------------------ packages/node/src/transports/http.ts | 12 +- packages/node/test/transports/http.test.ts | 12 + 4 files changed, 56 insertions(+), 265 deletions(-) diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 5f3c0ad160ef..24cc750939d9 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -26,40 +26,11 @@ const STATIC_ASSET_INVOCATIONS: NonNullable = [ ]; /** - * 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); } /** @@ -107,14 +78,14 @@ export function getIgnoreSpans(options: AwsServerlessOptions): NonNullable { }); }); - describe('proxy environment variables and layer extension', () => { - test('should enable useLayerExtension when no proxy env vars are set', () => { + describe('proxy settings and layer extension', () => { + test('keeps the extension enabled and bypasses the proxy for it when http_proxy is set', () => { + process.env.http_proxy = 'http://proxy.example.com:8080'; mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - init(options); + init({ transportOptions: { keepAlive: false } }); expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( expect.objectContaining({ useLayerExtension: true, tunnel: 'http://localhost:9000/envelope', + transportOptions: { keepAlive: false, proxy: false }, }), ); }); - test('should disable useLayerExtension when http_proxy is set', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; + test.each([ + ['proxy', { proxy: 'http://proxy.example.com:8080' }], + ['caCerts', { caCerts: 'cert' }], + ])('disables the extension when transportOptions.%s is set', (_name, transportOptions) => { mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - init(options); + init({ transportOptions }); expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: false, - }), + expect.objectContaining({ useLayerExtension: false, transportOptions }), ); expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.not.objectContaining({ - tunnel: expect.any(String), - }), - ); - }); - - describe('no_proxy patterns', () => { - test('should enable useLayerExtension when no_proxy=* (wildcard)', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = '*'; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: true, - tunnel: 'http://localhost:9000/envelope', - }), - ); - }); - - test('should enable useLayerExtension when no_proxy contains localhost', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = 'localhost'; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: true, - tunnel: 'http://localhost:9000/envelope', - }), - ); - }); - - test('should enable useLayerExtension when no_proxy contains 127.0.0.1', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = '127.0.0.1'; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: true, - tunnel: 'http://localhost:9000/envelope', - }), - ); - }); - - test('should enable useLayerExtension when no_proxy contains ::1', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = '::1'; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: true, - tunnel: 'http://localhost:9000/envelope', - }), - ); - }); - - test('should enable useLayerExtension when no_proxy contains localhost in a comma-separated list', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = 'example.com,localhost,other.com'; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: true, - tunnel: 'http://localhost:9000/envelope', - }), - ); - }); - - test('should disable useLayerExtension when no_proxy does not contain localhost patterns', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = 'example.com,other.com'; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: false, - }), - ); - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.not.objectContaining({ - tunnel: expect.any(String), - }), - ); - }); - - test('should disable useLayerExtension when no_proxy contains host (no longer supported)', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = 'host'; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: false, - }), - ); - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.not.objectContaining({ - tunnel: expect.any(String), - }), - ); - }); - - test('should handle case-insensitive no_proxy values', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = 'LOCALHOST'; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: true, - tunnel: 'http://localhost:9000/envelope', - }), - ); - }); - - test('should handle whitespace in no_proxy values', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = ' localhost , example.com '; - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: true, - tunnel: 'http://localhost:9000/envelope', - }), - ); - }); - }); - - test('should respect explicit useLayerExtension=false even with no proxy interference', () => { - mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = { - useLayerExtension: false, - }; - - init(options); - - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: false, - }), - ); - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.not.objectContaining({ - tunnel: expect.any(String), - }), + expect.not.objectContaining({ tunnel: expect.any(String) }), ); }); - test('should respect explicit useLayerExtension=false even with proxy that would interfere', () => { - process.env.http_proxy = 'http://proxy.example.com:8080'; + test('does not use the extension when explicitly enabled but transportOptions sets a proxy', () => { mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = { - useLayerExtension: false, - }; + const transportOptions = { proxy: 'http://proxy.example.com:8080' }; - init(options); + init({ useLayerExtension: true, transportOptions }); expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: false, - }), + expect.objectContaining({ useLayerExtension: true, transportOptions }), ); expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.not.objectContaining({ - tunnel: expect.any(String), - }), + expect.not.objectContaining({ tunnel: expect.any(String) }), ); }); - test('should respect explicit useLayerExtension=false even when no_proxy would enable it', () => { + test('respects explicit useLayerExtension=false when http_proxy is set', () => { process.env.http_proxy = 'http://proxy.example.com:8080'; - process.env.no_proxy = 'localhost'; mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = { - useLayerExtension: false, - }; - init(options); + init({ useLayerExtension: false }); expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.objectContaining({ - useLayerExtension: false, - }), + expect.objectContaining({ useLayerExtension: false }), ); expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.not.objectContaining({ - tunnel: expect.any(String), - }), + expect.not.objectContaining({ tunnel: expect.any(String) }), ); }); }); @@ -461,23 +270,18 @@ describe('init', () => { ); }); - test('should not set tunnel even tho useLayerExtension is set via env var when proxy is explicitly set', () => { + test('should set tunnel when useLayerExtension is set via env var and http_proxy is set', () => { process.env.http_proxy = 'http://proxy.example.com:8080'; process.env.SENTRY_LAYER_EXTENSION = 'true'; mockGetSDKSource.mockReturnValue('aws-lambda-layer'); - const options: AwsServerlessOptions = {}; - init(options); + init({}); - // useLayerExtension is respected but tunnel is not set due to proxy interference expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( expect.objectContaining({ useLayerExtension: true, - }), - ); - expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( - expect.not.objectContaining({ - tunnel: expect.any(String), + tunnel: 'http://localhost:9000/envelope', + transportOptions: { proxy: false }, }), ); }); diff --git a/packages/node/src/transports/http.ts b/packages/node/src/transports/http.ts index 66fc0f080b03..0b4cba59cec1 100644 --- a/packages/node/src/transports/http.ts +++ b/packages/node/src/transports/http.ts @@ -14,8 +14,11 @@ import { HttpsProxyAgent } from '../proxy'; import type { HTTPModule } from './http-module'; export interface NodeTransportOptions extends BaseTransportOptions { - /** Set a proxy that should be used for outbound requests. */ - proxy?: string; + /** + * Set a proxy that should be used for outbound requests. + * Set to `false` to never use a proxy, even when `http_proxy` or `https_proxy` are set. + */ + proxy?: string | false; /** HTTPS proxy CA certificates */ caCerts?: string | Buffer | Array; /** Custom HTTP module. Defaults to the native 'http' and 'https' modules. */ @@ -62,9 +65,8 @@ export function makeNodeTransport(options: NodeTransportOptions): Transport { const nativeHttpModule = isHttps ? https : http; const keepAlive = options.keepAlive ?? true; - const agent = - getProxyAgent(urlSegments, options.proxy) ?? - new nativeHttpModule.Agent({ keepAlive, maxSockets: 30, timeout: 2000 }); + const proxyAgent = options.proxy === false ? undefined : getProxyAgent(urlSegments, options.proxy); + const agent = proxyAgent ?? new nativeHttpModule.Agent({ keepAlive, maxSockets: 30, timeout: 2000 }); const requestExecutor = createRequestExecutor(options, options.httpModule ?? nativeHttpModule, agent); return createTransport(options, requestExecutor); diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index 7c74e160acb2..af815595fa3d 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -377,6 +377,18 @@ describe('makeNewHttpTransport()', () => { delete process.env.http_proxy; }); + it('proxy: false ignores proxy env variables', () => { + process.env.http_proxy = 'http://example.com'; + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: false, + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + delete process.env.http_proxy; + }); + it('no_proxy allows for skipping specific hosts', () => { process.env.no_proxy = 'sentry.io'; makeNodeTransport({ From ca08e9cab09d2a5496753aa8dd27b9644ec894c1 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 13 Nov 2026 17:04:37 +0100 Subject: [PATCH 2/2] test(aws-serverless): Send events through the Lambda extension and http_proxy Co-Authored-By: Claude Opus 5.5 --- .../lambda-functions-layer/HttpProxy/index.js | 3 + .../aws-serverless-layer/src/stack.ts | 6 +- .../aws-serverless-layer/tests/layer.test.ts | 59 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/HttpProxy/index.js diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/HttpProxy/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/HttpProxy/index.js new file mode 100644 index 000000000000..76d529f1324d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/HttpProxy/index.js @@ -0,0 +1,3 @@ +exports.handler = async () => { + throw new Error('http-proxy-test'); +}; 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 6ed4cd16440f..ac85092fcbac 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 @@ -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' { @@ -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 @@ -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}` } : {}), }, }, }, 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 5d3eb3701a2e..dac2e244aa78 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,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 { @@ -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 }> { + 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'); @@ -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 => {