@@ -2,9 +2,12 @@ package main
22
33import (
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
8083func 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+ }
0 commit comments