diff --git a/auth/context.go b/auth/context.go index 9493965..6ff099b 100644 --- a/auth/context.go +++ b/auth/context.go @@ -36,5 +36,24 @@ func Get(ctx context.Context) *Identity { return id } +const payloadTrustKey = contextKey("ms-payload-trust") + +// WithPayloadTrust marks ctx as carrying identity injected from the typed +// Lambda payload — set ONLY by runtime.NewLambdaHandler (the real Lambda +// invoke path, or the dev lambda-invoke shim after its envelope-secret gate). +// RequireProxy gives a marked request the same pass-through it gives Lambda +// mode: the payload IS the trust boundary, and the envelope never carries the +// per-session X-MS-Platform-Token. The mark lives in context, so inbound +// request data can never set it. +func WithPayloadTrust(ctx context.Context) context.Context { + return context.WithValue(ctx, payloadTrustKey, true) +} + +// PayloadTrusted reports whether WithPayloadTrust marked this context. +func PayloadTrusted(ctx context.Context) bool { + trusted, _ := ctx.Value(payloadTrustKey).(bool) + return trusted +} + // Roles is a convenience constructor for role lists. func Roles(r ...string) []string { return r } diff --git a/auth/middleware.go b/auth/middleware.go index cee493d..8f866a2 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -268,11 +268,16 @@ func requireProxy(inLambda bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Lambda: headers already stripped + identity injected from payload - // (runtime.InjectResources). The payload is the trust boundary, so - // pass through WITHOUT promoting from headers — there are none to - // read, and the preset identity must not be touched. - if inLambda { + // Lambda / payload-trusted: headers already stripped + identity + // injected from the typed payload (runtime.InjectResources). The + // payload is the trust boundary, so pass through WITHOUT promoting + // from headers — there are none to read, and the preset identity + // must not be touched. The payload-trust mark is set only by + // runtime.NewLambdaHandler (real Lambda, or the dev lambda-invoke + // shim behind its envelope-secret gate), never from inbound + // request data — so honoring it here cannot be forged by a direct + // caller. + if inLambda || PayloadTrusted(r.Context()) { next.ServeHTTP(w, r) return } diff --git a/auth/middleware_test.go b/auth/middleware_test.go index b2387ca..044713c 100644 --- a/auth/middleware_test.go +++ b/auth/middleware_test.go @@ -531,6 +531,25 @@ func TestRequireProxy_InLambda_PassThrough(t *testing.T) { } } +func TestRequireProxy_PayloadTrusted_PassThrough(t *testing.T) { + // Dev lambda-invoke shim path: identity was injected from the typed + // envelope behind the shim's secret gate and ctx carries the payload-trust + // mark. The guard must pass through exactly like Lambda mode — the + // envelope never carries the per-session X-MS-Platform-Token, so matching + // the header would 403 every shim-delivered request. + t.Setenv("MS_PLATFORM_TOKEN", "real-token") + handler := requireProxy(false)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/public/me", nil) // no token header + req = req.WithContext(WithPayloadTrust(req.Context())) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200 (payload-trusted request passes the guard), got %d", rec.Code) + } +} + func TestRequireProxy_TokenConfigured_NoHeader_403NotProxied(t *testing.T) { // HTTP dev/tunnel path with a token configured: a direct caller (no token) // is rejected with 403 not_proxied. diff --git a/internal/core/lambda_shim.go b/internal/core/lambda_shim.go new file mode 100644 index 0000000..0852600 --- /dev/null +++ b/internal/core/lambda_shim.go @@ -0,0 +1,134 @@ +package core + +import ( + "context" + "crypto/subtle" + "encoding/json" + "io" + "net/http" + "os" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/mirrorstack-ai/app-module-sdk/auth" + "github.com/mirrorstack-ai/app-module-sdk/internal/httputil" + "github.com/mirrorstack-ai/app-module-sdk/internal/runtime" +) + +// lambdaInvokePath is the reserved dev route dispatch's localHTTPInvoker POSTs +// LambdaRequest envelopes to (via MS_MODULE_LAMBDA_DEV_URL). Mounted only +// outside Lambda mode — see mountSystemRoutes. +const lambdaInvokePath = "/__mirrorstack/lambda-invoke" + +// lambdaInvokeSecretEnvVar is the module-side copy of dispatch's +// MS_MODULE_LAMBDA_INTERNAL_SECRET — dispatch injects that value into every +// envelope's headers map as X-MS-Internal-Secret. A DEDICATED var, not the +// MS_PLATFORM_TOKEN[_FILE] > MS_INTERNAL_SECRET chain: the dev runner points +// that chain at the per-session tunnel token (and the log-shipper secret), +// neither of which dispatch puts inside lambda envelopes. +const lambdaInvokeSecretEnvVar = "MS_LAMBDA_INTERNAL_SECRET" + +// lambdaInvokeShim returns the POST /__mirrorstack/lambda-invoke handler: the +// dev-mode stand-in for the real Lambda transport. It feeds the raw envelope +// through the same runtime.NewLambdaHandler closure production uses, which +// injects the envelope's typed identity into ctx BEFORE any router middleware +// runs — so the shim MUST gate itself: on the dev HTTP path PlatformAuth +// passes preset identity through with no secret check, and an ungated shim +// would let any local caller forge identity. +// +// Gate matrix (envelope secret = headers["X-MS-Internal-Secret"], compared +// against MS_LAMBDA_INTERNAL_SECRET; mirrors internalAuth's matrix): +// +// lambda secret set → enforce: constant-time compare, +// 401 on absent/mismatch +// unset + platform chain set → 503 fail closed: an enforcing config +// (tunnel/self-hosted) with no usable +// lambda secret must reject, never bypass +// nothing configured → bypass: plain `mirrorstack dev`, where +// every other guard on this surface is +// already inert/synthetic +// +// Rejections use OUTER HTTP statuses (400/401/503): dispatch's transport +// treats any >=300 as a generic module-unavailable fault, so no identity or +// gate detail leaks to the client. A delivered invoke always writes HTTP 200 +// — the module's real status rides inside the LambdaResponse envelope. +func (m *Module) lambdaInvokeShim() http.HandlerFunc { + // Captured once at mount, matching internalAuth's construction-time + // capture contract (env vars don't change at runtime). + expected := os.Getenv(lambdaInvokeSecretEnvVar) + platformConfigured := auth.SecretConfigured() + invoke := runtime.NewLambdaHandler(m.router) + + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, internalRouteBodyCap)) + if err != nil { + httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "invalid request body"}) + return + } + + // Peek ONLY the gate-relevant fields before anything touches the + // envelope's identity: the secret, and the inner path (an envelope + // addressed back at the shim would re-enter it with an + // attacker-controlled inner body). + var env struct { + Path string `json:"path"` + Headers map[string]string `json:"headers"` + } + if err := json.Unmarshal(body, &env); err != nil { + httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "invalid lambda invoke envelope"}) + return + } + if isLambdaInvokePath(env.Path) { + httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "invalid lambda invoke envelope"}) + return + } + + switch { + case expected != "": + // Dispatch writes the literal key "X-MS-Internal-Secret"; iterate + // with EqualFold so a differently-cased producer still gates + // correctly instead of silently 401ing every invoke. + var got string + for k, v := range env.Headers { + if strings.EqualFold(k, auth.HeaderInternalSecret) { + got = v + break + } + } + if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { + m.logger.Printf("lambda-invoke shim rejected (secret mismatch, present=%v) from %s", got != "", r.RemoteAddr) + httputil.JSON(w, http.StatusUnauthorized, httputil.ErrorResponse{Error: "internal authentication required"}) + return + } + case platformConfigured: + m.logger.Printf("lambda-invoke shim rejected (%s not set while a platform secret is configured) from %s", lambdaInvokeSecretEnvVar, r.RemoteAddr) + httputil.JSON(w, http.StatusServiceUnavailable, httputil.ErrorResponse{Error: "service unavailable"}) + return + } + + // The outer request's context carries chi's (already-consumed) + // RouteContext for this same mux — the synthetic inner request must + // start routing fresh, or chi reuses that state and 404s every path. + ctx := context.WithValue(r.Context(), chi.RouteCtxKey, nil) + + // NewLambdaHandler never returns a non-nil error — malformed payloads + // and handler failures come back INSIDE the envelope (statusCode + // field), which is what dispatch decodes out of a 2xx body. + resp, _ := invoke(ctx, json.RawMessage(body)) + httputil.JSON(w, http.StatusOK, resp) + } +} + +// isLambdaInvokePath reports whether an envelope's inner path addresses the +// shim itself, normalized the way NewLambdaHandler builds the synthetic URL +// (leading slash added; query/fragment not part of the routed path). +func isLambdaInvokePath(path string) bool { + if i := strings.IndexAny(path, "?#"); i >= 0 { + path = path[:i] + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path == lambdaInvokePath +} diff --git a/internal/core/lambda_shim_test.go b/internal/core/lambda_shim_test.go new file mode 100644 index 0000000..53d39a1 --- /dev/null +++ b/internal/core/lambda_shim_test.go @@ -0,0 +1,395 @@ +package core + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/mirrorstack-ai/app-module-sdk/auth" + "github.com/mirrorstack-ai/app-module-sdk/db" +) + +func postShim(t *testing.T, m *Module, envelope string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest("POST", lambdaInvokePath, strings.NewReader(envelope)) + rec := httptest.NewRecorder() + m.Router().ServeHTTP(rec, req) + return rec +} + +// shimResponse is the LambdaResponse wire shape dispatch decodes out of a 2xx +// shim body. Headers is deliberately map[string][]string — decoding pins the +// contract (a map[string]string producer would fail the unmarshal). +type shimResponse struct { + StatusCode int `json:"statusCode"` + Headers map[string][]string `json:"headers"` + Body string `json:"body"` +} + +func decodeShimResponse(t *testing.T, rec *httptest.ResponseRecorder) shimResponse { + t.Helper() + // Exact top-level field names are the transport contract with dispatch. + var raw map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode shim response: %v (body=%s)", err, rec.Body.String()) + } + for _, key := range []string{"statusCode", "headers", "body"} { + if _, ok := raw[key]; !ok { + t.Errorf("shim response missing %q field: %s", key, rec.Body.String()) + } + } + var out shimResponse + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode shim response envelope: %v (body=%s)", err, rec.Body.String()) + } + return out +} + +// newShimTestModule builds a module with the lambda shim secret configured and +// three public routes the round-trip cases exercise. MS_LAMBDA_INTERNAL_SECRET +// MUST be set before New() — lambdaInvokeShim captures it at mount time. +func newShimTestModule(t *testing.T) *Module { + t.Helper() + t.Setenv("MS_LAMBDA_INTERNAL_SECRET", "lambda-secret") + m, err := New(Config{ID: "test"}) + if err != nil { + t.Fatalf("New: %v", err) + } + m.Public(func(r chi.Router) { + r.Put("/echo", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + resp := map[string]string{ + "method": r.Method, + "body": string(body), + "contentType": r.Header.Get("Content-Type"), + "schema": db.SchemaFrom(r.Context()), + } + if id := auth.Get(r.Context()); id != nil { + resp["userId"] = id.UserID + resp["appId"] = id.AppID + resp["appRole"] = id.AppRole + } + w.Header().Set("X-Echo", "yes") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + r.Get("/anon", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("anon")) + }) + r.Get("/boom", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }) + }) + return m +} + +// TestLambdaInvokeShim_RoundTrip pins the full wire contract with dispatch: +// request field names method/path/headers/body/userId/appId/appRole/appSchema, +// response field names statusCode/headers/body (headers: map[string][]string), +// and outer-status semantics (a delivered invoke is ALWAYS outer 200 — the +// module's real status, including failures, rides inside the envelope). +func TestLambdaInvokeShim_RoundTrip(t *testing.T) { + cases := []struct { + name string + envelope string // dispatch-shaped JSON; field names are the contract + wantInner int + checkInner func(t *testing.T, out shimResponse) + }{ + { + name: "typed identity, appSchema, headers, and body all flow", + envelope: `{ + "method": "PUT", + "path": "/public/echo", + "headers": {"Content-Type": "application/json", "X-MS-Internal-Secret": "lambda-secret"}, + "body": "{\"title\":\"x\"}", + "userId": "u-1", + "appId": "a-1", + "appRole": "admin", + "appSchema": "app_a1" + }`, + wantInner: http.StatusCreated, + checkInner: func(t *testing.T, out shimResponse) { + if got := out.Headers["X-Echo"]; len(got) != 1 || got[0] != "yes" { + t.Errorf("response headers[X-Echo] = %v, want [yes]", got) + } + var inner map[string]string + if err := json.Unmarshal([]byte(out.Body), &inner); err != nil { + t.Fatalf("decode inner body: %v", err) + } + want := map[string]string{ + "method": "PUT", + "body": `{"title":"x"}`, + "contentType": "application/json", + "schema": "app_a1", + "userId": "u-1", + "appId": "a-1", + "appRole": "admin", + } + for k, v := range want { + if inner[k] != v { + t.Errorf("inner %s = %q, want %q", k, inner[k], v) + } + } + }, + }, + { + name: "anonymous public route", + envelope: `{ + "method": "GET", + "path": "/public/anon", + "headers": {"X-MS-Internal-Secret": "lambda-secret"} + }`, + wantInner: http.StatusOK, + }, + { + name: "handler failure rides INSIDE the envelope", + envelope: `{ + "method": "GET", + "path": "/public/boom", + "headers": {"X-MS-Internal-Secret": "lambda-secret"} + }`, + wantInner: http.StatusInternalServerError, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newShimTestModule(t) + rec := postShim(t, m, tc.envelope) + if rec.Code != http.StatusOK { + t.Fatalf("outer status = %d, want 200 (dispatch treats >=300 as transport fault); body=%s", rec.Code, rec.Body.String()) + } + out := decodeShimResponse(t, rec) + if out.StatusCode != tc.wantInner { + t.Errorf("envelope statusCode = %d, want %d (body=%s)", out.StatusCode, tc.wantInner, out.Body) + } + if tc.checkInner != nil { + tc.checkInner(t, out) + } + }) + } +} + +// TestLambdaInvokeShim_Gate pins the secret gate: with MS_LAMBDA_INTERNAL_SECRET +// configured, a forged envelope (absent/wrong secret) is rejected with an outer +// 401 and the routed handler never runs. +func TestLambdaInvokeShim_Gate(t *testing.T) { + cases := []struct { + name string + headers string // headers object inside the envelope + wantStatus int + }{ + {"absent secret", `{}`, http.StatusUnauthorized}, + {"wrong secret", `{"X-MS-Internal-Secret": "nope"}`, http.StatusUnauthorized}, + {"correct secret", `{"X-MS-Internal-Secret": "lambda-secret"}`, http.StatusOK}, + {"correct secret, differently-cased key", `{"x-ms-internal-secret": "lambda-secret"}`, http.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MS_LAMBDA_INTERNAL_SECRET", "lambda-secret") + m, err := New(Config{ID: "test"}) + if err != nil { + t.Fatalf("New: %v", err) + } + served := false + m.Public(func(r chi.Router) { + r.Get("/probe", func(w http.ResponseWriter, r *http.Request) { served = true }) + }) + + rec := postShim(t, m, `{"method":"GET","path":"/public/probe","headers":`+tc.headers+`}`) + if rec.Code != tc.wantStatus { + t.Fatalf("outer status = %d, want %d (body=%s)", rec.Code, tc.wantStatus, rec.Body.String()) + } + if wantServed := tc.wantStatus == http.StatusOK; served != wantServed { + t.Errorf("route served = %v, want %v", served, wantServed) + } + }) + } +} + +func TestLambdaInvokeShim_EnforcingWithoutLambdaSecret_FailsClosed(t *testing.T) { + // A platform-secret source configured (tunnel / self-hosted enforcing mode) + // but no MS_LAMBDA_INTERNAL_SECRET: the shim must 503 and never invoke — + // the operator intended enforcement, so the missing lambda secret must not + // degrade into a bypass (tunnel mode never bypasses auth). Sending the + // platform secret's VALUE in the envelope must not help: the shim gates on + // its own dedicated secret, not the tunnel token. + t.Setenv("MS_INTERNAL_SECRET", "tunnel-secret") + t.Setenv("MS_LAMBDA_INTERNAL_SECRET", "") + m, err := New(Config{ID: "test"}) + if err != nil { + t.Fatalf("New: %v", err) + } + served := false + m.Public(func(r chi.Router) { + r.Get("/probe", func(w http.ResponseWriter, r *http.Request) { served = true }) + }) + + rec := postShim(t, m, `{"method":"GET","path":"/public/probe","headers":{"X-MS-Internal-Secret":"tunnel-secret"}}`) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("outer status = %d, want 503 (fail closed)", rec.Code) + } + if served { + t.Error("route must never be served when the shim fails closed") + } +} + +func TestLambdaInvokeShim_NothingConfigured_Bypasses(t *testing.T) { + // Plain `mirrorstack dev` / standalone go test: no secret source of any + // kind. The shim bypasses its gate, matching every other guard on this + // surface (proxy guard inert, PlatformAuth synthetic admin) — the module + // is already fully open in this state, so the shim adds no new exposure. + t.Setenv("MS_PLATFORM_TOKEN_FILE", "") + t.Setenv("MS_PLATFORM_TOKEN", "") + t.Setenv("MS_INTERNAL_SECRET", "") + t.Setenv("MS_LAMBDA_INTERNAL_SECRET", "") + m, err := New(Config{ID: "test"}) + if err != nil { + t.Fatalf("New: %v", err) + } + m.Public(func(r chi.Router) { + r.Get("/probe", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }) + }) + + rec := postShim(t, m, `{"method":"GET","path":"/public/probe","headers":{}}`) + if rec.Code != http.StatusOK { + t.Fatalf("outer status = %d, want 200 (gate inert with nothing configured)", rec.Code) + } + if out := decodeShimResponse(t, rec); out.StatusCode != http.StatusOK { + t.Errorf("envelope statusCode = %d, want 200", out.StatusCode) + } +} + +func TestLambdaInvokeShim_ProxyGuardedRoutes_Serve(t *testing.T) { + // The dev-runner config: MS_PLATFORM_TOKEN_FILE points at the per-session + // tunnel token, so RequireProxy ENFORCES a token that lambda envelopes + // never carry. The payload-trust mark set behind the shim's gate must move + // the request past the guard — without it every Public/Platform route + // would 403 not_proxied through the shim. + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("session-token\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + t.Setenv("MS_PLATFORM_TOKEN_FILE", tokenFile) + t.Setenv("MS_LAMBDA_INTERNAL_SECRET", "lambda-secret") + m, err := New(Config{ID: "test"}) + if err != nil { + t.Fatalf("New: %v", err) + } + m.Public(func(r chi.Router) { + r.Get("/start", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("started")) }) + }) + var gotIdentity auth.Identity + m.Platform(func(r chi.Router) { + r.Get("/whoami", func(w http.ResponseWriter, r *http.Request) { + if id := auth.Get(r.Context()); id != nil { + gotIdentity = *id + } + }) + }) + + t.Run("public behind RequireProxy", func(t *testing.T) { + rec := postShim(t, m, `{"method":"GET","path":"/public/start","headers":{"X-MS-Internal-Secret":"lambda-secret"}}`) + if rec.Code != http.StatusOK { + t.Fatalf("outer status = %d, want 200", rec.Code) + } + out := decodeShimResponse(t, rec) + if out.StatusCode != http.StatusOK { + t.Errorf("envelope statusCode = %d, want 200 (not_proxied would be 403): %s", out.StatusCode, out.Body) + } + }) + + t.Run("platform reads envelope identity via auth.Get", func(t *testing.T) { + rec := postShim(t, m, `{"method":"GET","path":"/platform/whoami","headers":{"X-MS-Internal-Secret":"lambda-secret"},"userId":"u-7","appId":"a-7","appRole":"member"}`) + if rec.Code != http.StatusOK { + t.Fatalf("outer status = %d, want 200", rec.Code) + } + out := decodeShimResponse(t, rec) + if out.StatusCode != http.StatusOK { + t.Fatalf("envelope statusCode = %d, want 200: %s", out.StatusCode, out.Body) + } + want := auth.Identity{UserID: "u-7", AppID: "a-7", AppRole: "member"} + if gotIdentity != want { + t.Errorf("platform handler identity = %+v, want %+v", gotIdentity, want) + } + }) +} + +func TestLambdaInvokeShim_StripsIdentityClaimHeaders(t *testing.T) { + // Identity-claim headers inside the envelope must still be stripped on the + // shim path — trusted identity arrives ONLY via the typed envelope fields. + m := newShimTestModule(t) + var claims map[string]string + m.Public(func(r chi.Router) { + r.Get("/claims", func(w http.ResponseWriter, r *http.Request) { + claims = map[string]string{ + "userID": r.Header.Get(auth.HeaderUserID), + "appID": r.Header.Get(auth.HeaderAppID), + "appRole": r.Header.Get(auth.HeaderAppRole), + } + }) + }) + + rec := postShim(t, m, `{ + "method": "GET", + "path": "/public/claims", + "headers": { + "X-MS-Internal-Secret": "lambda-secret", + "X-MS-User-ID": "spoofed-user", + "X-MS-App-ID": "spoofed-app", + "X-MS-App-Role": "admin" + } + }`) + if rec.Code != http.StatusOK { + t.Fatalf("outer status = %d, want 200", rec.Code) + } + for name, got := range claims { + if got != "" { + t.Errorf("identity claim header %s must be stripped through the shim, got %q", name, got) + } + } +} + +func TestLambdaInvokeShim_RejectsSelfTargetingEnvelope(t *testing.T) { + // An envelope addressed back at the shim would re-enter it with an + // attacker-controlled inner body; reject before invoking. + m := newShimTestModule(t) + for _, path := range []string{ + "/__mirrorstack/lambda-invoke", + "__mirrorstack/lambda-invoke", + "/__mirrorstack/lambda-invoke?x=1", + } { + t.Run(path, func(t *testing.T) { + rec := postShim(t, m, `{"method":"POST","path":"`+path+`","headers":{"X-MS-Internal-Secret":"lambda-secret"}}`) + if rec.Code != http.StatusBadRequest { + t.Errorf("outer status = %d, want 400 for self-targeting envelope", rec.Code) + } + }) + } +} + +func TestLambdaInvokeShim_MalformedEnvelope_400(t *testing.T) { + m := newShimTestModule(t) + rec := postShim(t, m, `not json`) + if rec.Code != http.StatusBadRequest { + t.Errorf("outer status = %d, want 400 for malformed envelope", rec.Code) + } +} + +func TestLambdaInvokeShim_MountedPostOnly(t *testing.T) { + // Mount condition: outside Lambda mode (this test process) the route + // exists, POST only. The Lambda-mode branch (route absent entirely) can't + // be driven from a test — runtime.IsLambda() is fixed at process init — + // so the guard in mountSystemRoutes is the reviewed line for that half. + m := newShimTestModule(t) + rec := doRequest(t, m.Router(), "GET", lambdaInvokePath) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("GET %s = %d, want 405 (POST only)", lambdaInvokePath, rec.Code) + } +} diff --git a/internal/core/module.go b/internal/core/module.go index 4afdb6f..fb02011 100644 --- a/internal/core/module.go +++ b/internal/core/module.go @@ -736,6 +736,18 @@ func (m *Module) mountSystemRoutes() { m.router.Route("/__mirrorstack", func(r chi.Router) { r.Get("/health", system.Health) // intentionally public — no auth + // Dev-mode Lambda transport shim: dispatch's localHTTPInvoker POSTs + // LambdaRequest envelopes here when MS_MODULE_LAMBDA_DEV_URL points at + // this module (module_deploys simulation without real Lambda). Never + // mounted in Lambda mode — the real transport owns invocation there, + // and not mounting removes any prod re-entrancy surface. Auth is the + // handler's own envelope-secret gate (see lambdaInvokeShim): + // internalAuth would validate the wrong secret (the tunnel session + // token, not the lambda secret dispatch puts INSIDE the envelope). + if !runtime.IsLambda() { + r.Post("/lambda-invoke", m.lambdaInvokeShim()) + } + // Public-scope static handler for the module's React bundle (the // directory the author named in Config.WebDir). Browser-fetched // from the platform's catch-all module page; CORS-permissive diff --git a/internal/runtime/lambda.go b/internal/runtime/lambda.go index bf3df77..f2b37d0 100644 --- a/internal/runtime/lambda.go +++ b/internal/runtime/lambda.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" + "github.com/mirrorstack-ai/app-module-sdk/auth" "github.com/mirrorstack-ai/app-module-sdk/cache" "github.com/mirrorstack-ai/app-module-sdk/db" "github.com/mirrorstack-ai/app-module-sdk/internal/httputil" @@ -133,6 +134,12 @@ func NewLambdaHandler(handler http.Handler) func(context.Context, json.RawMessag if err != nil { return jsonError(400, err.Error()), nil } + // Payload-trust mark: RequireProxy passes a marked request through + // exactly like Lambda mode (the envelope never carries the per-session + // X-MS-Platform-Token). NewLambdaHandler must stay the ONLY writer — + // its callers are the real Lambda transport and the dev lambda-invoke + // shim, which gates on the envelope secret before invoking. + reqCtx = auth.WithPayloadTrust(reqCtx) httpReq = httpReq.WithContext(reqCtx) rec := httptest.NewRecorder() diff --git a/internal/runtime/lambda_test.go b/internal/runtime/lambda_test.go index 95f2cc3..0d07241 100644 --- a/internal/runtime/lambda_test.go +++ b/internal/runtime/lambda_test.go @@ -242,6 +242,31 @@ func TestMsAuthSecretHeadersMatchConstants(t *testing.T) { } } +// TestNewLambdaHandler_MarksPayloadTrusted pins that NewLambdaHandler is the +// writer of the payload-trust mark: every request it builds carries it, so +// auth.RequireProxy passes shim-delivered requests the way it passes Lambda. +func TestNewLambdaHandler_MarksPayloadTrusted(t *testing.T) { + r := chi.NewRouter() + var trusted bool + r.Get("/check", func(w http.ResponseWriter, r *http.Request) { + trusted = auth.PayloadTrusted(r.Context()) + }) + + handler := NewLambdaHandler(r) + resp, err := handler(context.Background(), mustMarshal(t, LambdaRequest{ + Method: "GET", + Path: "/check", + })) + requireNoErr(t, err) + + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if !trusted { + t.Error("synthetic request context must carry the payload-trust mark") + } +} + func TestNewLambdaHandler_InvalidSchema(t *testing.T) { handler := NewLambdaHandler(chi.NewRouter()) payload := mustMarshal(t, LambdaRequest{