From 5bc3f65b04e12aab321380532d90786866db9e99 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Fri, 25 Sep 2026 21:04:32 +0000 Subject: [PATCH] fix: drop a client-supplied x-amz-tenant-id before setting the context value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guide/src/features/multi-tenancy.md | 44 +++++++++++++++++-- src/lib.rs | 56 +++++++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/docs/guide/src/features/multi-tenancy.md b/docs/guide/src/features/multi-tenancy.md index a763cd5a..581cdda4 100644 --- a/docs/guide/src/features/multi-tenancy.md +++ b/docs/guide/src/features/multi-tenancy.md @@ -1,10 +1,37 @@ # Multi-Tenancy -Lambda Web Adapter supports multi-tenancy by automatically propagating the tenant ID from the Lambda runtime context to your web application. +Lambda Web Adapter supports multi-tenancy by propagating the tenant ID from the Lambda invocation context to your web application as an `X-Amz-Tenant-Id` HTTP header. ## How It Works -When the Lambda runtime includes a `tenant_id` in the invocation context, the adapter forwards it as an `X-Amz-Tenant-Id` HTTP header. If no tenant ID is present, the header is omitted. +When the Lambda invocation context carries a tenant ID, the adapter forwards it as an `X-Amz-Tenant-Id` HTTP header. When it does not, the adapter sets no such header. + +The adapter reads the tenant ID only from the invocation context, never from the forwarded request. It removes any `X-Amz-Tenant-Id` header the caller sent before setting its own, so your application never reads a caller-supplied value from this header. + +That makes the header exactly as trustworthy as whatever puts the tenant ID into the invocation context. Getting that part right is the subject of the prerequisites below. + +> **Warning:** the stripping exists only while the adapter is in the request path — that is, when your application runs on Lambda behind the adapter. If you run the same image or application **without** the adapter (Amazon ECS, Amazon EKS, a local Docker host), nothing removes a caller-supplied `X-Amz-Tenant-Id`, and an application that treats it as an asserted identity is reading raw caller input. Establish the tenant another way in those deployments, or reject the header at your edge. + +## Prerequisites + +The tenant ID only reaches your application if the function uses [Lambda tenant isolation](https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation.html). That carries constraints worth knowing before you build on it: + +- **Tenant isolation must be enabled when the function is created.** It is an immutable function property and cannot be added to an existing function. +- **Function URLs are not supported**, and neither are provisioned concurrency or SnapStart. +- **API Gateway REST APIs are the only supported HTTP trigger.** HTTP APIs cannot be used, because they cannot override the `X-Amz-Tenant-Id` header that Lambda's `Invoke` API requires. +- **Every invocation must carry a tenant ID.** Lambda rejects an invocation of a tenant-isolated function that has none, so such a request fails before your application runs. + +With API Gateway REST you map a request property to `integration.request.header.X-Amz-Tenant-Id`, which is the header Lambda's `Invoke` API reads. **Map it from something the caller cannot choose** — an authorizer context value or a verified token claim: + +```text +integration.request.header.X-Amz-Tenant-Id = context.authorizer.tenantId +``` + +A raw client header is not such a source. Mapping `method.request.header.x-tenant-id` straight through lets any caller name their own tenant: API Gateway forwards the value it was given, Lambda puts it in the invocation context, and the adapter then asserts it as `X-Amz-Tenant-Id` — so the application receives a caller-chosen tenant that looks like a platform-asserted one. Have your authorizer establish the tenant from the caller's credentials and map that. + +See [Invoking Lambda functions with tenant isolation](https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation-invoke.html) for the full setup. + +If the function does not use tenant isolation, no request carries a tenant ID and the adapter sets no `X-Amz-Tenant-Id` header. ## Reading the Tenant ID @@ -22,4 +49,15 @@ app.get('/', (req, res) => { }); ``` -No additional configuration is required. +The adapter itself needs no configuration; the prerequisites above are function and API Gateway settings. + +## Do Not Fall Back to a Client-Supplied Tenant + +If your application scopes data by tenant, treat a missing `X-Amz-Tenant-Id` as an error rather than falling back to a default tenant or to another header the caller controls. A fallback like this turns the tenant identity into caller input: + +```python +# Don't do this: the caller chooses the tenant. +tenant_id = request.headers.get("x-amz-tenant-id") or request.headers.get("x-tenant-id") +``` + +A missing header on a tenant-isolated function means the deployment is wrong, not that the request belongs to a default tenant. diff --git a/src/lib.rs b/src/lib.rs index fb5cbbce..1fa91b18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1600,7 +1600,10 @@ impl Adapter { HeaderValue::from_bytes(&strip_forbidden_header_bytes(&serde_json::to_string(&lambda_context)?))?, ); - // Multi-tenancy support: propagate tenant_id from Lambda context + // Multi-tenancy support: propagate tenant_id from Lambda context. + // The adapter asserts this header, so a client-supplied copy is always dropped + // first -- the same way the two context headers above are unconditionally set. + req_headers.remove(HeaderName::from_static("x-amz-tenant-id")); if let Some(ref tenant_id) = lambda_context.tenant_id { if let Ok(value) = HeaderValue::from_str(tenant_id) { req_headers.insert(HeaderName::from_static("x-amz-tenant-id"), value); @@ -2506,10 +2509,14 @@ mod tests { let adapter = Adapter::new(&options).expect("Failed to create adapter"); + // The caller sets the header too. The app must still not see one: the adapter + // asserts this header, so a client-supplied value is never passed through. let alb_req = lambda_http::request::LambdaRequest::Alb({ let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default(); req.http_method = Method::GET; req.path = Some("/hello".into()); + req.headers + .insert("x-amz-tenant-id", "client-supplied".parse().unwrap()); req }); let mut request = Request::from(alb_req); @@ -2519,6 +2526,53 @@ mod tests { assert_eq!(200, response.status().as_u16()); } + #[tokio::test] + async fn test_context_tenant_id_wins_over_client_supplied_header() { + let app_server = MockServer::start(); + app_server.mock(|when, then| { + // This does NOT guard the `remove` call above: on the `Some` path `insert` + // already drops every prior value, so the app sees only the context tenant + // either way. `test_tenant_id_header_absent_when_no_tenant` is the test that + // fails without it. What this pins is precedence -- the context value is the + // ONLY value forwarded -- so asserting the complete value set keeps it honest + // if `insert` ever becomes `append`. + when.method(GET).path("/hello").is_true(|req| { + req.headers() + .iter() + .filter(|(k, _)| k.as_str() == "x-amz-tenant-id") + .map(|(_, v)| v.to_str().unwrap_or_default()) + .eq(["tenant-from-context"]) + }); + then.status(200).body("OK"); + }); + + let options = AdapterOptions { + host: app_server.host(), + port: app_server.port().to_string(), + readiness_check_port: app_server.port().to_string(), + readiness_check_path: "/".to_string(), + ..Default::default() + }; + + let adapter = Adapter::new(&options).expect("Failed to create adapter"); + + let alb_req = lambda_http::request::LambdaRequest::Alb({ + let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default(); + req.http_method = Method::GET; + req.path = Some("/hello".into()); + req.headers + .insert("x-amz-tenant-id", "client-supplied".parse().unwrap()); + req + }); + let mut request = Request::from(alb_req); + request + .extensions_mut() + .insert(make_lambda_context(Some("tenant-from-context"))); + + let response = adapter.fetch_response(request).await.expect("Request failed"); + assert_eq!(200, response.status().as_u16()); + } + #[test] fn test_strip_forbidden_header_bytes() { // Tab (0x09) and printable ASCII are preserved; CR/LF, NUL, DEL, and other