From 3974acb9f5e840f08836551d828bbf0abce638fb Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sat, 20 Jun 2026 03:42:42 +0800 Subject: [PATCH] feat: exempt platform-auth secret headers from the Lambda x-ms-* strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone G groundwork. A production module runs as a Lambda invoked via the HTTP-shaped LambdaRequest envelope; NewLambdaHandler strips all x-ms-* before the router, which dropped X-MS-Internal-Secret / X-MS-Platform-Token too, so a Lambda-invoked module's internalAuth/RequireProxy 401'd every internal/MCP call. Strip only spoofable identity CLAIMS (user/app/role — identity rides typed fields); exempt the two platform-auth SECRET headers. Bumps 0.2.5 -> 0.2.6. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++++ VERSION | 2 +- internal/runtime/lambda.go | 33 +++++++++++++++- internal/runtime/lambda_test.go | 68 +++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3a41b..4fb4921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [v0.2.6] - 2026-06-20 + +Prepares the SDK for the production module transport. In production a module runs as a Lambda function invoked via the HTTP-shaped `LambdaRequest` envelope; this closes the one kernel gap on that receive path so a deployed module's internal/MCP auth works. + +### Changed +- **`NewLambdaHandler` no longer strips the platform-auth secret headers.** The Lambda receive path strips spoofable `X-MS-*` identity *claims* (`X-MS-User-ID`, `X-MS-App-ID`, `X-MS-App-Role`) — trusted identity arrives via the typed `LambdaRequest` fields — but now **exempts the two platform-auth *secret* headers** (`X-MS-Internal-Secret`, `X-MS-Platform-Token`). Previously every `x-ms-*` header was dropped before the router ran, so a Lambda-invoked module's `InternalAuth` / `RequireProxy` middleware could never see the secret and rejected every internal/MCP call. The secrets are platform-injected credentials, not client-spoofable claims (the platform builds a fresh header set per invoke), so letting them through is safe and restores the documented `internalAuth` behavior on the Lambda path. + ## [v0.2.5] - 2026-06-19 A module can now mark one of its own tables **read-only eligible** for a depending module — the producer half of the cross-module data contract. v0.2.0's design notes deferred this in favor of `pg_class` introspection; an explicit declaration is clearer (the producer's intent is in source, not inferred) and keeps the GRANT surface auditable, while preserving the same trust model: the producer marks a table readable, the **app owner** decides who reads it. diff --git a/VERSION b/VERSION index 3a4036f..a53741c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.5 +0.2.6 \ No newline at end of file diff --git a/internal/runtime/lambda.go b/internal/runtime/lambda.go index b5c40b4..bf3df77 100644 --- a/internal/runtime/lambda.go +++ b/internal/runtime/lambda.go @@ -58,6 +58,29 @@ func jsonError(code int, msg string) LambdaResponse { } } +// msAuthSecretHeaders are the platform-injected auth SECRET headers (lower-cased +// for comparison). Unlike the spoofable identity-claim headers (x-ms-user-id, +// x-ms-app-id, x-ms-app-role), these are credentials the platform sets and the +// module's internalAuth / RequireProxy middleware must still validate — so they +// survive the x-ms-* strip on the Lambda path, exactly as the dev tunnel injects +// them. Kept in sync with auth.HeaderInternalSecret / auth.HeaderPlatformToken +// (asserted in lambda_test.go). See decisions/09 §4 (prod module transport). +var msAuthSecretHeaders = map[string]bool{ + "x-ms-internal-secret": true, + "x-ms-platform-token": true, +} + +// isStrippedIdentityHeader reports whether an inbound header must be dropped +// before the module router sees it: every x-ms-* header EXCEPT the platform-auth +// secrets in msAuthSecretHeaders. Trusted identity arrives via the typed +// LambdaRequest fields, so identity-claim headers are always stripped. +func isStrippedIdentityHeader(k string) bool { + if len(k) < 5 || !strings.EqualFold(k[:5], "x-ms-") { + return false + } + return !msAuthSecretHeaders[strings.ToLower(k)] +} + // NewLambdaHandler wraps an http.Handler into a function compatible with // aws-lambda-go's lambda.Start(). func NewLambdaHandler(handler http.Handler) func(context.Context, json.RawMessage) (LambdaResponse, error) { @@ -83,9 +106,15 @@ func NewLambdaHandler(handler http.Handler) func(context.Context, json.RawMessag return jsonError(500, "failed to build request"), nil } - // Copy headers but strip X-MS-* to prevent spoofing + // Copy headers, stripping spoofable X-MS-* identity CLAIMS (user/app/ + // role) — trusted identity arrives via the typed LambdaRequest fields + // below, never from headers. The platform-auth SECRET headers are exempt + // (see msAuthSecretHeaders): they are credentials the module's + // internalAuth / RequireProxy middleware must still see. Safe because the + // platform builds a fresh header set per invoke — nothing client-supplied + // reaches here. for k, v := range req.Headers { - if len(k) >= 5 && strings.EqualFold(k[:5], "x-ms-") { + if isStrippedIdentityHeader(k) { continue } httpReq.Header.Set(k, v) diff --git a/internal/runtime/lambda_test.go b/internal/runtime/lambda_test.go index 15430cc..95f2cc3 100644 --- a/internal/runtime/lambda_test.go +++ b/internal/runtime/lambda_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "strings" "testing" "github.com/go-chi/chi/v5" @@ -174,6 +175,73 @@ func TestNewLambdaHandler_StripXMSHeaders(t *testing.T) { } } +// TestNewLambdaHandler_AuthSecretHeadersSurvive pins the prod-transport +// contract (decisions/09 §4): the platform-auth SECRET headers must reach the +// module router so internalAuth / RequireProxy can validate them on the Lambda +// path, while identity-CLAIM headers stay stripped (identity rides typed fields). +func TestNewLambdaHandler_AuthSecretHeadersSurvive(t *testing.T) { + r := chi.NewRouter() + r.Get("/check", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{ + "internalSecret": r.Header.Get(auth.HeaderInternalSecret), + "platformToken": r.Header.Get(auth.HeaderPlatformToken), + "userID": r.Header.Get(auth.HeaderUserID), + "appID": r.Header.Get(auth.HeaderAppID), + "appRole": r.Header.Get(auth.HeaderAppRole), + }) + }) + + handler := NewLambdaHandler(r) + payload := mustMarshal(t, LambdaRequest{ + Method: "GET", + Path: "/check", + Headers: map[string]string{ + auth.HeaderInternalSecret: "platform-secret", // exempt — must survive + auth.HeaderPlatformToken: "platform-token", // exempt — must survive + auth.HeaderUserID: "spoofed-user", // claim — must be stripped + auth.HeaderAppID: "spoofed-app", // claim — must be stripped + auth.HeaderAppRole: "admin", // claim — must be stripped + }, + }) + + resp, err := handler(context.Background(), payload) + requireNoErr(t, err) + + var body map[string]string + if err := json.Unmarshal([]byte(resp.Body), &body); err != nil { + t.Fatalf("unmarshal handler response: %v", err) + } + + if body["internalSecret"] != "platform-secret" { + t.Errorf("%s must survive the strip, got %q", auth.HeaderInternalSecret, body["internalSecret"]) + } + if body["platformToken"] != "platform-token" { + t.Errorf("%s must survive the strip, got %q", auth.HeaderPlatformToken, body["platformToken"]) + } + for _, claim := range []string{"userID", "appID", "appRole"} { + if body[claim] != "" { + t.Errorf("identity claim %q must be stripped, got %q", claim, body[claim]) + } + } +} + +// TestMsAuthSecretHeadersMatchConstants structurally pins the exempt-header map +// to the auth package constants. The behavioral test above sends whatever the +// constants say, so a rename of a constant WITHOUT updating the map would slip +// past it; this test fails closed on that drift. +func TestMsAuthSecretHeadersMatchConstants(t *testing.T) { + for _, h := range []string{auth.HeaderInternalSecret, auth.HeaderPlatformToken} { + if !msAuthSecretHeaders[strings.ToLower(h)] { + t.Errorf("auth secret header %q is missing from the msAuthSecretHeaders exempt set", h) + } + } + for _, h := range []string{auth.HeaderUserID, auth.HeaderAppID, auth.HeaderAppRole} { + if msAuthSecretHeaders[strings.ToLower(h)] { + t.Errorf("identity claim %q must NOT be exempt from the strip", h) + } + } +} + func TestNewLambdaHandler_InvalidSchema(t *testing.T) { handler := NewLambdaHandler(chi.NewRouter()) payload := mustMarshal(t, LambdaRequest{