Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Fixed event matcher to focus on properties specific to web requests (v1/ALB and v2) #2863

Merged
merged 6 commits into from
Jan 9, 2025
88 changes: 9 additions & 79 deletions lib/serverless/api-gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ class LambdaProxyWebRequest {
}
this.method = ''

if (isGatewayV1Event(event) === true || isAlbEvent(event) === true) {
if (isGatewayV1ProxyEvent(event) === true) {
this.url.path = event.path
this.method = event.httpMethod
} else if (isGatewayV2Event(event) === true) {
} else if (isGatewayV2ProxyEvent(event) === true) {
this.url.path = event.requestContext.http.path
this.method = event.requestContext.http.method
}
Expand Down Expand Up @@ -110,87 +110,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 +140,6 @@ module.exports = {
LambdaProxyWebResponse,
isLambdaProxyEvent,
isValidLambdaProxyResponse,
isGatewayV1Event,
isGatewayV2Event,
isAlbEvent
isGatewayV1ProxyEvent,
isGatewayV2ProxyEvent
}
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 prettier and eslint complaints: prettier requires
mrickard marked this conversation as resolved.
Show resolved Hide resolved
// 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
}
54 changes: 23 additions & 31 deletions test/unit/serverless/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,47 +9,39 @@ const test = require('node:test')
const assert = require('node:assert')

const {
isGatewayV1Event,
isGatewayV2Event,
isAlbEvent
isGatewayV1ProxyEvent,
isGatewayV2ProxyEvent
} = require('../../../lib/serverless/api-gateway')

const {
restApiGatewayV1Event,
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)
})
Loading