Skip to content

Commit

Permalink
fix: Fixed event matcher to focus on properties specific to web reque…
Browse files Browse the repository at this point in the history
…sts (v1/ALB and v2) (#2863)

Signed-off-by: mrickard <maurice@mauricerickard.com>
Co-authored-by: Bob Evans <robert.evans25@gmail.com>
  • Loading branch information
mrickard and bizob2828 authored Jan 9, 2025
1 parent 75f8902 commit a93fe6e
Show file tree
Hide file tree
Showing 5 changed files with 212 additions and 112 deletions.
92 changes: 13 additions & 79 deletions lib/serverless/api-gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}

/**
Expand All @@ -209,7 +144,6 @@ module.exports = {
LambdaProxyWebResponse,
isLambdaProxyEvent,
isValidLambdaProxyResponse,
isGatewayV1Event,
isGatewayV2Event,
isAlbEvent
isGatewayV1ProxyEvent,
isGatewayV2ProxyEvent
}
4 changes: 4 additions & 0 deletions lib/serverless/aws-lambda.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
115 changes: 115 additions & 0 deletions test/unit/serverless/api-gateway-v2-websocket.test.js
Original file line number Diff line number Diff line change
@@ -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, () => {})
})
})
59 changes: 57 additions & 2 deletions test/unit/serverless/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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',
Expand All @@ -352,6 +406,7 @@ module.exports = {
httpApiGatewayV2Event,
httpApiGatewayV2EventAlt,
albEvent,
lambaV1InvocationEvent,
lambdaV1InvocationEvent,
lambdaAuthorizerEvent,
lambdaEvent
}
Loading

0 comments on commit a93fe6e

Please sign in to comment.