Sitelet https://github.com/aws/aws-lambda-web-adapter/pull/872
Skip to content

fix: drop a client-supplied x-amz-tenant-id before setting the context value - #872

Open
bnusunny wants to merge 1 commit into
mainfrom
fix/tenant-id-header-hardening
Open

bnusunny wants to merge 1 commit into
mainfrom
fix/tenant-id-header-hardening

Conversation

@bnusunny

@bnusunny bnusunny commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The adapter asserts x-amz-tenant-id on the request it forwards to your application: the value is supposed to come from the Lambda invocation context, not from the caller. But the code only ever set that header. It never removed one the caller had already sent.

When the invocation context carries no tenant ID, the whole block is skipped, so a caller's own x-amz-tenant-id reaches the application untouched. The multi-tenancy guide says the opposite — "If no tenant ID is present, the header is omitted."

The existing test for that promise could not catch it. test_tenant_id_header_absent_when_no_tenant asserted the app sees no tenant header, but never set one on the inbound request, so it passed no matter what the adapter did.

Why it matters

An application following the guide reads this header as the tenant identity. It should never be able to read a value the caller chose. The adapter's guarantee was narrower than the guide's wording, and the gap was invisible to the test suite.

What changed

The adapter now drops any caller-supplied x-amz-tenant-id before deciding whether to set its own. After this change it either sets the header itself or leaves the application with none — there is no third case.

Part Change
src/lib.rs req_headers.remove(...) before the conditional insert, matching how x-amzn-request-context and x-amzn-lambda-context are set unconditionally
src/lib.rs (tests) the absent-tenant test now sets a caller header, so it tests the promise; one new test pins that the context value is the only value the app sees
docs/guide/src/features/multi-tenancy.md states what the adapter guarantees and where it stops, lists the function prerequisites, says where a mapped tenant ID must come from, and warns against a caller-supplied fallback

The guide is the larger half of this change, and it had two problems.

It ended with "No additional configuration is required," which is wrong in a way that matters. The tenant ID only arrives when the function uses Lambda tenant isolation, and that is immutable at function creation, unsupported on function URLs, and among HTTP triggers works only with API Gateway REST — HTTP APIs cannot override the header Lambda's Invoke API requires. Someone who followed the old guide on a function URL would see an empty tenant on every request.

It also said nothing about where the mapped tenant ID should come from. Mapping a raw client header to integration.request.header.X-Amz-Tenant-Id lets any caller name their own tenant: API Gateway forwards the value it was handed, Lambda puts it in the invocation context, and the adapter faithfully asserts it — so the application receives a caller-chosen tenant that looks platform-asserted, and this PR's header stripping buys nothing. The guide now tells readers to map from an authorizer context value or verified token claim and says plainly that a request header is not such a source.

Finally, the stripping this PR adds lives in the adapter, so it stops where the adapter does. The same image on Amazon ECS, Amazon EKS or a local Docker host has nothing removing a caller-supplied X-Amz-Tenant-Id, which matters here because running one image everywhere is the project's headline claim. The guide carries that warning in the same form README.md already uses for the SnapStart hook routes.

Backwards compatibility

Compatible: for every deployment that uses this header as documented — the adapter's own value is unchanged, and Lambda rejects an invocation of a tenant-isolated function that carries no tenant ID, so the value is always present where it is meant to be read.

One behaviour does change: an application that was reading a caller-supplied x-amz-tenant-id passed through the adapter no longer receives it. That is the point of the change, and such a deployment was reading caller input as a tenant identity.

Tests

cargo test --lib: 67 passed. cargo fmt --check and cargo clippy --lib --all-targets clean.

  • test_tenant_id_header_absent_when_no_tenant — strengthened to set x-amz-tenant-id: client-supplied on the inbound event. This is the regression guard: it fails on the pre-fix code (the app receives client-supplied, the mock does not match, 404) and passes after.
  • test_context_tenant_id_wins_over_client_supplied_header — new, and deliberately not a second guard for this fix. On the Some path insert already drops every prior value, so it passes with or without the remove line. What it pins is precedence: the caller sends one value, the context carries another, and the context's value must be the only one forwarded. It asserts the complete header value set rather than "some entry matches", which keeps it meaningful if insert ever becomes append. Its comment says all of this, so the next reader does not mistake it for the regression test.

Both assertions were mutation-checked rather than just run: deleting req_headers.remove(...) fails the first, and deleting it while switching insert to append fails both. The previous .header() matcher survived that second mutation.

Manual verification

Verified against the live service in us-west-2 that the header's source behaves as the rewritten guide describes: a function created with TenantIsolationMode=PER_TENANT rejects an invocation with no tenant ID (InvalidParameterValueException) and reports context.tenant_id when given one, while a function without tenancy config reports None and rejects an invocation that supplies a tenant ID. Also confirmed through an API Gateway REST API with integration.request.header.X-Amz-Tenant-Id mapped from a client header: omitting the client header returns 400 before the function runs, and a caller-supplied x-amz-tenant-id does reach the event headers, which is what this change drops.

no linked issue: found while reviewing the tenant header path; the fix is small enough to stand alone.

@bnusunny
bnusunny requested a review from a team as a code owner September 25, 2026 21:05

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 64a89c2..0c74608
Files: 2
Comments: 2

Comment thread docs/guide/src/features/multi-tenancy.md Outdated
Comment thread src/lib.rs
@bnusunny
bnusunny force-pushed the fix/tenant-id-header-hardening branch from 0c74608 to 580bde7 Compare September 25, 2026 21:27

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 64a89c2..580bde7
Files: 2
Comments: 2

Comment thread docs/guide/src/features/multi-tenancy.md
Comment thread src/lib.rs
…t value

The adapter asserts `x-amz-tenant-id` from the Lambda invocation context, but it
only ever set the header, never cleared one the caller had sent. When the context
carried no tenant ID the block was skipped entirely, so a caller's own
`x-amz-tenant-id` was forwarded to the application untouched — the opposite of
what the multi-tenancy guide promised.

Remove the header before the conditional insert, the same way the sibling
`x-amzn-request-context` and `x-amzn-lambda-context` headers are set
unconditionally. The adapter now either sets this header itself or leaves the
application with none.

Also rewrite the multi-tenancy guide, which is the larger half of this change. It
claimed no additional configuration was required, which is wrong in a way that
matters: the tenant ID only arrives when the function uses Lambda tenant
isolation, which is immutable at creation time, excludes function URLs, and among
HTTP triggers works only with API Gateway REST.

The guide now also says where the mapped tenant ID must come from. Mapping a raw
client header to `integration.request.header.X-Amz-Tenant-Id` lets any caller name
their own tenant — API Gateway forwards it, Lambda puts it in the context, and the
adapter faithfully asserts it — so the guide directs readers to map from an
authorizer context value or verified token claim instead, and says plainly that a
request header is not such a source.

The guide also warns that the stripping stops where the adapter does: the same
image run on Amazon ECS, Amazon EKS or a local Docker host has nothing removing a
caller-supplied `X-Amz-Tenant-Id`, so an application treating it as an asserted
identity there is reading raw caller input. This mirrors the caveat `README.md`
already carries for the SnapStart hook routes.
@bnusunny
bnusunny force-pushed the fix/tenant-id-header-hardening branch from 580bde7 to 5bc3f65 Compare September 25, 2026 21:37

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant