diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml new file mode 100644 index 0000000..377a8a5 --- /dev/null +++ b/.github/workflows/dockerized-test.yml @@ -0,0 +1,45 @@ +name: dockerized-test + +permissions: + contents: read + +on: + push: + branches: [nodejs24.x] + pull_request: + branches: ['*'] + workflow_dispatch: + +jobs: + dockerized-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Build the RIC tarball + # Produces build-artifacts/aws-lambda-ric-.tgz, which + # Dockerfile.test unpacks into /var/runtime in the test image. + run: npm run build:container + + - name: Build the test image + run: | + docker build . \ + -t local/test \ + -f Dockerfile.test \ + --build-arg BASE_IMAGE=public.ecr.aws/lambda/nodejs:24 + + - name: Run dockerized suites + uses: aws/containerized-test-runner-for-aws-lambda@0863dd17b5fc19585250a2405c0f939a77b4f397 # main + with: + suiteFileArray: '["./test/dockerized/suites/*.json"]' + dockerImageName: 'local/test' + taskFolder: './test/dockerized/tasks' diff --git a/Dockerfile.js b/Dockerfile.js index dde5a77..3d920cd 100644 --- a/Dockerfile.js +++ b/Dockerfile.js @@ -23,11 +23,11 @@ RUN mkdir -p /build && \ ls -R /build/deps # Copy bare config -COPY package.json tsconfig.json eslint.config.js vitest.config.js vitest.setup.ts /app/ +COPY package.json package-lock.json tsconfig.json eslint.config.js vitest.config.js vitest.setup.ts /app/ WORKDIR /app -RUN npm install --ignore-scripts +RUN npm ci --ignore-scripts COPY src /app/src COPY scripts/build.js /app/scripts/build.js diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..5a83234 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,11 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG BASE_IMAGE=public.ecr.aws/lambda/nodejs:24 +FROM $BASE_IMAGE + +# Swap the RIC shipped in the base image for the one we just built. +ADD build-artifacts/aws-lambda-ric-*.tgz /tmp/ +RUN mv /tmp/package/* /var/runtime/ && rm -rf /tmp/package + +COPY test/dockerized/tasks /var/task/ diff --git a/src/context/constants.ts b/src/context/constants.ts index ff315b4..302f22b 100644 --- a/src/context/constants.ts +++ b/src/context/constants.ts @@ -24,6 +24,15 @@ export const REQUIRED_ENV_VARS = [ "AWS_LAMBDA_LOG_STREAM_NAME", ]; +export const W3C_ALLOWED_FIELDS = [ + "traceparent", + "tracestate", + "baggage", +] as const; + +export type W3CFieldName = (typeof W3C_ALLOWED_FIELDS)[number]; +export type W3CFields = Readonly>>; + // This RIC is used by Nodejs24 and above, it's used by NOdejs22 only for LMI and not OD export const CALLBACK_ERROR_NODEJS22 = "ERROR: AWS Lambda does not support callback-based function handlers when using Node.js 22 with Managed Instances. To use Managed Instances, modify this function to use a supported handler signature. For more information see https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html."; diff --git a/src/context/context-builder.test.ts b/src/context/context-builder.test.ts index c6d8833..99a7c49 100644 --- a/src/context/context-builder.test.ts +++ b/src/context/context-builder.test.ts @@ -60,6 +60,7 @@ describe("ContextBuilder", () => { // Methods getRemainingTimeInMillis: expect.any(Function), + w3c: expect.any(Function), }); }); @@ -183,6 +184,283 @@ describe("ContextBuilder", () => { }); }); + describe("w3c", () => { + it("should return {} when no clientContext header is provided", () => { + // GIVEN + const headersWithoutClientContext: Record = { + ...mockValidHeaders, + }; + delete headersWithoutClientContext[HEADERS.CLIENT_CONTEXT]; + + // WHEN + const context = ContextBuilder.build(headersWithoutClientContext); + + // THEN + expect(context.w3c()).toEqual({}); + }); + + it("should return {} when clientContext has no w3c key", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ custom: { value: "test" } }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({}); + // clientContext is untouched when there was nothing to strip + expect(context.clientContext).toEqual({ custom: { value: "test" } }); + }); + + it("should return {baggage:'abc'} when only baggage is set", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { baggage: "abc" }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ baggage: "abc" }); + }); + + it("should return every w3c field carried on clientContext", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + custom: { value: "test" }, + w3c: { + traceparent: + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "rojo=00f067aa0ba902b7", + baggage: "userId=alice", + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "rojo=00f067aa0ba902b7", + baggage: "userId=alice", + }); + }); + + it("should remove the source clientContext.w3c (and nested fields) after construction", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + custom: { value: "test" }, + w3c: { + traceparent: + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + baggage: "userId=alice", + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.clientContext).toBeDefined(); + expect(context.clientContext).not.toHaveProperty("w3c"); + expect( + (context.clientContext as Record)["w3c"], + ).toBeUndefined(); + // Sibling clientContext fields are preserved + expect(context.clientContext).toEqual({ custom: { value: "test" } }); + }); + + it("should ignore non-string w3c field values while still stripping the source", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { + baggage: "abc", + traceparent: 42, // wrong type — must be dropped + tracestate: null, // wrong type — must be dropped + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ baggage: "abc" }); + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + + it("should treat a non-object w3c value as empty and still strip the source", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: "not-an-object", + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({}); + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + + it("should treat an array w3c value as empty and still strip the source", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: ["baggage=abc"], + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({}); + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + + it("should expose a frozen object so callers cannot mutate the fields", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { baggage: "abc" }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN — the object is frozen + expect(Object.isFrozen(context.w3c())).toBe(true); + + // AND — attempts to write silently no-op in sloppy mode and throw in + // strict mode. The test file is a strict ESM TypeScript module, so + // both overwriting an existing key and adding a new one throw. + const mutable = context.w3c() as Record; + expect(() => { + mutable["baggage"] = "tampered"; + }).toThrow(TypeError); + expect(() => { + mutable["injected"] = "nope"; + }).toThrow(TypeError); + + // AND — the value is unchanged. + expect(context.w3c()).toEqual({ baggage: "abc" }); + }); + + it("should only surface the allowlisted fields (traceparent, tracestate, baggage)", () => { + // GIVEN — every allowlisted field set, plus a non-allowlisted one + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { + traceparent: + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "rojo=00f067aa0ba902b7", + baggage: "userId=alice", + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "rojo=00f067aa0ba902b7", + baggage: "userId=alice", + }); + }); + + it("should drop non-allowlisted w3c keys even when the value is a valid string", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { + baggage: "keep=me", + // Non-allowlisted keys — must NOT be surfaced by w3c() + unknownField: "should-not-appear", + "x-custom-trace": "should-not-appear", + __proto__: "should-not-appear", + constructor: "should-not-appear", + toString: "should-not-appear", + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ baggage: "keep=me" }); + // Source is still stripped regardless + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + + it("should omit allowlisted keys when they are absent (no undefined leaks)", () => { + // GIVEN — only baggage present + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { baggage: "abc" }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + const result = context.w3c(); + expect(result).toEqual({ baggage: "abc" }); + expect("traceparent" in result).toBe(false); + expect("tracestate" in result).toBe(false); + }); + + it("should drop allowlisted keys whose value is not a string", () => { + // GIVEN — every allowlisted key present, but with wrong types + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { + traceparent: 42, + tracestate: null, + baggage: { nested: "no" }, + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({}); + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + }); + describe("getRemainingTimeInMillis", () => { it("should calculate remaining time correctly", () => { // GIVEN diff --git a/src/context/context-builder.ts b/src/context/context-builder.ts index 1a6a40e..4be5dc5 100644 --- a/src/context/context-builder.ts +++ b/src/context/context-builder.ts @@ -3,6 +3,9 @@ import { OPTIONAL_INVOKE_HEADERS, REQUIRED_ENV_VARS, REQUIRED_INVOKE_HEADERS, + W3C_ALLOWED_FIELDS, + W3CFields, + W3CFieldName, } from "./constants.js"; import { InvokeContext, InvokeHeaders } from "./types.js"; @@ -32,11 +35,15 @@ export class ContextBuilder { private static getHeaderData(invokeHeaders: InvokeHeaders) { const deadline = this.parseDeadline(invokeHeaders); + const clientContext = this.parseJsonHeader>( + invokeHeaders[OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT], + OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT, + ); + + const w3cFields = this.extractAndStripW3c(clientContext); + return { - clientContext: this.parseJsonHeader>( - invokeHeaders[OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT], - OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT, - ), + clientContext, identity: this.parseJsonHeader>( invokeHeaders[OPTIONAL_INVOKE_HEADERS.COGNITO_IDENTITY], OPTIONAL_INVOKE_HEADERS.COGNITO_IDENTITY, @@ -48,9 +55,46 @@ export class ContextBuilder { getRemainingTimeInMillis: function () { return deadline - Date.now(); }, + w3c: function (): W3CFields { + return w3cFields; + }, }; } + /** + * Pulls `w3c` out of the parsed `clientContext` and returns a frozen, + * normalized copy of the allowlisted string fields (see + * `W3C_ALLOWED_FIELDS`). The `w3c` key is removed from `clientContext` + * itself so callers cannot read the source through `context.clientContext`. + */ + private static extractAndStripW3c( + clientContext: Record | undefined, + ): W3CFields { + if (!clientContext || typeof clientContext !== "object") { + return Object.freeze({}); + } + if (!("w3c" in clientContext)) { + return Object.freeze({}); + } + + const rawW3c = clientContext.w3c; + delete clientContext.w3c; + + if (!rawW3c || typeof rawW3c !== "object" || Array.isArray(rawW3c)) { + return Object.freeze({}); + } + + const source = rawW3c as Record; + const fields: Partial> = {}; + for (const key of W3C_ALLOWED_FIELDS) { + const value = source[key]; + if (typeof value === "string") { + fields[key] = value; + } + } + return Object.freeze(fields); + } + private static parseDeadline(invokeHeaders: InvokeHeaders) { const deadline = parseInt( invokeHeaders[REQUIRED_INVOKE_HEADERS.DEADLINE_MS], diff --git a/src/context/types.ts b/src/context/types.ts index d57986a..7b48580 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -2,6 +2,7 @@ import { WritableResponseStream } from "../stream/index.js"; import { OPTIONAL_INVOKE_HEADERS, REQUIRED_INVOKE_HEADERS, + W3CFields, } from "./constants.js"; export interface InvokeHeaders { @@ -35,6 +36,12 @@ export interface InvokeContext { // Methods getRemainingTimeInMillis(): number; + + /** + * Returns the W3C trace context fields (traceparent, tracestate, baggage) + * that were carried on `clientContext.w3c` at invoke time. + */ + w3c(): W3CFields; } export interface StreamOptions { diff --git a/src/global.d.ts b/src/global.d.ts index a30988c..b3d1849 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,7 +1,6 @@ import { HttpResponseStream } from "./stream/index.ts"; declare global { - // eslint-disable-next-line no-var var awslambda: { /** * Marks a handler as streaming and (optionally) captures a highWaterMark. diff --git a/test/dockerized/suites/ctx.json b/test/dockerized/suites/ctx.json new file mode 100644 index 0000000..564cb05 --- /dev/null +++ b/test/dockerized/suites/ctx.json @@ -0,0 +1,30 @@ +{ + "tests": [ + { + "name": "client_context_is_echoed_when_no_w3c_key", + "handler": "w3c.echoClientContext", + "request": {}, + "clientContext": { + "custom": { "value": "hello" }, + "environment": { "stage": "beta" } + }, + "assertions": [ + { + "response": { + "custom": { "value": "hello" }, + "environment": { "stage": "beta" } + } + } + ] + }, + + { + "name": "client_context_is_null_when_header_absent", + "handler": "w3c.echoClientContext", + "request": {}, + "assertions": [ + { "response": null } + ] + } + ] +} diff --git a/test/dockerized/suites/w3c.json b/test/dockerized/suites/w3c.json new file mode 100644 index 0000000..dd170cc --- /dev/null +++ b/test/dockerized/suites/w3c.json @@ -0,0 +1,149 @@ +{ + "tests": [ + { + "name": "w3c_is_a_function_returning_a_frozen_object", + "handler": "w3c.w3cShape", + "request": {}, + "assertions": [ + { "response": { "typeofW3c": "function", "typeofResult": "object", "isFrozen": true, "isObject": true } } + ] + }, + + { + "name": "w3c_returns_empty_when_no_client_context_header", + "handler": "w3c.getW3c", + "request": {}, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_returns_empty_when_client_context_has_no_w3c_key", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "custom": { "value": "test" } + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_returns_baggage_only", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": { "baggage": "userId=alice" } + }, + "assertions": [ + { "response": { "baggage": "userId=alice" } } + ] + }, + + { + "name": "w3c_returns_all_three_allowlisted_fields", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "tracestate": "rojo=00f067aa0ba902b7", + "baggage": "userId=alice" + } + }, + "assertions": [ + { + "response": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "tracestate": "rojo=00f067aa0ba902b7", + "baggage": "userId=alice" + } + } + ] + }, + + { + "name": "w3c_allowlist_drops_non_allowlisted_keys", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": { + "baggage": "keep=me", + "unknownField": "should-not-appear", + "x-custom-trace": "should-not-appear" + } + }, + "assertions": [ + { "response": { "baggage": "keep=me" } } + ] + }, + + { + "name": "w3c_drops_allowlisted_fields_with_non_string_values", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": { + "traceparent": 42, + "tracestate": null, + "baggage": { "nested": "no" } + } + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_treats_non_object_as_empty", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": "not-an-object" + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_treats_array_as_empty", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": ["baggage=abc"] + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_strips_source_clientContext_w3c_after_construction", + "handler": "w3c.getW3cAndSource", + "request": {}, + "clientContext": { + "custom": { "value": "test" }, + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "baggage": "userId=alice" + } + }, + "assertions": [ + { + "response": { + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "baggage": "userId=alice" + }, + "clientContextIsDefined": true, + "clientContextHasW3c": false, + "clientContext": { "custom": { "value": "test" } } + } + } + ] + } + ] +} diff --git a/test/dockerized/tasks/w3c.mjs b/test/dockerized/tasks/w3c.mjs new file mode 100644 index 0000000..7b37076 --- /dev/null +++ b/test/dockerized/tasks/w3c.mjs @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// + +export const getW3c = async (_event, context) => { + return context.w3c(); +}; + +export const getW3cAndSource = async (_event, context) => { + const clientContext = context.clientContext; + return { + w3c: context.w3c(), + clientContextIsDefined: clientContext !== undefined, + clientContextHasW3c: clientContext !== undefined && "w3c" in clientContext, + clientContext: clientContext ?? null, + }; +}; + +export const echoClientContext = async (_event, context) => { + return context.clientContext ?? null; +}; + +export const w3cShape = async (_event, context) => { + const value = context.w3c(); + return { + typeofW3c: typeof context.w3c, + typeofResult: typeof value, + isFrozen: Object.isFrozen(value), + isObject: value !== null && typeof value === "object", + }; +};