package web import ( "net/http" "net/http/httptest" "strings" "testing" ) // okHandler is a stub handler that always succeeds, used to verify whether // the middleware lets a request through. var okHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) func newAuthTestServer(addr string) *Server { return &Server{ authToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Addr: addr, } } func addAuthCookie(req *http.Request, token string) { req.AddCookie(&http.Cookie{Name: authTokenCookieName, Value: token}) } func TestGenerateAuthToken(t *testing.T) { token := generateAuthToken() if len(token) != 64 { t.Errorf("Expected 64-char hex token (32 bytes), got %d chars", len(token)) } // Tokens must be unique per generation other := generateAuthToken() if token == other { t.Error("Two generated tokens should never be equal") } } func TestSecurityMiddlewareSetsRestrictiveContentSecurityPolicy(t *testing.T) { handler := securityMiddleware(okHandler) req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:6787/", nil) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) policy := recorder.Header().Get("Content-Security-Policy") if policy != contentSecurityPolicy { t.Fatalf("unexpected Content-Security-Policy: %q", policy) } if strings.Contains(policy, "script-src 'self' 'unsafe-inline'") { t.Fatal("script policy must not permit inline scripts") } for _, directive := range []string{"default-src 'self'", "object-src 'none'", "frame-ancestors 'none'", "connect-src 'self'"} { if !strings.Contains(policy, directive) { t.Errorf("Content-Security-Policy missing %q", directive) } } } func TestAuthMiddleware_AllowsSameOriginWithCookie(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://127.0.0.1:6787/api/platform", nil) addAuthCookie(req, server.authToken) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Expected status OK for same-origin request with valid cookie, got %d", w.Code) } } func TestAuthMiddleware_AllowsMatchingOriginHeader(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("POST", "http://127.0.0.1:6787/api/config/apply", nil) addAuthCookie(req, server.authToken) req.Header.Set("Origin", "http://127.0.0.1:6787") w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Expected status OK when Origin matches scheme://Host, got %d", w.Code) } } func TestAuthMiddleware_RejectsMissingCookie(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://127.0.0.1:6787/api/platform", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("Expected status 401 for missing cookie, got %d", w.Code) } if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { t.Errorf("Expected JSON error response, got Content-Type %q", ct) } } func TestAuthMiddleware_RejectsWrongCookie(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://127.0.0.1:6787/api/platform", nil) addAuthCookie(req, "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("Expected status 401 for wrong token cookie, got %d", w.Code) } } func TestAuthMiddleware_RejectsCrossOrigin(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("POST", "http://127.0.0.1:6787/api/install", nil) addAuthCookie(req, server.authToken) req.Header.Set("Origin", "http://evil.example.com") w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Errorf("Expected status 403 for cross-origin Origin header, got %d", w.Code) } } func TestAuthMiddleware_RejectsForeignHostOnLoopback(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) // Simulates a DNS re-binding attack: browser sends Host of attacker's domain req := httptest.NewRequest("GET", "http://127.0.0.1:6787/api/platform", nil) req.Host = "evil.example.com" addAuthCookie(req, server.authToken) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Errorf("Expected status 403 for foreign Host on loopback bind, got %d", w.Code) } } func TestAuthMiddleware_AllowsLocalhostHost(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://127.0.0.1:6787/api/platform", nil) req.Host = "localhost:6787" addAuthCookie(req, server.authToken) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Expected status OK for localhost Host, got %d", w.Code) } } func TestAuthMiddleware_AllowsIPv6Loopback(t *testing.T) { server := newAuthTestServer("[::1]:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://[::1]:6787/api/platform", nil) addAuthCookie(req, server.authToken) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Expected status OK for IPv6 loopback Host, got %d", w.Code) } // Foreign Host must still be rejected on an IPv6 loopback bind req2 := httptest.NewRequest("GET", "http://[::1]:6787/api/platform", nil) req2.Host = "evil.example.com" addAuthCookie(req2, server.authToken) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) if w2.Code != http.StatusForbidden { t.Errorf("Expected status 403 for foreign Host on IPv6 loopback bind, got %d", w2.Code) } } func TestAuthMiddleware_RejectsForeignHostRegardlessOfConfiguredBind(t *testing.T) { server := newAuthTestServer("0.0.0.0:6787") handler := server.authMiddleware(okHandler) // Defense in depth: even an invalid/non-loopback Server.Addr must not // disable the Host check or allow a DNS re-binding request through. req := httptest.NewRequest("GET", "http://192.168.1.10:6787/api/platform", nil) addAuthCookie(req, server.authToken) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Errorf("Expected status 403 for foreign Host regardless of configured bind, got %d", w.Code) } } func TestAuthMiddleware_RejectsForeignHostBeforeSettingCookie(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://127.0.0.1:6787/dashboard", nil) req.Host = "evil.example.com" w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("Expected status 403 for foreign page Host, got %d", w.Code) } for _, cookie := range w.Result().Cookies() { if cookie.Name == authTokenCookieName { t.Fatal("Foreign Host response must not receive the auth token cookie") } } } func TestAuthMiddleware_RejectsOptions(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("OPTIONS", "http://127.0.0.1:6787/api/platform", nil) addAuthCookie(req, server.authToken) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Errorf("Expected status 403 for OPTIONS on /api/*, got %d", w.Code) } } func TestAuthMiddleware_NoCORSHeaders(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://127.0.0.1:6787/api/platform", nil) addAuthCookie(req, server.authToken) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("Access-Control-Allow-Origin must not be set, got %q", got) } } func TestAuthMiddleware_SetsCookieOnPageGet(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://127.0.0.1:6787/dashboard", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Expected status OK for page GET, got %d", w.Code) } var cookie *http.Cookie for _, c := range w.Result().Cookies() { if c.Name == authTokenCookieName { cookie = c break } } if cookie == nil { t.Fatal("Expected auth token cookie to be set on page GET") } if cookie.Value != server.authToken { t.Errorf("Cookie value %q does not match server token", cookie.Value) } if !cookie.HttpOnly { t.Error("Cookie must be HttpOnly") } if cookie.SameSite != http.SameSiteStrictMode { t.Error("Cookie must be SameSite=Strict") } if cookie.Path != "/" { t.Errorf("Cookie path must be /, got %q", cookie.Path) } } func TestAuthMiddleware_SetsCookieOnStaticGet(t *testing.T) { server := newAuthTestServer("127.0.0.1:6787") handler := server.authMiddleware(okHandler) req := httptest.NewRequest("GET", "http://127.0.0.1:6787/static/style.css", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) found := false for _, c := range w.Result().Cookies() { if c.Name == authTokenCookieName { found = true break } } if !found { t.Error("Expected auth token cookie to be set on static GET") } } func TestKeyringAuthRateLimit(t *testing.T) { server := &Server{} // First 5 attempts are allowed through the limiter (they fail later with // "Password is required" but must not be 429) for i := 1; i <= rateLimitMaxAttempts; i++ { req := httptest.NewRequest("POST", "/api/keyring/auth", strings.NewReader(`{}`)) w := httptest.NewRecorder() server.handleKeyringAuthStore(w, req) if w.Code == http.StatusTooManyRequests { t.Fatalf("Attempt %d should not be rate limited", i) } } // 6th attempt within the window gets 429 JSON req := httptest.NewRequest("POST", "/api/keyring/auth", strings.NewReader(`{}`)) w := httptest.NewRecorder() server.handleKeyringAuthStore(w, req) if w.Code != http.StatusTooManyRequests { t.Errorf("Expected status 429 after %d attempts, got %d", rateLimitMaxAttempts, w.Code) } if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { t.Errorf("Expected JSON error response for 429, got Content-Type %q", ct) } } func TestRateLimiter_SlidingWindow(t *testing.T) { var l rateLimiter for i := 0; i < rateLimitMaxAttempts; i++ { if !l.allow(rateLimitMaxAttempts, rateLimitWindow) { t.Fatalf("Attempt %d should be allowed", i+1) } } if l.allow(rateLimitMaxAttempts, rateLimitWindow) { t.Error("Attempt beyond the limit should be denied") } }