diff --git a/lib/serverless/api-gateway.js b/lib/serverless/api-gateway.js index 2e4fd7c6ca..4ee07d8b02 100644 --- a/lib/serverless/api-gateway.js +++ b/lib/serverless/api-gateway.js @@ -5,6 +5,8 @@ 'use strict' +const logger = require('../logger').child({ component: 'api-gateway' }) + /** * This class captures data needed to construct a web transaction from * an API Gateway Lambda proxy request. This is to be used with the setWebRequest @@ -22,10 +24,12 @@ class LambdaProxyWebRequest { } this.method = '' - if (isGatewayV1Event(event) === true || isAlbEvent(event) === true) { + if (isGatewayV1ProxyEvent(event) === true) { + logger.trace('Web proxy event is API Gateway v1 or ALB') this.url.path = event.path this.method = event.httpMethod - } else if (isGatewayV2Event(event) === true) { + } else if (isGatewayV2ProxyEvent(event) === true) { + logger.trace('Web proxy event is API Gateway v2') this.url.path = event.requestContext.http.path this.method = event.requestContext.http.method } @@ -110,87 +114,18 @@ function normalizeHeaders(event, lowerCaseKey = false) { * to create a web transaction. */ function isLambdaProxyEvent(event) { - return isGatewayV1Event(event) || isGatewayV2Event(event) || isAlbEvent(event) -} - -/** - * Iterates over the minimum signature properties of an event received by this Lambda function - * to determine if this request is triggered from a proxy (API Gateway V1, V2, ALB) or some other service. - * If API Gateway, we need to determine which version: V1 and V2 have a lot of overlap, but some signature - * differences for which we can test. - * - * The test is designed to look only at signature properties used by each version, and returns true with the first - * top-level match. Each test array has only four elements, and each invocation should incur - * only five comparisons: one match for its matching type, and four comparisons for the non-matching type. - * - * API Gateway v2 HTTP: top-level 'rawPath', 'rawQueryString', 'routeKey'. Possibly 'cookies' - * API Gateway v1 HTTP: top-level 'httpMethod', 'resource'. Possibly 'multiValueHeaders', 'multiValueQueryStringParameters' - * API Gateway v1 REST same as HTTP, but without top-level `version` - * ALB: very similar to API Gateway v1, but requestContext contains an elb property - * - * In tests, this set of required API Gateway v1 properties has consistently been delivered in event payloads. - * Similar tests with API Gateway V2 shows that the cookies property is *only* defined if cookies are present. - * If `cookies` is present as a top-level property, the event is surely triggered by API Gateway V2. Its absence, - * though, is *not* a certain indicator of v1. As such, it's the last property considered in our test. - * - * @param {object} targetEvent The event to inspect. - * @param {Array} searchFor An array of keys unique to this proxy type - * @returns {boolean} Whether this event has matches for the keys we're checking - */ -function eventHasRequiredKeys(targetEvent, searchFor) { - const keys = Object.keys(targetEvent) - for (const el of searchFor) { - if (keys.indexOf(el) > -1) { - return true - } - } - return false + return isGatewayV1ProxyEvent(event) || isGatewayV2ProxyEvent(event) } // See https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format -const requiredHttpApiV1Keys = [ - 'httpMethod', - 'path', - 'resource', - 'multiValueHeaders', - 'multiValueQueryStringParameters' -] - // See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html -function isGatewayV1Event(event) { - if (event?.requestContext === undefined || event?.requestContext?.elb !== undefined) { - return false - } - const hasKeys = eventHasRequiredKeys(event, requiredHttpApiV1Keys) - if (hasKeys && event?.version === '1.0') { - return true - } - // Rest API doesn't have version, but we can check on the key matching: - return hasKeys +function isGatewayV1ProxyEvent(event) { + return !!(event.httpMethod && event.path && (event.headers ?? event.multiValueHeaders)) } // See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html -const requiredHttpApiV2Keys = ['rawPath', 'rawQueryString', 'routeKey', 'cookies'] - -function isGatewayV2Event(event) { - if (event?.requestContext === undefined || event?.requestContext?.elb !== undefined) { - return false - } - return eventHasRequiredKeys(event, requiredHttpApiV2Keys) && event?.version === '2.0' -} - -/** - * ALB can act as a proxy for Lambda. Properties have commonalities with API GateWay v1, though ALB-triggered events - * consistently carry an ARN at requestContext.elb. See - * https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#receive-event-from-load-balancer - * and https://docs.aws.amazon.com/lambda/latest/dg/services-alb.html - * - * If we check for that property, we can accept variation in other properties. - * @param {object} event The event property supplied to the Lambda handler - * @returns {boolean} Whether or not the event was triggered by ALB - */ -function isAlbEvent(event) { - return event?.requestContext?.elb !== undefined +function isGatewayV2ProxyEvent(event) { + return !!(event.requestContext?.http?.method && event.requestContext?.http?.path && event.headers) } /** @@ -209,7 +144,6 @@ module.exports = { LambdaProxyWebResponse, isLambdaProxyEvent, isValidLambdaProxyResponse, - isGatewayV1Event, - isGatewayV2Event, - isAlbEvent + isGatewayV1ProxyEvent, + isGatewayV2ProxyEvent } diff --git a/lib/serverless/aws-lambda.js b/lib/serverless/aws-lambda.js index 3d3d2843e1..d8f69a7995 100644 --- a/lib/serverless/aws-lambda.js +++ b/lib/serverless/aws-lambda.js @@ -148,6 +148,10 @@ class AwsLambda { // resultProcessor is used to execute additional logic based on the // payload supplied to the callback. let resultProcessor + + logger.trace('Is this Lambda event an API Gateway or ALB web proxy?', isApiGatewayLambdaProxy) + logger.trace('Lambda event keys', Object.keys(event)) + if (isApiGatewayLambdaProxy) { const webRequest = new apiGateway.LambdaProxyWebRequest(event) setWebRequest(shim, transaction, webRequest) diff --git a/test/unit/serverless/api-gateway-v2-websocket.test.js b/test/unit/serverless/api-gateway-v2-websocket.test.js new file mode 100644 index 0000000000..6b97bb7170 --- /dev/null +++ b/test/unit/serverless/api-gateway-v2-websocket.test.js @@ -0,0 +1,115 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert') +const os = require('node:os') + +const { tspl } = require('@matteo.collina/tspl') +const helper = require('../../lib/agent_helper') +const AwsLambda = require('../../../lib/serverless/aws-lambda') + +const { lambdaAuthorizerEvent } = require('./fixtures') + +test.beforeEach((ctx) => { + // This env var suppresses console output we don't need to inspect. + process.env.NEWRELIC_PIPE_PATH = os.devNull + + ctx.nr = {} + ctx.nr.agent = helper.loadMockedAgent({ + allow_all_headers: true, + serverless_mode: { + enabled: true + } + }) + + ctx.nr.lambda = new AwsLambda(ctx.nr.agent) + ctx.nr.lambda._resetModuleState() + + ctx.nr.event = structuredClone(lambdaAuthorizerEvent) + ctx.nr.functionContext = { + done() {}, + succeed() {}, + fail() {}, + functionName: 'testFunction', + functionVersion: 'testVersion', + invokedFunctionArn: 'arn:test:function', + memoryLimitInMB: '128', + awsRequestId: 'testId' + } + + ctx.nr.agent.setState('started') +}) + +test.afterEach((ctx) => { + helper.unloadAgent(ctx.nr.agent) +}) + +test('should pick up the arn', async (t) => { + const { agent, lambda, event, functionContext } = t.nr + assert.equal(agent.collector.metadata.arn, null) + lambda.patchLambdaHandler(() => {})(event, functionContext, () => {}) + assert.equal(agent.collector.metadata.arn, functionContext.invokedFunctionArn) +}) + +test('should not create a web transaction', async (t) => { + const plan = tspl(t, { plan: 4 }) + const { agent, lambda, event, functionContext, responseBody } = t.nr + + const wrappedHandler = lambda.patchLambdaHandler((event, context, callback) => { + const tx = agent.tracer.getTransaction() + plan.ok(tx) + plan.equal(tx.type, 'bg') + plan.equal(tx.getFullName(), 'OtherTransaction/Function/testFunction') + plan.equal(tx.isActive(), true) + + callback(null, responseBody) + }) + + wrappedHandler(event, functionContext, () => {}) + + await plan.completed +}) + +test('should add w3c tracecontext to transaction if not present on request header', async (t) => { + const plan = tspl(t, { plan: 2 }) + + const { agent, lambda, event, functionContext, responseBody } = t.nr + + agent.config.account_id = 'AccountId1' + agent.config.primary_application_id = 'AppId1' + agent.config.trusted_account_key = 33 + agent.config.distributed_tracing.enabled = true + + const wrappedHandler = lambda.patchLambdaHandler((event, context, callback) => { + const tx = agent.tracer.getTransaction() + + const headers = {} + tx.insertDistributedTraceHeaders(headers) + + plan.match(headers.traceparent, /00-[a-f0-9]{32}-[a-f0-9]{16}-\d{2}/) + plan.match(headers.tracestate, /33@nr=.+AccountId1-AppId1.+/) + + callback(null, responseBody) + }) + + wrappedHandler(event, functionContext, () => {}) + await plan.completed +}) + +test('should not crash when headers are non-existent', (t) => { + const { lambda, event, functionContext, responseBody } = t.nr + delete event.headers + + const wrappedHandler = lambda.patchLambdaHandler((event, context, callback) => { + callback(null, responseBody) + }) + + assert.doesNotThrow(() => { + wrappedHandler(event, functionContext, () => {}) + }) +}) diff --git a/test/unit/serverless/fixtures.js b/test/unit/serverless/fixtures.js index 3172e2c4ce..af5ea8d94f 100644 --- a/test/unit/serverless/fixtures.js +++ b/test/unit/serverless/fixtures.js @@ -311,7 +311,7 @@ const albEvent = { // Event used when one Lambda directly invokes another Lambda. // https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-retain-records.html#invocation-async-destinations -const lambaV1InvocationEvent = { +const lambdaV1InvocationEvent = { version: '1.0', timestamp: '2019-11-14T18:16:05.568Z', requestContext: { @@ -338,6 +338,60 @@ const lambaV1InvocationEvent = { } } +// Event sent by API Gateway to an authorizing Lambda function. This should not be classified as a web event. +// https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-input.html +// Connection, Host, and Upgrade headers removed due to eslint complaints: it requires +// enquoting them, while eslint's @stylistic/quote-props requires unquoting. +const lambdaAuthorizerEvent = { + headers: { + 'content-length': '0', + 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits', + 'Sec-WebSocket-Key': '[redacted]', + 'Sec-WebSocket-Version': '13', + 'X-Amzn-Trace-Id': 'Root=[redacted-traceid]', + 'X-Forwarded-For': '[redacted-ipaddress]', + 'X-Forwarded-Port': '443', + 'X-Forwarded-Proto': 'https' + }, + type: 'REQUEST', + methodArn: + 'arn:aws:execute-api:us-east-1:[redacted-accountid]:[redacted-apiid]/[redacted-stage]/$connect', + multiValueHeaders: { + 'content-length': ['0'], + 'Sec-WebSocket-Extensions': ['permessage-deflate; client_max_window_bits'], + 'Sec-WebSocket-Key': ['[redacted]'], + 'Sec-WebSocket-Version': ['13'], + 'X-Amzn-Trace-Id': ['Root=[redacted]'], + 'X-Forwarded-For': ['[redacted-ipaddress]'], + 'X-Forwarded-Port': ['443'], + 'X-Forwarded-Proto': ['https'] + }, + queryStringParameters: { + Auth: 'blaa' + }, + multiValueQueryStringParameters: { + Auth: ['blaa'] + }, + stageVariables: {}, + requestContext: { + routeKey: '$connect', + eventType: 'CONNECT', + extendedRequestId: '[redacted]', + requestTime: '02/Jan/2025:17:23:00 +0000', + messageDirection: 'IN', + stage: '[redacted-stage]', + connectedAt: 1735838580271, + requestTimeEpoch: 1735838580272, + identity: { + sourceIp: '[redacted-ipaddress]' + }, + requestId: '[redacted]', + domainName: '[redacted-apiid].execute-api.us-east-1.amazonaws.com', + connectionId: '1234567xABCD=', + apiId: '[redacted-apiid]' + } +} + // Event which contains `resource` key and should not be a web event. const lambdaEvent = { someKey: 'someValue', @@ -352,6 +406,7 @@ module.exports = { httpApiGatewayV2Event, httpApiGatewayV2EventAlt, albEvent, - lambaV1InvocationEvent, + lambdaV1InvocationEvent, + lambdaAuthorizerEvent, lambdaEvent } diff --git a/test/unit/serverless/utils.test.js b/test/unit/serverless/utils.test.js index fe0a1a1f0c..89c7351b4b 100644 --- a/test/unit/serverless/utils.test.js +++ b/test/unit/serverless/utils.test.js @@ -9,9 +9,8 @@ const test = require('node:test') const assert = require('node:assert') const { - isGatewayV1Event, - isGatewayV2Event, - isAlbEvent + isGatewayV1ProxyEvent, + isGatewayV2ProxyEvent } = require('../../../lib/serverless/api-gateway') const { @@ -19,37 +18,30 @@ const { httpApiGatewayV1Event, httpApiGatewayV2Event, httpApiGatewayV2EventAlt, - lambaV1InvocationEvent, + lambdaV1InvocationEvent, albEvent, - lambdaEvent + lambdaEvent, + lambdaAuthorizerEvent } = require('./fixtures') -test('isGatewayV1Event', () => { - assert.equal(isGatewayV1Event(restApiGatewayV1Event), true) - assert.equal(isGatewayV1Event(httpApiGatewayV1Event), true) - assert.equal(isGatewayV1Event(httpApiGatewayV2Event), false) - assert.equal(isGatewayV1Event(httpApiGatewayV2EventAlt), false) - assert.equal(isGatewayV1Event(lambaV1InvocationEvent), false) - assert.equal(isGatewayV1Event(albEvent), false) - assert.equal(isGatewayV1Event(lambdaEvent), false) +test('isGatewayV1ProxyEvent', () => { + assert.equal(isGatewayV1ProxyEvent(restApiGatewayV1Event), true) + assert.equal(isGatewayV1ProxyEvent(httpApiGatewayV1Event), true) + assert.equal(isGatewayV1ProxyEvent(httpApiGatewayV2Event), false) + assert.equal(isGatewayV1ProxyEvent(httpApiGatewayV2EventAlt), false) + assert.equal(isGatewayV1ProxyEvent(lambdaV1InvocationEvent), false) + assert.equal(isGatewayV1ProxyEvent(albEvent), true) + assert.equal(isGatewayV1ProxyEvent(lambdaEvent), false) + assert.equal(isGatewayV1ProxyEvent(lambdaAuthorizerEvent), false) }) -test('isGatewayV2Event', () => { - assert.equal(isGatewayV2Event(restApiGatewayV1Event), false) - assert.equal(isGatewayV2Event(httpApiGatewayV1Event), false) - assert.equal(isGatewayV2Event(httpApiGatewayV2Event), true) - assert.equal(isGatewayV2Event(httpApiGatewayV2EventAlt), true) - assert.equal(isGatewayV2Event(lambaV1InvocationEvent), false) - assert.equal(isGatewayV2Event(albEvent), false) - assert.equal(isGatewayV2Event(lambdaEvent), false) -}) - -test('isAlbEvent', () => { - assert.equal(isAlbEvent(restApiGatewayV1Event), false) - assert.equal(isAlbEvent(httpApiGatewayV1Event), false) - assert.equal(isAlbEvent(httpApiGatewayV2Event), false) - assert.equal(isAlbEvent(httpApiGatewayV2EventAlt), false) - assert.equal(isAlbEvent(lambaV1InvocationEvent), false) - assert.equal(isAlbEvent(albEvent), true) - assert.equal(isAlbEvent(lambdaEvent), false) +test('isGatewayV2ProxyEvent', () => { + assert.equal(isGatewayV2ProxyEvent(restApiGatewayV1Event), false) + assert.equal(isGatewayV2ProxyEvent(httpApiGatewayV1Event), false) + assert.equal(isGatewayV2ProxyEvent(httpApiGatewayV2Event), true) + assert.equal(isGatewayV2ProxyEvent(httpApiGatewayV2EventAlt), true) + assert.equal(isGatewayV2ProxyEvent(lambdaV1InvocationEvent), false) + assert.equal(isGatewayV2ProxyEvent(albEvent), false) + assert.equal(isGatewayV2ProxyEvent(lambdaEvent), false) + assert.equal(isGatewayV2ProxyEvent(lambdaAuthorizerEvent), false) })