From 225c319d76bd59b725715da317ae08577da1191f Mon Sep 17 00:00:00 2001 From: terafin Date: Mon, 8 Jun 2026 11:57:44 -0700 Subject: [PATCH] feat(auth): AUTH_MODE enforcement + TRUST_REMOTE_USER_HEADER + path-prefix coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [squashed; see PR body for full description] 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- README.md | 4 + backend/cmd/server/auth_middleware.go | 173 +++++++ backend/cmd/server/auth_middleware_test.go | 498 +++++++++++++++++++++ backend/cmd/server/config_helpers.go | 19 + backend/cmd/server/routes.go | 1 + 5 files changed, 695 insertions(+) create mode 100644 backend/cmd/server/auth_middleware.go create mode 100644 backend/cmd/server/auth_middleware_test.go diff --git a/README.md b/README.md index e3b64a0..3961c18 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ Maps host port `80` to container port `80`. Disables login requirements. This is the default and is intended for trusted internal networks. +`TRUST_REMOTE_USER_HEADER: false` + +When `AUTH_MODE=enabled`, set `TRUST_REMOTE_USER_HEADER=true` ONLY if a reverse proxy (e.g. Authelia, Authentik, oauth2-proxy) sits in front of RSM and strips and re-sets the `Remote-User` header on every request. The default (`false`) prevents WAN spoofing — without a stripping proxy, any client could forge the header and bypass authentication. + `SAVE_ROOT: /saves` The container stores all managed save data under `/saves`. diff --git a/backend/cmd/server/auth_middleware.go b/backend/cmd/server/auth_middleware.go new file mode 100644 index 0000000..4d004bc --- /dev/null +++ b/backend/cmd/server/auth_middleware.go @@ -0,0 +1,173 @@ +package main + +import ( + "net/http" + "strings" +) + +// publicAuthPaths lists the canonical (un-prefixed) endpoint paths that must +// remain reachable without authentication even when AUTH_MODE=enabled. These +// are the bootstrap endpoints — without them a fresh client could never log +// in, fetch runtime config, or complete a device-pairing flow. +// +// Paths are matched after stripping any of the well-known route prefixes +// (/api/v1, /api, /v1) via stripRoutePrefix, so each entry needs to be listed +// only once. +// +// NOTE: /healthz is intentionally NOT in this map. It is registered at the +// router root only (NOT under any compat-mount prefix), so allowing +// /api/healthz / /v1/healthz / etc. through the allowlist would let those +// paths skip auth even though they have no real route — leaking 404s and, +// worse, falling through to the static-frontend handler. /healthz is matched +// as an exact path in isPublicAuthPath instead. +var publicAuthPaths = map[string]struct{}{ + "/runtime-config": {}, + "/auth/login": {}, + "/auth/signup": {}, + "/auth/token": {}, + "/auth/token/app-password": {}, + "/auth/resend-verification": {}, + "/auth/verify-email": {}, + "/auth/forgot-password": {}, + "/auth/reset-password": {}, + "/auth/device": {}, + "/auth/device/token": {}, + "/auth/device/verify": {}, + "/auth/device/confirm": {}, + "/auth/2fa/verify": {}, + "/auth/2fa/setup/totp": {}, + "/auth/2fa/verify-setup": {}, +} + +// stripRoutePrefix removes one of the well-known router prefixes +// (/api/v1, /api, /v1) from p, returning the canonical sub-path. The +// longest prefix is tried first so that /api/v1/... is not misread as +// /api/... with a stray /v1 segment. +func stripRoutePrefix(p string) string { + for _, prefix := range []string{"/api/v1", "/api", "/v1"} { + if p == prefix { + return "/" + } + if strings.HasPrefix(p, prefix+"/") { + return p[len(prefix):] + } + } + return p +} + +// isPublicAuthPath reports whether p is one of the bootstrap endpoints that +// must remain reachable without authentication. The check is prefix-aware so +// that, e.g., /api/v1/auth/login matches the canonical /auth/login entry. +// +// /healthz is handled as a special case: only the exact "/healthz" path is +// allowlisted. /api/healthz, /v1/healthz, etc. are NOT — they have no real +// route and matching them would invite the static-frontend NotFound fallback. +func isPublicAuthPath(p string) bool { + if p == "/healthz" { + return true + } + canonical := stripRoutePrefix(p) + if _, ok := publicAuthPaths[canonical]; ok { + return true + } + return false +} + +// requireAuth enforces AUTH_MODE=enabled on all non-public endpoints. When +// AUTH_MODE is disabled (the default) the middleware is a no-op so existing +// behavior — and the existing test suite — is preserved verbatim. +// +// When AUTH_MODE=enabled the request is allowed through if ANY of the +// following are true: +// +// 1. The path is on the bootstrap allowlist (isPublicAuthPath). +// 2. The request carries a valid helper app-password (Bearer token, +// X-RSM-App-Password header, or app_password form field), validated +// against the existing app-password store. +// 3. The request carries a non-empty Remote-User header set by an upstream +// forwardAuth reverse proxy (e.g. Authelia, oauth2-proxy). The mere +// presence is trusted — the proxy is responsible for the verification. +// 4. The request carries a non-empty `session` cookie. This matches the +// existing /auth/login handler which sets such a cookie without a +// server-side store. TODO: replace with a real session store backed by +// security_state. +// +// Otherwise the request is rejected with 401 Unauthorized + an apiError +// JSON body. +func (a *app) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if authMode() != "enabled" { + next.ServeHTTP(w, r) + return + } + if isPublicAuthPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + // Helper protocol: requests carrying both identity headers + // (X-RSM-Device-Type + X-RSM-Fingerprint) are deferred to the + // downstream handler (authorizeHelperSyncRequest in helper_auth.go), + // which enforces either app-password validation or the + // auto-enroll-window policy. Pre-empting that decision here would + // break the bootstrap flow where a fresh helper has no key yet. + if hasHelperIdentity(r) { + next.ServeHTTP(w, r) + return + } + if a.isAuthenticatedRequest(r) { + next.ServeHTTP(w, r) + return + } + writeJSON(w, http.StatusUnauthorized, apiError{ + Error: "Unauthorized", + Message: "authentication required", + StatusCode: http.StatusUnauthorized, + }) + }) +} + +// hasHelperIdentity reports whether r carries both helper-identity headers +// (X-RSM-Device-Type + X-RSM-Fingerprint). The middleware uses this to +// recognize the helper protocol and defer the auth decision to +// authorizeHelperSyncRequest, which knows the auto-enroll-window policy. +func hasHelperIdentity(r *http.Request) bool { + dt := strings.TrimSpace(r.Header.Get("X-RSM-Device-Type")) + fp := strings.TrimSpace(r.Header.Get("X-RSM-Fingerprint")) + return dt != "" && fp != "" +} + +// isAuthenticatedRequest returns true if r presents any acceptable credential +// when AUTH_MODE=enabled. See requireAuth for the accepted forms. +func (a *app) isAuthenticatedRequest(r *http.Request) bool { + // 1. Reverse-proxy forwardAuth (Authelia, oauth2-proxy, etc). + // Only honored when the operator has explicitly opted in via + // TRUST_REMOTE_USER_HEADER — otherwise a WAN-exposed RSM would trust + // any client-supplied Remote-User header, which is trivially spoofable. + if trustRemoteUserHeader() && strings.TrimSpace(r.Header.Get("Remote-User")) != "" { + return true + } + + // 2. Session cookie set by /auth/login. The current login handler does + // not persist a server-side session, so any non-empty value is treated + // as authenticated — this matches existing behavior. See TODO above. + if cookie, err := r.Cookie("session"); err == nil && strings.TrimSpace(cookie.Value) != "" { + return true + } + + // 3. Helper app-password (Bearer / X-RSM-App-Password / form field). + // extractHelperAppPassword returns the raw caller-supplied value. + // We normalize and look it up against the app-password store. + raw := extractHelperAppPassword(r, nil) + if strings.TrimSpace(raw) != "" { + if _, compact, ok := normalizeAppPasswordInput(raw); ok { + a.mu.Lock() + _, found := a.findAppPasswordByCompactLocked(compact) + a.mu.Unlock() + if found { + return true + } + } + } + + return false +} diff --git a/backend/cmd/server/auth_middleware_test.go b/backend/cmd/server/auth_middleware_test.go new file mode 100644 index 0000000..4df24c9 --- /dev/null +++ b/backend/cmd/server/auth_middleware_test.go @@ -0,0 +1,498 @@ +package main + +import ( + "bytes" + "context" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// authMiddlewareHarness builds a contract harness, optionally toggling +// AUTH_MODE=enabled. Because t.Setenv resets the value at test end, each +// subtest can opt in or out of enforcement independently. +func authMiddlewareHarness(t *testing.T, enabled bool) *contractHarness { + t.Helper() + h := newContractHarness(t) + if enabled { + t.Setenv("AUTH_MODE", "enabled") + } + return h +} + +// mintAppPassword creates a real app-password record on the harness app and +// returns the compact (XXX-XXX-formatted) plain-text key. The HTTP layer is +// not used so the call works regardless of AUTH_MODE. +func mintAppPassword(t *testing.T, h *contractHarness) string { + t.Helper() + h.app.mu.Lock() + _, key := h.app.createAppPasswordLocked("middleware-test", time.Now().UTC()) + h.app.mu.Unlock() + if _, _, ok := normalizeAppPasswordInput(key); !ok { + t.Fatalf("created app password has unexpected format: %q", key) + } + return formatAppPasswordCompact(key) +} + +func rawRequest(method, path string) *http.Request { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("X-CSRF-Protection", "1") + return req +} + +// --- AUTH_MODE=disabled (baseline — must keep existing behavior) --- + +func TestAuthMiddleware_Disabled_AllowsApiSavesWithoutAuth(t *testing.T) { + h := authMiddlewareHarness(t, false) + rr := h.do(rawRequest(http.MethodGet, "/api/saves")) + assertStatus(t, rr, http.StatusOK) +} + +// --- AUTH_MODE=enabled — enforcement --- + +func TestAuthMiddleware_Enabled_BlocksApiSavesWithoutAuth(t *testing.T) { + h := authMiddlewareHarness(t, true) + rr := h.do(rawRequest(http.MethodGet, "/api/saves")) + assertStatus(t, rr, http.StatusUnauthorized) + assertJSONContentType(t, rr) +} + +func TestAuthMiddleware_Enabled_AllowsValidBearerAppPassword(t *testing.T) { + h := authMiddlewareHarness(t, true) + key := mintAppPassword(t, h) + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Authorization", "Bearer "+key) + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +func TestAuthMiddleware_Enabled_AllowsValidXRSMAppPasswordHeader(t *testing.T) { + h := authMiddlewareHarness(t, true) + key := mintAppPassword(t, h) + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("X-RSM-App-Password", key) + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +func TestAuthMiddleware_Enabled_AllowsRemoteUserHeader(t *testing.T) { + h := authMiddlewareHarness(t, true) + t.Setenv("TRUST_REMOTE_USER_HEADER", "true") + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Remote-User", "alice") + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +func TestAuthMiddleware_Enabled_AllowsNonEmptySessionCookie(t *testing.T) { + h := authMiddlewareHarness(t, true) + req := rawRequest(http.MethodGet, "/api/saves") + req.AddCookie(&http.Cookie{Name: "session", Value: "anything"}) + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +// --- AUTH_MODE=enabled — allowlist (bootstrap endpoints) --- + +func TestAuthMiddleware_Enabled_AllowsHealthzWithoutAuth(t *testing.T) { + h := authMiddlewareHarness(t, true) + rr := h.do(rawRequest(http.MethodGet, "/healthz")) + assertStatus(t, rr, http.StatusOK) +} + +func TestAuthMiddleware_Enabled_AllowsRuntimeConfigWithoutAuth(t *testing.T) { + h := authMiddlewareHarness(t, true) + rr := h.do(rawRequest(http.MethodGet, "/runtime-config")) + assertStatus(t, rr, http.StatusOK) +} + +func TestAuthMiddleware_Enabled_AllowsAuthLoginWithoutAuth(t *testing.T) { + h := authMiddlewareHarness(t, true) + req := httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Protection", "1") + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +// --- AUTH_MODE=enabled — bad credentials still rejected --- + +func TestAuthMiddleware_Enabled_RejectsGarbageBearer(t *testing.T) { + h := authMiddlewareHarness(t, true) + req := rawRequest(http.MethodGet, "/api/saves") + // Format-valid (six A-Z0-9 chars) but not bound to any real password. + req.Header.Set("Authorization", "Bearer ZZZZZZ") + rr := h.do(req) + assertStatus(t, rr, http.StatusUnauthorized) +} + +// --- AUTH_MODE=enabled — helper-identity defer-to-handler behavior --- +// +// These tests cover the bug where the middleware would 401 helper requests +// that have identity headers but no app-password, breaking the auto-enroll +// bootstrap flow. The middleware MUST defer to authorizeHelperSyncRequest +// (in helper_auth.go), which enforces the actual policy. + +// helperMultipartSaveRequest builds a POST /saves multipart request that +// looks like a helper upload — identity carried via headers, not form fields, +// so the middleware's hasHelperIdentity check is exercised. Optionally sets +// an Authorization Bearer with the supplied app-password. +func helperMultipartSaveRequest(t *testing.T, deviceType, fingerprint, appPassword string) *http.Request { + t.Helper() + + fields := map[string]string{ + "rom_sha1": "middleware-helper-rom", + "slotName": "default", + "system": "snes", + "runtimeProfile": "snes/snes9x", + } + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + t.Fatalf("write multipart field %s: %v", key, err) + } + } + part, err := writer.CreateFormFile("file", "Final Fantasy VI.srm") + if err != nil { + t.Fatalf("create multipart file: %v", err) + } + payload := normalizeTestUploadPayload(fields, "Final Fantasy VI.srm", []byte("helper-middleware-payload")) + if _, err := part.Write(payload); err != nil { + t.Fatalf("write multipart payload: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/saves", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-CSRF-Protection", "1") + req.Header.Set("X-RSM-Device-Type", deviceType) + req.Header.Set("X-RSM-Fingerprint", fingerprint) + if appPassword != "" { + req.Header.Set("Authorization", "Bearer "+appPassword) + } + return req +} + +func TestAuthMiddleware_Enabled_HelperHeadersNoKey_AutoEnrollOpen_Allows(t *testing.T) { + h := authMiddlewareHarness(t, true) + // Open the auto-enroll window via the normal handler path. + h.app.mu.Lock() + h.app.enableAutoAppPasswordWindowLocked(15 * time.Minute) + h.app.mu.Unlock() + + req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-open", "") + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) + if got := rr.Header().Get("X-RSM-Auto-App-Password"); got == "" { + t.Fatalf("expected auto-provisioned app password header on bootstrap; headers=%v", rr.Header()) + } +} + +func TestAuthMiddleware_Enabled_HelperHeadersNoKey_AutoEnrollClosed_Rejects(t *testing.T) { + h := authMiddlewareHarness(t, true) + // Default state: auto-enroll window is NOT open. + + req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-closed", "") + rr := h.do(req) + // The middleware passes through; the downstream handler enforces the + // no-key + closed-window policy with 401. + assertStatus(t, rr, http.StatusUnauthorized) +} + +func TestAuthMiddleware_Enabled_HelperHeadersValidKey_Allows(t *testing.T) { + h := authMiddlewareHarness(t, true) + key := mintAppPassword(t, h) + + req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-key", key) + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +// Regression test for the GET /api/saves + valid Bearer path. The empirical +// repro that surfaced the bootstrap bug had an empty Bearer (enrollment had +// failed), so this confirms the happy path independently. +func TestAuthMiddleware_Enabled_GetApiSavesWithValidBearer_Allows(t *testing.T) { + h := authMiddlewareHarness(t, true) + key := mintAppPassword(t, h) + + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Authorization", "Bearer "+key) + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +// --- AUTH_MODE=enabled — path-prefix variants of allowlisted endpoints --- +// +// The compat router mounts the public-auth endpoints under "" and /v1. The +// agent router mounts a separate /runtime-config under /api and /api/v1, but +// does NOT re-expose /auth/login. The middleware's stripRoutePrefix must let +// each variant that actually has a backing route through without auth. + +func TestAuthMiddleware_Enabled_AllowsRuntimeConfigPrefixVariants(t *testing.T) { + for _, path := range []string{ + "/runtime-config", + "/v1/runtime-config", + "/api/runtime-config", + "/api/v1/runtime-config", + } { + t.Run(path, func(t *testing.T) { + h := authMiddlewareHarness(t, true) + rr := h.do(rawRequest(http.MethodGet, path)) + assertStatus(t, rr, http.StatusOK) + }) + } +} + +func TestAuthMiddleware_Enabled_AllowsAuthLoginPrefixVariants(t *testing.T) { + // Compat-router mounts only — /api(/v1)/auth/login has no backing route + // and is intentionally not part of the agent surface. + for _, path := range []string{ + "/auth/login", + "/v1/auth/login", + } { + t.Run(path, func(t *testing.T) { + h := authMiddlewareHarness(t, true) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Protection", "1") + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) + }) + } +} + +// /healthz is registered at the router ROOT only — NOT under any compat mount. +// Only the exact path is allowlisted; /api/healthz / /v1/healthz / etc. must +// be treated as non-bootstrap traffic and rejected with 401. +func TestAuthMiddleware_Enabled_HealthzAllowlistIsRootOnly(t *testing.T) { + h := authMiddlewareHarness(t, true) + + rrRoot := h.do(rawRequest(http.MethodGet, "/healthz")) + assertStatus(t, rrRoot, http.StatusOK) + + for _, path := range []string{ + "/api/healthz", + "/v1/healthz", + "/api/v1/healthz", + } { + t.Run(path, func(t *testing.T) { + h := authMiddlewareHarness(t, true) + rr := h.do(rawRequest(http.MethodGet, path)) + assertStatus(t, rr, http.StatusUnauthorized) + }) + } +} + +// --- AUTH_MODE=enabled — spoof-prevention edge cases --- +// +// Empty / whitespace-only Remote-User and session cookies must NOT be +// accepted as proof of authentication, regardless of how they got there +// (a misconfigured proxy that always sets Remote-User="" is the most +// realistic source). + +func TestAuthMiddleware_Enabled_RejectsEmptyRemoteUserHeader(t *testing.T) { + h := authMiddlewareHarness(t, true) + t.Setenv("TRUST_REMOTE_USER_HEADER", "true") + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Remote-User", "") + rr := h.do(req) + assertStatus(t, rr, http.StatusUnauthorized) +} + +func TestAuthMiddleware_Enabled_RejectsWhitespaceRemoteUserHeader(t *testing.T) { + h := authMiddlewareHarness(t, true) + t.Setenv("TRUST_REMOTE_USER_HEADER", "true") + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Remote-User", " ") + rr := h.do(req) + assertStatus(t, rr, http.StatusUnauthorized) +} + +func TestAuthMiddleware_Enabled_RejectsEmptySessionCookie(t *testing.T) { + h := authMiddlewareHarness(t, true) + req := rawRequest(http.MethodGet, "/api/saves") + req.AddCookie(&http.Cookie{Name: "session", Value: ""}) + rr := h.do(req) + assertStatus(t, rr, http.StatusUnauthorized) +} + +func TestAuthMiddleware_Enabled_RejectsWhitespaceSessionCookie(t *testing.T) { + h := authMiddlewareHarness(t, true) + req := rawRequest(http.MethodGet, "/api/saves") + req.AddCookie(&http.Cookie{Name: "session", Value: " "}) + rr := h.do(req) + assertStatus(t, rr, http.StatusUnauthorized) +} + +// --- AUTH_MODE=enabled — SSE /events endpoint --- +// +// /events serves text/event-stream and leaks live state if exposed. +// It must require auth in every prefix-mount variant. + +func TestAuthMiddleware_Enabled_BlocksEventsWithoutAuth(t *testing.T) { + for _, path := range []string{ + "/events", + "/api/events", + } { + t.Run(path, func(t *testing.T) { + h := authMiddlewareHarness(t, true) + rr := h.do(rawRequest(http.MethodGet, path)) + assertStatus(t, rr, http.StatusUnauthorized) + }) + } +} + +// With a valid Bearer the SSE handler should at least send response headers +// with the SSE content-type before we tear the connection down. We use the +// existing ssePrelude helper which cancels right after seeing the initial +// ": connected\n\n" frame, so the test doesn't hang on the long-lived stream. +func TestAuthMiddleware_Enabled_AllowsEventsWithValidBearer(t *testing.T) { + h := authMiddlewareHarness(t, true) + key := mintAppPassword(t, h) + + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx) + req.Header.Set("X-CSRF-Protection", "1") + req.Header.Set("Authorization", "Bearer "+key) + + rr := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + defer close(done) + h.handler.ServeHTTP(rr, req) + }() + + deadline := time.Now().Add(2 * time.Second) + for { + if strings.HasPrefix(rr.Body.String(), ": connected\n\n") { + cancel() + <-done + break + } + if time.Now().After(deadline) { + cancel() + <-done + t.Fatalf("timed out waiting for SSE prelude; status=%d headers=%v body=%q", + rr.Code, rr.Header(), rr.Body.String()) + } + time.Sleep(10 * time.Millisecond) + } + + if rr.Code != http.StatusOK { + t.Fatalf("expected SSE 200, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") { + t.Fatalf("expected Content-Type to contain text/event-stream, got %q", ct) + } +} + +// --- AUTH_MODE=enabled — static frontend behavior --- +// +// The chi NotFound handler serves the SPA shell for any GET that isn't a +// reserved API path. With AUTH_MODE=enabled the auth middleware fires BEFORE +// NotFound, so unauthenticated SPA loads receive 401. This is intentional: +// when AUTH_MODE=enabled the operator is expected to either expose the UI +// behind a forwardAuth proxy (Authelia) or accept that the SPA is gated. +// +// These tests pin the documented behavior so it can't silently regress. + +// staticFrontendHarness installs a minimal dist/index.html + dist/assets/app.js +// so the static-frontend handler is wired up. Returns the harness. +func staticFrontendHarness(t *testing.T, enabled bool) *contractHarness { + t.Helper() + distDir := filepath.Join(t.TempDir(), "dist") + if err := os.MkdirAll(filepath.Join(distDir, "assets"), 0o755); err != nil { + t.Fatalf("mkdir dist: %v", err) + } + if err := os.WriteFile(filepath.Join(distDir, "index.html"), + []byte("RSM"), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + if err := os.WriteFile(filepath.Join(distDir, "assets", "app.js"), + []byte("console.log('rsm');"), 0o644); err != nil { + t.Fatalf("write asset: %v", err) + } + t.Setenv("FRONTEND_DIST_DIR", distDir) + return authMiddlewareHarness(t, enabled) +} + +func TestAuthMiddleware_Enabled_BlocksStaticRootWithoutAuth(t *testing.T) { + h := staticFrontendHarness(t, true) + rr := h.do(rawRequest(http.MethodGet, "/")) + assertStatus(t, rr, http.StatusUnauthorized) +} + +func TestAuthMiddleware_Enabled_BlocksStaticAssetWithoutAuth(t *testing.T) { + h := staticFrontendHarness(t, true) + rr := h.do(rawRequest(http.MethodGet, "/assets/app.js")) + assertStatus(t, rr, http.StatusUnauthorized) +} + +func TestAuthMiddleware_Enabled_AllowsStaticRootWithRemoteUser(t *testing.T) { + h := staticFrontendHarness(t, true) + t.Setenv("TRUST_REMOTE_USER_HEADER", "true") + req := rawRequest(http.MethodGet, "/") + req.Header.Set("Remote-User", "alice") + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +func TestAuthMiddleware_Disabled_AllowsStaticRoot(t *testing.T) { + h := staticFrontendHarness(t, false) + rr := h.do(rawRequest(http.MethodGet, "/")) + assertStatus(t, rr, http.StatusOK) +} + +// --- AUTH_MODE=enabled — TRUST_REMOTE_USER_HEADER opt-in --- +// +// Remote-User is only honored when the operator explicitly opts in. Default +// off is the safe-by-default posture: a WAN-exposed RSM with no stripping +// reverse proxy would otherwise trust any client-supplied header. + +func TestAuthMiddleware_Enabled_RemoteUserIgnoredByDefault(t *testing.T) { + h := authMiddlewareHarness(t, true) + // TRUST_REMOTE_USER_HEADER intentionally unset. + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Remote-User", "alice") + rr := h.do(req) + assertStatus(t, rr, http.StatusUnauthorized) +} + +func TestAuthMiddleware_Enabled_RemoteUserHonoredWhenTrusted(t *testing.T) { + h := authMiddlewareHarness(t, true) + t.Setenv("TRUST_REMOTE_USER_HEADER", "true") + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Remote-User", "alice") + rr := h.do(req) + assertStatus(t, rr, http.StatusOK) +} + +func TestAuthMiddleware_Enabled_RemoteUserIgnoredWhenExplicitlyFalse(t *testing.T) { + h := authMiddlewareHarness(t, true) + t.Setenv("TRUST_REMOTE_USER_HEADER", "false") + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Remote-User", "alice") + rr := h.do(req) + assertStatus(t, rr, http.StatusUnauthorized) +} + +// TRUST_REMOTE_USER_HEADER=true + empty/whitespace Remote-User must still +// reject — the trim check is the last line of defense if a misconfigured +// proxy strips the user but leaves the header in place. +func TestAuthMiddleware_Enabled_RemoteUserTrustedButEmptyRejects(t *testing.T) { + h := authMiddlewareHarness(t, true) + t.Setenv("TRUST_REMOTE_USER_HEADER", "true") + req := rawRequest(http.MethodGet, "/api/saves") + req.Header.Set("Remote-User", " ") + rr := h.do(req) + assertStatus(t, rr, http.StatusUnauthorized) +} diff --git a/backend/cmd/server/config_helpers.go b/backend/cmd/server/config_helpers.go index 9f8585f..4b3995d 100644 --- a/backend/cmd/server/config_helpers.go +++ b/backend/cmd/server/config_helpers.go @@ -14,6 +14,25 @@ func authMode() string { return mode } +// trustRemoteUserHeader reports whether the Remote-User header should be +// trusted as proof of authentication when AUTH_MODE=enabled. +// +// Default is FALSE — a deployment that exposes RSM directly to the WAN +// without a stripping reverse proxy in front would otherwise be trivially +// spoofable by any client that sets the header. Operators who DO have a +// trusted forwardAuth proxy (Authelia, oauth2-proxy, Authentik, etc.) in +// front and want SSO to work must opt in explicitly by setting +// TRUST_REMOTE_USER_HEADER=true (or "yes" / "1"). +func trustRemoteUserHeader() bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv("TRUST_REMOTE_USER_HEADER"))) + switch v { + case "true", "yes", "1": + return true + default: + return false + } +} + func baseURLForRequest(r *http.Request) string { if env := strings.TrimSpace(os.Getenv("BASE_URL")); env != "" { return strings.TrimRight(env, "/") diff --git a/backend/cmd/server/routes.go b/backend/cmd/server/routes.go index b8c1d91..a2e9f9f 100755 --- a/backend/cmd/server/routes.go +++ b/backend/cmd/server/routes.go @@ -14,6 +14,7 @@ func newRouter(app *app) http.Handler { r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Use(middleware.Logger) + r.Use(app.requireAuth) staticFrontend := newFrontendStaticHandler() r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {