From 7d6b50215d4653d2f4fb33df2a8780213457687a Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sat, 13 Jun 2026 19:08:54 +0800 Subject: [PATCH 1/2] feat: reject non-proxied requests on module public + platform surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an SDK-level proxy guard (auth.RequireProxy) that rejects any request reaching a module's PUBLIC or PLATFORM surface without the server-injected X-MS-Platform-Token. A direct caller can spoof X-MS-App-ID / X-MS-User-ID / X-MS-App-Role, but never the per-session platform token dispatch injects at the tunnel boundary — enforcing it at the SDK trust boundary makes every 3rd-party module inherit the protection (doc 09, Fix 2). - auth.RequireProxy: mirrors internalAuth's env gating via platformSecretReader. inert when no token configured (standalone go test); pass-through in Lambda (headers stripped + identity injected from the typed payload — the payload is the trust boundary); enforce on the HTTP dev/tunnel/prod path. Absent/mismatch -> 403 with stable code "not_proxied". - Module.Public now runs the guard (was unauthenticated). Module.Platform runs it in front of PlatformAuth so both surfaces enforce identically. - Internal surface keeps its existing X-MS-Internal-Secret guard, unchanged. - httputil.ErrorResponse gains an optional, omitempty Code field so the 403 carries the machine-matchable not_proxied code without changing existing {"error": ...} responses. Dev path verified: the CLI writes the platform token to MS_PLATFORM_TOKEN_FILE per module and dispatch injects the matching X-MS-Platform-Token, so oauth-core/oauth-google /public/* flows pass behind dispatch; a direct curl to the tunnel port (no token) -> 403 not_proxied. Co-Authored-By: Claude Opus 4.8 (1M context) --- auth/middleware.go | 79 ++++++++++++++++++++ auth/middleware_test.go | 136 +++++++++++++++++++++++++++++++++++ internal/core/module.go | 28 ++++++-- internal/core/module_test.go | 115 ++++++++++++++++++++++++----- internal/httputil/respond.go | 6 ++ 5 files changed, 342 insertions(+), 22 deletions(-) diff --git a/auth/middleware.go b/auth/middleware.go index af750b8..a0afe9e 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -203,6 +203,85 @@ func internalAuth(inLambda bool) func(http.Handler) http.Handler { } } +// CodeNotProxied is the stable error code returned when a request reaches a +// module's public/platform surface without the server-injected platform token +// — i.e. it did not come through the platform proxy (dispatch). The dispatch +// track matches on this code; do not rename it without updating that contract. +const CodeNotProxied = "not_proxied" + +// RequireProxy returns middleware that rejects requests which did not arrive +// through the platform proxy. It guards the module's PUBLIC and PLATFORM +// request surfaces: a direct caller can spoof X-MS-App-ID / X-MS-User-ID / +// X-MS-App-Role, but NOT the X-MS-Platform-Token (a per-session secret the +// platform injects at the tunnel boundary; the browser never sees or sends +// it). Validating the token here, at the SDK trust boundary, makes every +// 3rd-party module inherit the protection for free. +// +// It mirrors InternalAuth's env gating exactly via platformSecretReader, so a +// module's standalone `go test` (no token configured) is unaffected, while the +// `mirrorstack dev` / tunnel / production HTTP paths (dispatch injects the +// token) enforce. +func RequireProxy() func(http.Handler) http.Handler { + return requireProxy(lambdaenv.IsSet()) +} + +// requireProxy is the test seam; inLambda is injected so tests don't mutate +// process env captured at package init. +// +// Behavior matrix (secret = first non-empty of MS_PLATFORM_TOKEN[_FILE], +// MS_INTERNAL_SECRET; header = the corresponding X-MS-* header): +// +// inLambda → PASS through. The Lambda runtime strips all +// X-MS-* headers and injects trusted identity from +// the typed invoke payload (see runtime.NewLambdaHandler +// + InjectResources), so there is no spoofable header +// to guard and no token header to match. The payload +// IS the trust boundary. +// local + secret unset → PASS through (pure standalone `go test`: the guard +// must be inert so module unit tests still run). +// local + secret set → ENFORCE: require the matching token header. Absent +// or mismatched → 403 not_proxied. This is the +// `mirrorstack dev` / `--tunnel` / self-hosted HTTP +// path where dispatch forwards raw headers and injects +// X-MS-Platform-Token. +// +// CRITICAL: an accidentally-inert guard in production is a security hole; an +// accidentally-active guard in standalone tests bricks every module's tests. +// The gating above is deliberately identical to internalAuth's so the two +// trust signals stay in lockstep. +func requireProxy(inLambda bool) func(http.Handler) http.Handler { + readSecret := platformSecretReader() + + 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. + if inLambda { + next.ServeHTTP(w, r) + return + } + + expected, header := readSecret() + + // No token configured: inert (standalone unit tests). + if expected == "" { + next.ServeHTTP(w, r) + return + } + + token := r.Header.Get(header) + if !constantTimeEqual(token, expected) { + log.Printf("mirrorstack: proxy guard rejected (token mismatch, header_present=%v) from %s %s", token != "", r.RemoteAddr, r.URL.Path) + httputil.JSON(w, http.StatusForbidden, httputil.ErrorResponse{ + Error: "request did not come through the platform proxy", + Code: CodeNotProxied, + }) + return + } + next.ServeHTTP(w, r) + }) + } +} + // secretReader returns the current secret and which header carries it. // The file-backed variant re-reads on every call so a refreshed token // (CLI reconnect) is picked up without restarting the module process. diff --git a/auth/middleware_test.go b/auth/middleware_test.go index 9db88d4..9c4aac3 100644 --- a/auth/middleware_test.go +++ b/auth/middleware_test.go @@ -492,3 +492,139 @@ func TestPlatformAuth_PlatformToken_OldHeaderIgnored(t *testing.T) { t.Errorf("expected 401 when sending old header while platform token is configured, got %d", rec.Code) } } + +// --- RequireProxy (not_proxied guard) --- + +func TestRequireProxy_NoSecret_Inert(t *testing.T) { + // Pure standalone `go test`: no platform token configured → the guard MUST + // pass through so module unit tests run unmodified. + t.Setenv("MS_PLATFORM_TOKEN_FILE", "") + t.Setenv("MS_PLATFORM_TOKEN", "") + t.Setenv("MS_INTERNAL_SECRET", "") + handler := requireProxy(false)(http.HandlerFunc(okHandler)) + + // Even a spoofed app-id header is fine here — there's no token to enforce + // against, so the surface is simply open (local dev / unit test). + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderAppID, "spoofed-app") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200 (inert without configured token), got %d", rec.Code) + } +} + +func TestRequireProxy_InLambda_PassThrough(t *testing.T) { + // In Lambda the runtime strips X-MS-* headers and injects identity from the + // typed payload, so there is no token header to match — the guard must pass + // through (otherwise every Lambda request 403s). + t.Setenv("MS_PLATFORM_TOKEN", "tok") + handler := requireProxy(true)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/public/me", nil) // no headers (stripped) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200 (Lambda payload is the trust boundary), 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. + t.Setenv("MS_PLATFORM_TOKEN", "real-token") + handler := requireProxy(false)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderAppID, "spoofed-app") // attacker tries to assert app context + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), CodeNotProxied) { + t.Errorf("expected body to carry code %q, got %s", CodeNotProxied, rec.Body.String()) + } +} + +func TestRequireProxy_TokenConfigured_WrongToken_403(t *testing.T) { + t.Setenv("MS_PLATFORM_TOKEN", "real-token") + handler := requireProxy(false)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderPlatformToken, "wrong-token") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403 for wrong token, got %d", rec.Code) + } +} + +func TestRequireProxy_TokenConfigured_ValidToken_PassThrough(t *testing.T) { + // The proxied request (dispatch injected the matching X-MS-Platform-Token) + // is trusted and reaches the handler. + t.Setenv("MS_PLATFORM_TOKEN", "real-token") + handler := requireProxy(false)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderPlatformToken, "real-token") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200 for valid proxied request, got %d", rec.Code) + } +} + +func TestRequireProxy_LegacyInternalSecretHeader(t *testing.T) { + // When only MS_INTERNAL_SECRET is configured (legacy / internal-secret-only + // sessions), the guard matches the X-MS-Internal-Secret header — same + // fallback platformSecretReader uses for internalAuth. + t.Setenv("MS_PLATFORM_TOKEN_FILE", "") + t.Setenv("MS_PLATFORM_TOKEN", "") + t.Setenv("MS_INTERNAL_SECRET", "legacy-secret") + handler := requireProxy(false)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderInternalSecret, "legacy-secret") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200 with valid legacy internal secret, got %d", rec.Code) + } +} + +func TestRequireProxy_TokenFile_Refresh(t *testing.T) { + // MS_PLATFORM_TOKEN_FILE is re-read per request so a CLI reconnect that + // rotates the token is picked up without restarting the module. + dir := t.TempDir() + file := dir + "/token" + if err := os.WriteFile(file, []byte("first-token\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + t.Setenv("MS_PLATFORM_TOKEN_FILE", file) + handler := requireProxy(false)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderPlatformToken, "first-token") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 with first token, got %d", rec.Code) + } + + // Rotate the token on disk; the old header should now be rejected. + if err := os.WriteFile(file, []byte("second-token\n"), 0o600); err != nil { + t.Fatalf("rotate token file: %v", err) + } + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest("GET", "/public/me", nil)) + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403 after token rotation with no/old header, got %d", rec.Code) + } +} diff --git a/internal/core/module.go b/internal/core/module.go index 79093cb..1ebfb24 100644 --- a/internal/core/module.go +++ b/internal/core/module.go @@ -101,6 +101,10 @@ type Module struct { contribReg *contributions.Registry // declared contribution slots contribStorage *contributions.Storage // contributions table CRUD internalAuth func(http.Handler) http.Handler + // proxyGuard rejects non-proxied requests on the public + platform + // surfaces (auth.RequireProxy). Captured once at New() so every + // Public/Platform registration reuses one closure, matching internalAuth. + proxyGuard func(http.Handler) http.Handler poolCache *db.PoolCache // production: per-app DB pools devDBOnce sync.Once // dev mode: lazy DB init devDB *db.DB @@ -205,6 +209,7 @@ func New(cfg Config) (*Module, error) { contribReg: contributions.NewRegistry(), contribStorage: contributions.NewStorage(cfg.ID), internalAuth: auth.InternalAuth(), + proxyGuard: auth.RequireProxy(), poolCache: db.NewPoolCache(), cacheCache: cache.NewClientCache(), taskHandlers: make(map[string]taskEntry), @@ -282,14 +287,23 @@ func (m *Module) Router() *chi.Mux { return m.router } // Default role gate: admin only. Use Module.RequirePermission to // open routes to member/viewer. func (m *Module) Platform(fn func(r chi.Router)) { - m.scopedRoutes(registry.ScopePlatform, auth.PlatformAuth(), fn) + // proxyGuard runs BEFORE PlatformAuth: reject non-proxied callers at the + // trust boundary, then let PlatformAuth read the now-trusted identity + // headers. (PlatformAuth already validates the platform token on its own, + // so the guard is belt-and-suspenders here, but keeping it makes the + // public + platform surfaces enforce identically and auditably.) + m.scopedRoutes(registry.ScopePlatform, fn, m.proxyGuard, auth.PlatformAuth()) } // Public registers routes with public auth scope (anyone, including // anonymous). Auto-mounted under /public/ — write `r.Get("/me", ...)` // and the route lands at /public/me. func (m *Module) Public(fn func(r chi.Router)) { - m.scopedRoutes(registry.ScopePublic, nil, fn) + // proxyGuard is the ONLY thing standing between a direct caller and the + // public surface trusting spoofable X-MS-App-ID / X-MS-User-ID / + // X-MS-App-Role. Public scope has no role gate, so this is where the + // not_proxied check has to live. + m.scopedRoutes(registry.ScopePublic, fn, m.proxyGuard) } // Internal registers routes with internal auth scope (platform-to-module @@ -299,7 +313,7 @@ func (m *Module) Public(fn func(r chi.Router)) { // is cached on the Module at New() so OnEvent / Cron registrations reuse a // single closure instead of constructing one per call. func (m *Module) Internal(fn func(r chi.Router)) { - m.scopedRoutes(registry.ScopeInternal, m.internalAuth, fn) + m.scopedRoutes(registry.ScopeInternal, fn, m.internalAuth) } // internalRouteBodyCap is the default body size limit for Internal-scope @@ -346,13 +360,15 @@ func (m *Module) mountSystemInternalRoute(method, path string, handler http.Hand // route tree, which indicates a misconfigured module that should not start. // Panic instead of silently leaving the registry and router in inconsistent // states (some routes recorded but not re-registered, or vice versa). -func (m *Module) scopedRoutes(scope registry.Scope, scopeMiddleware func(http.Handler) http.Handler, fn func(r chi.Router)) { +func (m *Module) scopedRoutes(scope registry.Scope, fn func(r chi.Router), scopeMiddleware ...func(http.Handler) http.Handler) { sub := chi.NewRouter() if scope == registry.ScopeInternal { sub.Use(httputil.MaxBytes(internalRouteBodyCap)) } - if scopeMiddleware != nil { - sub.Use(scopeMiddleware) + for _, mw := range scopeMiddleware { + if mw != nil { + sub.Use(mw) + } } sub.Route("/"+string(scope), fn) diff --git a/internal/core/module_test.go b/internal/core/module_test.go index 608aff3..27998de 100644 --- a/internal/core/module_test.go +++ b/internal/core/module_test.go @@ -238,9 +238,11 @@ func TestPlatform_LocalDevBypass_InjectsSyntheticAdmin(t *testing.T) { } func TestPlatform_SecretSet_RejectsNoHeader(t *testing.T) { - // When MS_INTERNAL_SECRET is set (tunnel mode or prod), local bypass - // is off — a Platform-scope route 401s without a valid trusted- - // forwarder header. + // When MS_INTERNAL_SECRET is set (tunnel mode or prod), local bypass is + // off. The proxy guard now fronts PlatformAuth, so a request with NO + // trusted-forwarder token is rejected at the proxy boundary with + // 403 not_proxied (previously PlatformAuth returned 401). Either way the + // route is locked; the guard just catches it one step earlier. t.Setenv("MS_INTERNAL_SECRET", "real-secret") m, _ := New(Config{ID: "test", Name: "Test"}) m.Platform(func(r chi.Router) { @@ -250,8 +252,72 @@ func TestPlatform_SecretSet_RejectsNoHeader(t *testing.T) { }) rec := doRequest(t, m.Router(), "GET", "/platform/admin") - if rec.Code != 401 { - t.Errorf("expected 401 without trusted-forwarder header, got %d", rec.Code) + if rec.Code != http.StatusForbidden { + t.Errorf("expected 403 not_proxied without trusted-forwarder header, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), auth.CodeNotProxied) { + t.Errorf("expected not_proxied code in body, got %s", rec.Body.String()) + } +} + +func TestPublic_ProxyGuard_RejectsDirectCaller(t *testing.T) { + // With a platform token configured (tunnel / prod HTTP path), a direct + // caller that spoofs X-MS-App-ID but lacks the server-injected + // X-MS-Platform-Token is rejected with 403 not_proxied — it never reaches + // the public handler that would otherwise trust the app context. + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + m, _ := New(Config{ID: "test", Name: "Test"}) + m.Public(func(r chi.Router) { + r.Get("/start", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("started")) + }) + }) + + req := httptest.NewRequest("GET", "/public/start", nil) + req.Header.Set(auth.HeaderAppID, "spoofed-app") + rec := httptest.NewRecorder() + m.Router().ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for direct (non-proxied) caller, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), auth.CodeNotProxied) { + t.Errorf("expected not_proxied code in body, got %s", rec.Body.String()) + } +} + +func TestPublic_ProxyGuard_AllowsProxiedCaller(t *testing.T) { + // The same public route reached through dispatch — which injects the + // matching X-MS-Internal-Secret/Platform-Token — passes the guard. + m := newTestModuleWithSecret(t, "test") // MS_INTERNAL_SECRET = "secret" + m.Public(func(r chi.Router) { + r.Get("/start", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("started")) + }) + }) + + rec := doRequestWithSecret(t, m.Router(), "GET", "/public/start", "secret") + if rec.Code != 200 { + t.Errorf("expected 200 for proxied caller with valid token, got %d", rec.Code) + } +} + +func TestPublic_ProxyGuard_InertWithoutToken(t *testing.T) { + // Standalone module `go test` (no platform token configured): the guard is + // inert so the public surface stays open for local unit tests. + t.Setenv("MS_PLATFORM_TOKEN_FILE", "") + t.Setenv("MS_PLATFORM_TOKEN", "") + t.Setenv("MS_INTERNAL_SECRET", "") + m, _ := New(Config{ID: "test", Name: "Test"}) + m.Public(func(r chi.Router) { + r.Get("/start", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("started")) + }) + }) + + rec := doRequest(t, m.Router(), "GET", "/public/start") + if rec.Code != 200 { + t.Errorf("expected 200 (guard inert in standalone test), got %d", rec.Code) } } @@ -724,17 +790,34 @@ func TestManifest_RegisteredScopesStillRouteCorrectly(t *testing.T) { }) }) - // Public route — no auth needed - if rec := doRequest(t, m.Router(), "GET", "/public/feed"); rec.Code != 200 { - t.Errorf("public route: code = %d, want 200", rec.Code) - } - // Platform route without auth → 401 - if rec := doRequest(t, m.Router(), "GET", "/platform/admin/items"); rec.Code != 401 { - t.Errorf("platform route without auth: code = %d, want 401", rec.Code) - } - // Platform route with auth → 200 - if rec := doRequestWithRole(t, m.Router(), "GET", "/platform/admin/items", auth.RoleAdmin); rec.Code != 200 { - t.Errorf("platform route with admin: code = %d, want 200", rec.Code) + // A module created with MS_INTERNAL_SECRET set runs the proxy guard on its + // public + platform surfaces, so requests must carry the matching token + // (the value dispatch injects). doRequestWithSecret sends X-MS-Internal-Secret. + + // Public route — proxied (token present), no role needed → 200. + if rec := doRequestWithSecret(t, m.Router(), "GET", "/public/feed", "secret"); rec.Code != 200 { + t.Errorf("public route (proxied): code = %d, want 200", rec.Code) + } + // Public route — direct caller (no token) → 403 not_proxied. + if rec := doRequest(t, m.Router(), "GET", "/public/feed"); rec.Code != http.StatusForbidden { + t.Errorf("public route (direct): code = %d, want 403", rec.Code) + } + // Platform route without the proxy token → 403 not_proxied (the guard + // fronts PlatformAuth, so an unauthenticated direct call is caught here). + if rec := doRequest(t, m.Router(), "GET", "/platform/admin/items"); rec.Code != http.StatusForbidden { + t.Errorf("platform route without auth: code = %d, want 403", rec.Code) + } + // Platform route proxied with the token → PlatformAuth then reads the + // trusted identity headers (admin) → 200. + req := httptest.NewRequest("GET", "/platform/admin/items", nil) + req.Header.Set(auth.HeaderInternalSecret, "secret") + req.Header.Set(auth.HeaderUserID, "u-1") + req.Header.Set(auth.HeaderAppID, "a-1") + req.Header.Set(auth.HeaderAppRole, auth.RoleAdmin) + rec := httptest.NewRecorder() + m.Router().ServeHTTP(rec, req) + if rec.Code != 200 { + t.Errorf("platform route (proxied, admin): code = %d, want 200", rec.Code) } } diff --git a/internal/httputil/respond.go b/internal/httputil/respond.go index 4ca6306..7354f50 100644 --- a/internal/httputil/respond.go +++ b/internal/httputil/respond.go @@ -7,8 +7,14 @@ import ( ) // ErrorResponse is the standard JSON error envelope used across the SDK. +// +// Code is an optional stable, machine-readable identifier (e.g. "not_proxied") +// the platform/dispatch can match on without parsing the human Error string. +// It is omitted when empty so existing single-field {"error": ...} responses +// are byte-for-byte unchanged. type ErrorResponse struct { Error string `json:"error"` + Code string `json:"code,omitempty"` } // JSON writes v as JSON with the given status code. From cb03195c27e2f6bf04c0b7578a3168c549261d03 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sat, 13 Jun 2026 19:18:15 +0800 Subject: [PATCH 2/2] fix(auth): fail closed on unreadable token file; cache PlatformAuth; gate dev CORS on full secret chain Review fixes for the proxy-guard PR: - requireProxy/internalAuth/platformAuth now distinguish 'no secret source configured' from 'configured but unreadable'. A token-file read error (MS_PLATFORM_TOKEN_FILE deleted mid-rotation, bad perms, tmpfs full) previously collapsed to the local-dev bypass and made the guard inert. secretReader now returns a 'configured' bool so guards fail CLOSED (403/503) on read errors instead of passing through. - Cache PlatformAuth() on the Module at New() (m.platformAuth) so every Platform() registration reuses one closure, matching the captured-once contract already honored by internalAuth and proxyGuard. - Gate localDevCORS and requireInternalSecret on the full secret chain (new auth.SecretConfigured) instead of MS_INTERNAL_SECRET alone, so a tunnel module carrying only MS_PLATFORM_TOKEN no longer attaches permissive credentialed CORS or fails Start() spuriously. - Tests: token-file read-error fails closed; SecretConfigured stays true when the file is set-but-unreadable; clarify the auto-mount-prefix test comment that the guard is inert there (enforcement covered elsewhere). Co-Authored-By: Claude Opus 4.8 (1M context) --- auth/middleware.go | 102 +++++++++++++++++++++++++++-------- auth/middleware_test.go | 35 ++++++++++++ internal/core/module.go | 46 +++++++++++----- internal/core/module_test.go | 6 ++- 4 files changed, 152 insertions(+), 37 deletions(-) diff --git a/auth/middleware.go b/auth/middleware.go index a0afe9e..c387c2c 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -64,7 +64,7 @@ func PlatformAuth() func(http.Handler) http.Handler { // where the CLI sets the secret) func platformAuth(inLambda bool) func(http.Handler) http.Handler { readSecret := platformSecretReader() - if expected, _ := readSecret(); expected == "" && !inLambda { + if _, _, configured := readSecret(); !configured && !inLambda { log.Printf("mirrorstack: no platform secret set; platform routes bypass auth with synthetic admin identity (local dev)") } @@ -76,10 +76,13 @@ func platformAuth(inLambda bool) func(http.Handler) http.Handler { return } - expected, header := readSecret() + expected, header, configured := readSecret() - // Step 2: local-dev bypass — inject synthetic admin. - if expected == "" && !inLambda { + // Step 2: local-dev bypass — inject synthetic admin. Only when NO + // secret source is configured; a configured-but-unreadable secret + // (configured=true, expected="") falls through to Step 3/4 and + // fails closed rather than minting a synthetic admin. + if !configured && !inLambda { ctx := Set(r.Context(), Identity{ UserID: "local-dev-user", AppID: "local-dev-app", @@ -89,9 +92,11 @@ func platformAuth(inLambda bool) func(http.Handler) http.Handler { return } - // Step 3: Lambda + no secret is a misconfig. + // Step 3: no usable secret (Lambda misconfig, or a configured token + // file that could not be read) → reject. 503 signals operator + // misconfiguration to platform alerting. if expected == "" { - log.Printf("mirrorstack: platform auth rejected (no secret configured) from %s %s", r.RemoteAddr, r.URL.Path) + log.Printf("mirrorstack: platform auth rejected (no usable secret; configured=%v) from %s %s", configured, r.RemoteAddr, r.URL.Path) httputil.JSON(w, http.StatusServiceUnavailable, httputil.ErrorResponse{ Error: "service unavailable", }) @@ -168,7 +173,7 @@ func InternalAuth() func(http.Handler) http.Handler { // when tunneling). func internalAuth(inLambda bool) func(http.Handler) http.Handler { readSecret := platformSecretReader() - if expected, _ := readSecret(); expected == "" { + if _, _, configured := readSecret(); !configured { if inLambda { log.Printf("mirrorstack: WARNING — no platform secret set, all internal routes will be rejected") } else { @@ -178,11 +183,14 @@ func internalAuth(inLambda bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - expected, header := readSecret() + expected, header, configured := readSecret() if expected == "" { - if inLambda { - log.Printf("mirrorstack: internal auth rejected (no secret configured) from %s %s", r.RemoteAddr, r.URL.Path) + // Lambda misconfig OR a configured-but-unreadable token file → + // 503 (fail closed). Only the genuinely unconfigured local-dev + // state (configured=false, not in Lambda) bypasses. + if inLambda || configured { + log.Printf("mirrorstack: internal auth rejected (no secret configured/usable; source_configured=%v) from %s %s", configured, r.RemoteAddr, r.URL.Path) httputil.JSON(w, http.StatusServiceUnavailable, httputil.ErrorResponse{ Error: "service unavailable", }) @@ -244,6 +252,12 @@ func RequireProxy() func(http.Handler) http.Handler { // `mirrorstack dev` / `--tunnel` / self-hosted HTTP // path where dispatch forwards raw headers and injects // X-MS-Platform-Token. +// local + token file set → FAIL CLOSED: when MS_PLATFORM_TOKEN_FILE is set but +// but unreadable the file can't be read (deleted mid-rotation, bad +// perms, tmpfs full), the secret SOURCE is configured +// so the guard rejects (403 not_proxied) rather than +// silently going inert. A transient I/O error must +// never turn an enforcing guard into a no-op. // // CRITICAL: an accidentally-inert guard in production is a security hole; an // accidentally-active guard in standalone tests bricks every module's tests. @@ -260,14 +274,27 @@ func requireProxy(inLambda bool) func(http.Handler) http.Handler { return } - expected, header := readSecret() + expected, header, configured := readSecret() - // No token configured: inert (standalone unit tests). - if expected == "" { + // No token SOURCE configured: inert (standalone unit tests). + if !configured { next.ServeHTTP(w, r) return } + // Configured but the secret could not be read this call (e.g. the + // token file is missing, unreadable, or mid-rotation). Fail CLOSED: + // the guard was meant to enforce, so a transient I/O error must + // reject rather than silently pass every request through. + if expected == "" { + log.Printf("mirrorstack: proxy guard rejected (token source configured but unreadable) from %s %s", r.RemoteAddr, r.URL.Path) + httputil.JSON(w, http.StatusForbidden, httputil.ErrorResponse{ + Error: "request did not come through the platform proxy", + Code: CodeNotProxied, + }) + return + } + token := r.Header.Get(header) if !constantTimeEqual(token, expected) { log.Printf("mirrorstack: proxy guard rejected (token mismatch, header_present=%v) from %s %s", token != "", r.RemoteAddr, r.URL.Path) @@ -282,10 +309,23 @@ func requireProxy(inLambda bool) func(http.Handler) http.Handler { } } -// secretReader returns the current secret and which header carries it. -// The file-backed variant re-reads on every call so a refreshed token -// (CLI reconnect) is picked up without restarting the module process. -type secretReader func() (expected, header string) +// secretReader returns the current secret, which header carries it, and +// whether a secret SOURCE is configured at all. The file-backed variant +// re-reads on every call so a refreshed token (CLI reconnect) is picked up +// without restarting the module process. +// +// `configured` distinguishes two states that both yield an empty `expected`: +// +// - no source configured (no env var set) → configured=false +// - source configured but unreadable this call → configured=true, expected="" +// (e.g. MS_PLATFORM_TOKEN_FILE points at a file that is missing, +// unreadable, or mid-rotation) +// +// Guards MUST fail CLOSED on the second state: a configured-but-unreadable +// secret means the operator intended enforcement, so a transient I/O error +// must reject rather than silently make the guard inert. Only the genuinely +// unconfigured state (configured=false) takes the local-dev bypass path. +type secretReader func() (expected, header string, configured bool) // platformSecretReader builds the reader once at middleware construction. // - MS_PLATFORM_TOKEN_FILE set → read from file per call (dev) @@ -293,19 +333,35 @@ type secretReader func() (expected, header string) // - MS_INTERNAL_SECRET set → static, captured once (legacy) func platformSecretReader() secretReader { if file := os.Getenv("MS_PLATFORM_TOKEN_FILE"); file != "" { - return func() (string, string) { + return func() (string, string, bool) { data, err := os.ReadFile(file) if err != nil { - return "", HeaderPlatformToken + // Configured but unreadable: signal configured=true so guards + // fail closed instead of treating this as "no secret set". + log.Printf("mirrorstack: MS_PLATFORM_TOKEN_FILE %q read error: %v (failing closed)", file, err) + return "", HeaderPlatformToken, true } - return strings.TrimSpace(string(data)), HeaderPlatformToken + return strings.TrimSpace(string(data)), HeaderPlatformToken, true } } if v := os.Getenv("MS_PLATFORM_TOKEN"); v != "" { - return func() (string, string) { return v, HeaderPlatformToken } + return func() (string, string, bool) { return v, HeaderPlatformToken, true } } - v := os.Getenv("MS_INTERNAL_SECRET") - return func() (string, string) { return v, HeaderInternalSecret } + if v := os.Getenv("MS_INTERNAL_SECRET"); v != "" { + return func() (string, string, bool) { return v, HeaderInternalSecret, true } + } + return func() (string, string, bool) { return "", HeaderInternalSecret, false } +} + +// SecretConfigured reports whether any platform-secret source +// (MS_PLATFORM_TOKEN_FILE / MS_PLATFORM_TOKEN / MS_INTERNAL_SECRET) is set. +// It is the single source of truth for "are we in local-dev (no secret) mode", +// used by the SDK to gate local-dev-only behavior (e.g. permissive CORS) so +// that gate stays in lockstep with the auth/proxy guards' bypass branches +// rather than checking one specific env var name. +func SecretConfigured() bool { + _, _, configured := platformSecretReader()() + return configured } func constantTimeEqual(a, b string) bool { diff --git a/auth/middleware_test.go b/auth/middleware_test.go index 9c4aac3..9601bea 100644 --- a/auth/middleware_test.go +++ b/auth/middleware_test.go @@ -628,3 +628,38 @@ func TestRequireProxy_TokenFile_Refresh(t *testing.T) { t.Errorf("expected 403 after token rotation with no/old header, got %d", rec.Code) } } + +func TestRequireProxy_TokenFile_ReadError_FailsClosed(t *testing.T) { + // MS_PLATFORM_TOKEN_FILE is set (the operator intends enforcement) but the + // file can't be read (missing/deleted/bad perms/mid-rotation). The guard + // must FAIL CLOSED — 403 not_proxied — not silently pass through. A + // transient I/O error turning the guard inert would be a security hole. + missing := t.TempDir() + "/does-not-exist" + t.Setenv("MS_PLATFORM_TOKEN_FILE", missing) + handler := requireProxy(false)(http.HandlerFunc(okHandler)) + + // Even a request that carries SOME token header must be rejected: there is + // no readable expected value to match against, so nothing can be trusted. + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderPlatformToken, "anything") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 when token file is unreadable, got %d", rec.Code) + } + if body := rec.Body.String(); !strings.Contains(body, CodeNotProxied) { + t.Errorf("expected %q error code in body, got %q", CodeNotProxied, body) + } +} + +func TestRequireProxy_TokenFile_ReadError_NotMistakenForUnconfigured(t *testing.T) { + // Guards against a regression where a token-file read error collapses to + // the "no secret configured" path (configured=false) and bypasses. Here the + // file is configured but unreadable, so SecretConfigured stays true and the + // guard enforces. + missing := t.TempDir() + "/does-not-exist" + t.Setenv("MS_PLATFORM_TOKEN_FILE", missing) + if !SecretConfigured() { + t.Fatal("SecretConfigured() must be true when MS_PLATFORM_TOKEN_FILE is set even if unreadable") + } +} diff --git a/internal/core/module.go b/internal/core/module.go index 1ebfb24..30a1a72 100644 --- a/internal/core/module.go +++ b/internal/core/module.go @@ -105,6 +105,12 @@ type Module struct { // surfaces (auth.RequireProxy). Captured once at New() so every // Public/Platform registration reuses one closure, matching internalAuth. proxyGuard func(http.Handler) http.Handler + // platformAuth promotes trusted-forwarder identity headers on the platform + // surface (auth.PlatformAuth). Captured once at New() — same "captured + // once" contract as internalAuth/proxyGuard — so every Platform() + // registration reuses one closure instead of re-reading env + re-allocating + // the closure (and its secret snapshot) on each call. + platformAuth func(http.Handler) http.Handler poolCache *db.PoolCache // production: per-app DB pools devDBOnce sync.Once // dev mode: lazy DB init devDB *db.DB @@ -210,24 +216,32 @@ func New(cfg Config) (*Module, error) { contribStorage: contributions.NewStorage(cfg.ID), internalAuth: auth.InternalAuth(), proxyGuard: auth.RequireProxy(), + platformAuth: auth.PlatformAuth(), poolCache: db.NewPoolCache(), cacheCache: cache.NewClientCache(), taskHandlers: make(map[string]taskEntry), signingKey: []byte(os.Getenv("MS_TASK_SIGNING_KEY")), } - // Local-dev CORS: when MS_INTERNAL_SECRET is unset (i.e. the - // auth/PlatformAuth + InternalAuth bypass branch is active), echo - // the request Origin so a module's React bundle running on the + // Local-dev CORS: when NO platform-secret source is configured (i.e. the + // auth/PlatformAuth + InternalAuth + proxy-guard bypass branch is active), + // echo the request Origin so a module's React bundle running on the // platform's domain (e.g. localhost:3001) can fetch the module's // own /platform/* and /me endpoints cross-origin. Wildcard "*" // won't do — the bundle's api.ts uses credentials: 'include' for // the /me cookie flow, and browsers reject "*" with credentials. // - // Tunnel/prod (secret set) leaves CORS off entirely — bundles in - // those modes go through the platform proxy at same-origin, so + // Tunnel/prod (a secret is configured) leaves CORS off entirely — bundles + // in those modes go through the platform proxy at same-origin, so // cross-origin requests from random origins shouldn't be allowed. - if os.Getenv("MS_INTERNAL_SECRET") == "" { + // + // Gate on the FULL secret chain (auth.SecretConfigured), not just + // MS_INTERNAL_SECRET: with the MS_PLATFORM_TOKEN[_FILE] > MS_INTERNAL_SECRET + // hierarchy, a tunnel module may carry only MS_PLATFORM_TOKEN. Checking the + // old single var would attach permissive credentialed CORS in that + // enforcing mode — reflecting arbitrary origins, the exact surface this + // dev-only helper must never expose outside local dev. + if !auth.SecretConfigured() { m.router.Use(localDevCORS) } @@ -292,7 +306,7 @@ func (m *Module) Platform(fn func(r chi.Router)) { // headers. (PlatformAuth already validates the platform token on its own, // so the guard is belt-and-suspenders here, but keeping it makes the // public + platform surfaces enforce identically and auditably.) - m.scopedRoutes(registry.ScopePlatform, fn, m.proxyGuard, auth.PlatformAuth()) + m.scopedRoutes(registry.ScopePlatform, fn, m.proxyGuard, m.platformAuth) } // Public registers routes with public auth scope (anyone, including @@ -684,11 +698,15 @@ func parseTaskConcurrency() (int, error) { return n, nil } -// requireInternalSecret errors if MS_INTERNAL_SECRET is unset — used by -// Module.Start() in Lambda mode to fail init before lambda.Start handoff. +// requireInternalSecret errors if no platform-secret source is configured — +// used by Module.Start() in Lambda mode to fail init before lambda.Start +// handoff. It checks the full hierarchy (auth.SecretConfigured: +// MS_PLATFORM_TOKEN[_FILE] > MS_INTERNAL_SECRET) rather than a single var name, +// so a Lambda module configured with the preferred MS_PLATFORM_TOKEN key (which +// the auth guards already accept) is not blocked at Start. func requireInternalSecret() error { - if os.Getenv("MS_INTERNAL_SECRET") == "" { - return errors.New("mirrorstack: MS_INTERNAL_SECRET not set — required for platform routes in Lambda mode") + if !auth.SecretConfigured() { + return errors.New("mirrorstack: no platform secret set (MS_PLATFORM_TOKEN / MS_INTERNAL_SECRET) — required for platform routes in Lambda mode") } return nil } @@ -892,8 +910,10 @@ func StoredContributions(ctx context.Context, slot string) ([]contributions.Cont } // localDevCORS echoes the request Origin (with credentials) and answers -// OPTIONS preflights. Installed on the module router only when -// MS_INTERNAL_SECRET is unset — gated parallel to the auth bypasses. +// OPTIONS preflights. Installed on the module router only when no +// platform-secret source is configured (auth.SecretConfigured() == false) — +// gated parallel to the auth/proxy-guard bypasses so it is never active in +// tunnel/prod, regardless of which secret env var carries the token. func localDevCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if origin := r.Header.Get("Origin"); origin != "" { diff --git a/internal/core/module_test.go b/internal/core/module_test.go index 27998de..91a0ba7 100644 --- a/internal/core/module_test.go +++ b/internal/core/module_test.go @@ -506,7 +506,11 @@ func TestScopes_AutoMountUnderPrefix(t *testing.T) { suffix string needsAuth bool // Internal scope needs MS_INTERNAL_SECRET to reach the handler }{ - {registry.ScopePlatform, "GET", "/users", false}, // local-dev bypass injects synthetic admin + // No secret configured here, so the proxy guard is INERT and PlatformAuth + // takes the local-dev bypass (synthetic admin). This test only proves + // auto-mount under the prefix — guard ENFORCEMENT is covered by the + // TestPublic_ProxyGuard_* / TestRequireProxy_* tests, not here. + {registry.ScopePlatform, "GET", "/users", false}, {registry.ScopePublic, "GET", "/me", false}, {registry.ScopeInternal, "POST", "/sessions", true}, }