Skip to content

Commit a9ffce2

Browse files
committed
feat(auth): TRUST_REMOTE_USER_HEADER toggle, path-prefix coverage, spoof-resistance, SSE+static tests
1 parent 8d26dcd commit a9ffce2

4 files changed

Lines changed: 312 additions & 2 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ Maps host port `80` to container port `80`.
7272

7373
Disables login requirements. This is the default and is intended for trusted internal networks.
7474

75+
`TRUST_REMOTE_USER_HEADER: false`
76+
77+
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.
78+
7579
`SAVE_ROOT: /saves`
7680

7781
The container stores all managed save data under `/saves`.

backend/cmd/server/auth_middleware.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@ import (
1313
// Paths are matched after stripping any of the well-known route prefixes
1414
// (/api/v1, /api, /v1) via stripRoutePrefix, so each entry needs to be listed
1515
// only once.
16+
//
17+
// NOTE: /healthz is intentionally NOT in this map. It is registered at the
18+
// router root only (NOT under any compat-mount prefix), so allowing
19+
// /api/healthz / /v1/healthz / etc. through the allowlist would let those
20+
// paths skip auth even though they have no real route — leaking 404s and,
21+
// worse, falling through to the static-frontend handler. /healthz is matched
22+
// as an exact path in isPublicAuthPath instead.
1623
var publicAuthPaths = map[string]struct{}{
17-
"/healthz": {},
1824
"/runtime-config": {},
1925
"/auth/login": {},
2026
"/auth/signup": {},
@@ -52,7 +58,14 @@ func stripRoutePrefix(p string) string {
5258
// isPublicAuthPath reports whether p is one of the bootstrap endpoints that
5359
// must remain reachable without authentication. The check is prefix-aware so
5460
// that, e.g., /api/v1/auth/login matches the canonical /auth/login entry.
61+
//
62+
// /healthz is handled as a special case: only the exact "/healthz" path is
63+
// allowlisted. /api/healthz, /v1/healthz, etc. are NOT — they have no real
64+
// route and matching them would invite the static-frontend NotFound fallback.
5565
func isPublicAuthPath(p string) bool {
66+
if p == "/healthz" {
67+
return true
68+
}
5669
canonical := stripRoutePrefix(p)
5770
if _, ok := publicAuthPaths[canonical]; ok {
5871
return true
@@ -127,7 +140,10 @@ func hasHelperIdentity(r *http.Request) bool {
127140
// when AUTH_MODE=enabled. See requireAuth for the accepted forms.
128141
func (a *app) isAuthenticatedRequest(r *http.Request) bool {
129142
// 1. Reverse-proxy forwardAuth (Authelia, oauth2-proxy, etc).
130-
if strings.TrimSpace(r.Header.Get("Remote-User")) != "" {
143+
// Only honored when the operator has explicitly opted in via
144+
// TRUST_REMOTE_USER_HEADER — otherwise a WAN-exposed RSM would trust
145+
// any client-supplied Remote-User header, which is trivially spoofable.
146+
if trustRemoteUserHeader() && strings.TrimSpace(r.Header.Get("Remote-User")) != "" {
131147
return true
132148
}
133149

backend/cmd/server/auth_middleware_test.go

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package main
22

33
import (
44
"bytes"
5+
"context"
56
"mime/multipart"
67
"net/http"
78
"net/http/httptest"
9+
"os"
10+
"path/filepath"
811
"strings"
912
"testing"
1013
"time"
@@ -79,6 +82,7 @@ func TestAuthMiddleware_Enabled_AllowsValidXRSMAppPasswordHeader(t *testing.T) {
7982

8083
func TestAuthMiddleware_Enabled_AllowsRemoteUserHeader(t *testing.T) {
8184
h := authMiddlewareHarness(t, true)
85+
t.Setenv("TRUST_REMOTE_USER_HEADER", "true")
8286
req := rawRequest(http.MethodGet, "/api/saves")
8387
req.Header.Set("Remote-User", "alice")
8488
rr := h.do(req)
@@ -225,3 +229,270 @@ func TestAuthMiddleware_Enabled_GetApiSavesWithValidBearer_Allows(t *testing.T)
225229
rr := h.do(req)
226230
assertStatus(t, rr, http.StatusOK)
227231
}
232+
233+
// --- AUTH_MODE=enabled — path-prefix variants of allowlisted endpoints ---
234+
//
235+
// The compat router mounts the public-auth endpoints under "" and /v1. The
236+
// agent router mounts a separate /runtime-config under /api and /api/v1, but
237+
// does NOT re-expose /auth/login. The middleware's stripRoutePrefix must let
238+
// each variant that actually has a backing route through without auth.
239+
240+
func TestAuthMiddleware_Enabled_AllowsRuntimeConfigPrefixVariants(t *testing.T) {
241+
for _, path := range []string{
242+
"/runtime-config",
243+
"/v1/runtime-config",
244+
"/api/runtime-config",
245+
"/api/v1/runtime-config",
246+
} {
247+
t.Run(path, func(t *testing.T) {
248+
h := authMiddlewareHarness(t, true)
249+
rr := h.do(rawRequest(http.MethodGet, path))
250+
assertStatus(t, rr, http.StatusOK)
251+
})
252+
}
253+
}
254+
255+
func TestAuthMiddleware_Enabled_AllowsAuthLoginPrefixVariants(t *testing.T) {
256+
// Compat-router mounts only — /api(/v1)/auth/login has no backing route
257+
// and is intentionally not part of the agent surface.
258+
for _, path := range []string{
259+
"/auth/login",
260+
"/v1/auth/login",
261+
} {
262+
t.Run(path, func(t *testing.T) {
263+
h := authMiddlewareHarness(t, true)
264+
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
265+
req.Header.Set("Content-Type", "application/json")
266+
req.Header.Set("X-CSRF-Protection", "1")
267+
rr := h.do(req)
268+
assertStatus(t, rr, http.StatusOK)
269+
})
270+
}
271+
}
272+
273+
// /healthz is registered at the router ROOT only — NOT under any compat mount.
274+
// Only the exact path is allowlisted; /api/healthz / /v1/healthz / etc. must
275+
// be treated as non-bootstrap traffic and rejected with 401.
276+
func TestAuthMiddleware_Enabled_HealthzAllowlistIsRootOnly(t *testing.T) {
277+
h := authMiddlewareHarness(t, true)
278+
279+
rrRoot := h.do(rawRequest(http.MethodGet, "/healthz"))
280+
assertStatus(t, rrRoot, http.StatusOK)
281+
282+
for _, path := range []string{
283+
"/api/healthz",
284+
"/v1/healthz",
285+
"/api/v1/healthz",
286+
} {
287+
t.Run(path, func(t *testing.T) {
288+
h := authMiddlewareHarness(t, true)
289+
rr := h.do(rawRequest(http.MethodGet, path))
290+
assertStatus(t, rr, http.StatusUnauthorized)
291+
})
292+
}
293+
}
294+
295+
// --- AUTH_MODE=enabled — spoof-prevention edge cases ---
296+
//
297+
// Empty / whitespace-only Remote-User and session cookies must NOT be
298+
// accepted as proof of authentication, regardless of how they got there
299+
// (a misconfigured proxy that always sets Remote-User="" is the most
300+
// realistic source).
301+
302+
func TestAuthMiddleware_Enabled_RejectsEmptyRemoteUserHeader(t *testing.T) {
303+
h := authMiddlewareHarness(t, true)
304+
t.Setenv("TRUST_REMOTE_USER_HEADER", "true")
305+
req := rawRequest(http.MethodGet, "/api/saves")
306+
req.Header.Set("Remote-User", "")
307+
rr := h.do(req)
308+
assertStatus(t, rr, http.StatusUnauthorized)
309+
}
310+
311+
func TestAuthMiddleware_Enabled_RejectsWhitespaceRemoteUserHeader(t *testing.T) {
312+
h := authMiddlewareHarness(t, true)
313+
t.Setenv("TRUST_REMOTE_USER_HEADER", "true")
314+
req := rawRequest(http.MethodGet, "/api/saves")
315+
req.Header.Set("Remote-User", " ")
316+
rr := h.do(req)
317+
assertStatus(t, rr, http.StatusUnauthorized)
318+
}
319+
320+
func TestAuthMiddleware_Enabled_RejectsEmptySessionCookie(t *testing.T) {
321+
h := authMiddlewareHarness(t, true)
322+
req := rawRequest(http.MethodGet, "/api/saves")
323+
req.AddCookie(&http.Cookie{Name: "session", Value: ""})
324+
rr := h.do(req)
325+
assertStatus(t, rr, http.StatusUnauthorized)
326+
}
327+
328+
func TestAuthMiddleware_Enabled_RejectsWhitespaceSessionCookie(t *testing.T) {
329+
h := authMiddlewareHarness(t, true)
330+
req := rawRequest(http.MethodGet, "/api/saves")
331+
req.AddCookie(&http.Cookie{Name: "session", Value: " "})
332+
rr := h.do(req)
333+
assertStatus(t, rr, http.StatusUnauthorized)
334+
}
335+
336+
// --- AUTH_MODE=enabled — SSE /events endpoint ---
337+
//
338+
// /events serves text/event-stream and leaks live state if exposed.
339+
// It must require auth in every prefix-mount variant.
340+
341+
func TestAuthMiddleware_Enabled_BlocksEventsWithoutAuth(t *testing.T) {
342+
for _, path := range []string{
343+
"/events",
344+
"/api/events",
345+
} {
346+
t.Run(path, func(t *testing.T) {
347+
h := authMiddlewareHarness(t, true)
348+
rr := h.do(rawRequest(http.MethodGet, path))
349+
assertStatus(t, rr, http.StatusUnauthorized)
350+
})
351+
}
352+
}
353+
354+
// With a valid Bearer the SSE handler should at least send response headers
355+
// with the SSE content-type before we tear the connection down. We use the
356+
// existing ssePrelude helper which cancels right after seeing the initial
357+
// ": connected\n\n" frame, so the test doesn't hang on the long-lived stream.
358+
func TestAuthMiddleware_Enabled_AllowsEventsWithValidBearer(t *testing.T) {
359+
h := authMiddlewareHarness(t, true)
360+
key := mintAppPassword(t, h)
361+
362+
ctx, cancel := context.WithCancel(context.Background())
363+
req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx)
364+
req.Header.Set("X-CSRF-Protection", "1")
365+
req.Header.Set("Authorization", "Bearer "+key)
366+
367+
rr := httptest.NewRecorder()
368+
done := make(chan struct{})
369+
go func() {
370+
defer close(done)
371+
h.handler.ServeHTTP(rr, req)
372+
}()
373+
374+
deadline := time.Now().Add(2 * time.Second)
375+
for {
376+
if strings.HasPrefix(rr.Body.String(), ": connected\n\n") {
377+
cancel()
378+
<-done
379+
break
380+
}
381+
if time.Now().After(deadline) {
382+
cancel()
383+
<-done
384+
t.Fatalf("timed out waiting for SSE prelude; status=%d headers=%v body=%q",
385+
rr.Code, rr.Header(), rr.Body.String())
386+
}
387+
time.Sleep(10 * time.Millisecond)
388+
}
389+
390+
if rr.Code != http.StatusOK {
391+
t.Fatalf("expected SSE 200, got %d", rr.Code)
392+
}
393+
if ct := rr.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
394+
t.Fatalf("expected Content-Type to contain text/event-stream, got %q", ct)
395+
}
396+
}
397+
398+
// --- AUTH_MODE=enabled — static frontend behavior ---
399+
//
400+
// The chi NotFound handler serves the SPA shell for any GET that isn't a
401+
// reserved API path. With AUTH_MODE=enabled the auth middleware fires BEFORE
402+
// NotFound, so unauthenticated SPA loads receive 401. This is intentional:
403+
// when AUTH_MODE=enabled the operator is expected to either expose the UI
404+
// behind a forwardAuth proxy (Authelia) or accept that the SPA is gated.
405+
//
406+
// These tests pin the documented behavior so it can't silently regress.
407+
408+
// staticFrontendHarness installs a minimal dist/index.html + dist/assets/app.js
409+
// so the static-frontend handler is wired up. Returns the harness.
410+
func staticFrontendHarness(t *testing.T, enabled bool) *contractHarness {
411+
t.Helper()
412+
distDir := filepath.Join(t.TempDir(), "dist")
413+
if err := os.MkdirAll(filepath.Join(distDir, "assets"), 0o755); err != nil {
414+
t.Fatalf("mkdir dist: %v", err)
415+
}
416+
if err := os.WriteFile(filepath.Join(distDir, "index.html"),
417+
[]byte("<!doctype html><html><body>RSM</body></html>"), 0o644); err != nil {
418+
t.Fatalf("write index: %v", err)
419+
}
420+
if err := os.WriteFile(filepath.Join(distDir, "assets", "app.js"),
421+
[]byte("console.log('rsm');"), 0o644); err != nil {
422+
t.Fatalf("write asset: %v", err)
423+
}
424+
t.Setenv("FRONTEND_DIST_DIR", distDir)
425+
return authMiddlewareHarness(t, enabled)
426+
}
427+
428+
func TestAuthMiddleware_Enabled_BlocksStaticRootWithoutAuth(t *testing.T) {
429+
h := staticFrontendHarness(t, true)
430+
rr := h.do(rawRequest(http.MethodGet, "/"))
431+
assertStatus(t, rr, http.StatusUnauthorized)
432+
}
433+
434+
func TestAuthMiddleware_Enabled_BlocksStaticAssetWithoutAuth(t *testing.T) {
435+
h := staticFrontendHarness(t, true)
436+
rr := h.do(rawRequest(http.MethodGet, "/assets/app.js"))
437+
assertStatus(t, rr, http.StatusUnauthorized)
438+
}
439+
440+
func TestAuthMiddleware_Enabled_AllowsStaticRootWithRemoteUser(t *testing.T) {
441+
h := staticFrontendHarness(t, true)
442+
t.Setenv("TRUST_REMOTE_USER_HEADER", "true")
443+
req := rawRequest(http.MethodGet, "/")
444+
req.Header.Set("Remote-User", "alice")
445+
rr := h.do(req)
446+
assertStatus(t, rr, http.StatusOK)
447+
}
448+
449+
func TestAuthMiddleware_Disabled_AllowsStaticRoot(t *testing.T) {
450+
h := staticFrontendHarness(t, false)
451+
rr := h.do(rawRequest(http.MethodGet, "/"))
452+
assertStatus(t, rr, http.StatusOK)
453+
}
454+
455+
// --- AUTH_MODE=enabled — TRUST_REMOTE_USER_HEADER opt-in ---
456+
//
457+
// Remote-User is only honored when the operator explicitly opts in. Default
458+
// off is the safe-by-default posture: a WAN-exposed RSM with no stripping
459+
// reverse proxy would otherwise trust any client-supplied header.
460+
461+
func TestAuthMiddleware_Enabled_RemoteUserIgnoredByDefault(t *testing.T) {
462+
h := authMiddlewareHarness(t, true)
463+
// TRUST_REMOTE_USER_HEADER intentionally unset.
464+
req := rawRequest(http.MethodGet, "/api/saves")
465+
req.Header.Set("Remote-User", "alice")
466+
rr := h.do(req)
467+
assertStatus(t, rr, http.StatusUnauthorized)
468+
}
469+
470+
func TestAuthMiddleware_Enabled_RemoteUserHonoredWhenTrusted(t *testing.T) {
471+
h := authMiddlewareHarness(t, true)
472+
t.Setenv("TRUST_REMOTE_USER_HEADER", "true")
473+
req := rawRequest(http.MethodGet, "/api/saves")
474+
req.Header.Set("Remote-User", "alice")
475+
rr := h.do(req)
476+
assertStatus(t, rr, http.StatusOK)
477+
}
478+
479+
func TestAuthMiddleware_Enabled_RemoteUserIgnoredWhenExplicitlyFalse(t *testing.T) {
480+
h := authMiddlewareHarness(t, true)
481+
t.Setenv("TRUST_REMOTE_USER_HEADER", "false")
482+
req := rawRequest(http.MethodGet, "/api/saves")
483+
req.Header.Set("Remote-User", "alice")
484+
rr := h.do(req)
485+
assertStatus(t, rr, http.StatusUnauthorized)
486+
}
487+
488+
// TRUST_REMOTE_USER_HEADER=true + empty/whitespace Remote-User must still
489+
// reject — the trim check is the last line of defense if a misconfigured
490+
// proxy strips the user but leaves the header in place.
491+
func TestAuthMiddleware_Enabled_RemoteUserTrustedButEmptyRejects(t *testing.T) {
492+
h := authMiddlewareHarness(t, true)
493+
t.Setenv("TRUST_REMOTE_USER_HEADER", "true")
494+
req := rawRequest(http.MethodGet, "/api/saves")
495+
req.Header.Set("Remote-User", " ")
496+
rr := h.do(req)
497+
assertStatus(t, rr, http.StatusUnauthorized)
498+
}

backend/cmd/server/config_helpers.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ func authMode() string {
1414
return mode
1515
}
1616

17+
// trustRemoteUserHeader reports whether the Remote-User header should be
18+
// trusted as proof of authentication when AUTH_MODE=enabled.
19+
//
20+
// Default is FALSE — a deployment that exposes RSM directly to the WAN
21+
// without a stripping reverse proxy in front would otherwise be trivially
22+
// spoofable by any client that sets the header. Operators who DO have a
23+
// trusted forwardAuth proxy (Authelia, oauth2-proxy, Authentik, etc.) in
24+
// front and want SSO to work must opt in explicitly by setting
25+
// TRUST_REMOTE_USER_HEADER=true (or "yes" / "1").
26+
func trustRemoteUserHeader() bool {
27+
v := strings.TrimSpace(strings.ToLower(os.Getenv("TRUST_REMOTE_USER_HEADER")))
28+
switch v {
29+
case "true", "yes", "1":
30+
return true
31+
default:
32+
return false
33+
}
34+
}
35+
1736
func baseURLForRequest(r *http.Request) string {
1837
if env := strings.TrimSpace(os.Getenv("BASE_URL")); env != "" {
1938
return strings.TrimRight(env, "/")

0 commit comments

Comments
 (0)