diff options
| author | Claude <noreply@anthropic.com> | 2026-02-18 06:18:28 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-02-18 06:18:28 +0000 |
| commit | 269e44da41f9feed32214bbab6fc16ec88fffd85 (patch) | |
| tree | 6c6312b8ad3fd9175b2992e3e044fa6257e3ef43 /web/security_regression_test.go | |
| parent | 8eb86cdc49c3c2f69d8a64f855220ebd68be336c (diff) | |
| download | neko-269e44da41f9feed32214bbab6fc16ec88fffd85.tar.gz neko-269e44da41f9feed32214bbab6fc16ec88fffd85.tar.bz2 neko-269e44da41f9feed32214bbab6fc16ec88fffd85.zip | |
Increase test coverage across lowest-coverage packagesclaude/improve-test-coverage-iBkwc
Major coverage improvements:
- safehttp: 46.7% -> 93.3% (SafeDialer, redirect checking, SSRF protection)
- api: 81.8% -> 96.4% (HandleImport 0% -> 100%, stream errors, content types)
- importer: 85.3% -> 94.7% (ImportFeeds dispatcher, OPML nesting, edge cases)
- cmd/neko: 77.1% -> 85.4% (purge, secure-cookies, minutes, allow-local flags)
New tests added:
- Security regression tests (CSRF token uniqueness, mismatch rejection,
auth cookie HttpOnly, security headers, API auth requirements)
- Stress tests for concurrent mixed operations and rapid state toggling
- SSRF protection tests for SafeDialer hostname resolution and redirect paths
https://claude.ai/code/session_01XUBh32rHpbYue1JYXSH64Q
Diffstat (limited to 'web/security_regression_test.go')
| -rw-r--r-- | web/security_regression_test.go | 222 |
1 files changed, 222 insertions, 0 deletions
diff --git a/web/security_regression_test.go b/web/security_regression_test.go new file mode 100644 index 0000000..6c97491 --- /dev/null +++ b/web/security_regression_test.go @@ -0,0 +1,222 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "adammathes.com/neko/config" +) + +// Security regression tests to ensure critical security properties are maintained. + +// TestCSRFTokenMismatchRejected ensures mismatched CSRF tokens are rejected. +func TestCSRFTokenMismatchRejected(t *testing.T) { + cfg := &config.Settings{SecureCookies: false} + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := CSRFMiddleware(cfg, inner) + + // Get a valid token + getReq := httptest.NewRequest("GET", "/", nil) + getRR := httptest.NewRecorder() + handler.ServeHTTP(getRR, getReq) + + var csrfToken string + for _, c := range getRR.Result().Cookies() { + if c.Name == "csrf_token" { + csrfToken = c.Value + } + } + + // POST with wrong token in header should be rejected + req := httptest.NewRequest("POST", "/something", nil) + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) + req.Header.Set("X-CSRF-Token", "completely-wrong-token") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("CSRF token mismatch should return 403, got %d", rr.Code) + } +} + +// TestCSRFTokenEmptyHeaderRejected ensures empty CSRF tokens are rejected. +func TestCSRFTokenEmptyHeaderRejected(t *testing.T) { + cfg := &config.Settings{SecureCookies: false} + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := CSRFMiddleware(cfg, inner) + + getReq := httptest.NewRequest("GET", "/", nil) + getRR := httptest.NewRecorder() + handler.ServeHTTP(getRR, getReq) + + var csrfToken string + for _, c := range getRR.Result().Cookies() { + if c.Name == "csrf_token" { + csrfToken = c.Value + } + } + + // POST with empty X-CSRF-Token header + req := httptest.NewRequest("POST", "/data", nil) + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken}) + req.Header.Set("X-CSRF-Token", "") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("Empty CSRF token should return 403, got %d", rr.Code) + } +} + +// TestSecurityHeadersPresent verifies all security headers are set correctly. +func TestSecurityHeadersPresent(t *testing.T) { + handler := SecurityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + headers := map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "strict-origin-when-cross-origin", + } + + for name, expected := range headers { + if got := rr.Header().Get(name); got != expected { + t.Errorf("Header %s: expected %q, got %q", name, expected, got) + } + } + + // CSP should deny framing + csp := rr.Header().Get("Content-Security-Policy") + if !strings.Contains(csp, "frame-ancestors 'none'") { + t.Error("CSP should contain frame-ancestors 'none'") + } +} + +// TestAuthCookieHttpOnly ensures the auth cookie is HttpOnly. +func TestAuthCookieHttpOnly(t *testing.T) { + originalPw := config.Config.DigestPassword + defer func() { config.Config.DigestPassword = originalPw }() + config.Config.DigestPassword = "testpass" + + req := httptest.NewRequest("POST", "/login/", nil) + req.Form = map[string][]string{"password": {"testpass"}} + rr := httptest.NewRecorder() + loginHandler(rr, req) + + for _, c := range rr.Result().Cookies() { + if c.Name == AuthCookie { + if !c.HttpOnly { + t.Error("Auth cookie must be HttpOnly to prevent XSS theft") + } + return + } + } + t.Error("Auth cookie not found in login response") +} + +// TestLogoutClearsAuthCookie ensures logout properly invalidates the cookie. +func TestLogoutClearsAuthCookie(t *testing.T) { + req := httptest.NewRequest("POST", "/api/logout", nil) + rr := httptest.NewRecorder() + apiLogoutHandler(rr, req) + + for _, c := range rr.Result().Cookies() { + if c.Name == AuthCookie { + if c.MaxAge != -1 { + t.Errorf("Logout should set MaxAge=-1 to expire cookie, got %d", c.MaxAge) + } + if c.Value != "" { + t.Error("Logout should clear cookie value") + } + return + } + } + t.Error("Auth cookie not found in logout response") +} + +// TestAPIRoutesRequireAuth ensures API routes redirect when not authenticated. +func TestAPIRoutesRequireAuth(t *testing.T) { + setupTestDB(t) + originalPw := config.Config.DigestPassword + defer func() { config.Config.DigestPassword = originalPw }() + config.Config.DigestPassword = "secret" + + router := NewRouter(&config.Config) + + protectedPaths := []string{ + "/api/stream", + "/api/feed", + "/api/tag", + } + + for _, path := range protectedPaths { + req := httptest.NewRequest("GET", path, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusTemporaryRedirect { + t.Errorf("GET %s without auth should redirect, got %d", path, rr.Code) + } + } +} + +// TestCSRFTokenUniqueness ensures each new session gets a unique CSRF token. +func TestCSRFTokenUniqueness(t *testing.T) { + cfg := &config.Settings{SecureCookies: false} + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := CSRFMiddleware(cfg, inner) + + tokens := make(map[string]bool) + for i := 0; i < 10; i++ { + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + for _, c := range rr.Result().Cookies() { + if c.Name == "csrf_token" { + if tokens[c.Value] { + t.Error("CSRF tokens should be unique across sessions") + } + tokens[c.Value] = true + } + } + } + + if len(tokens) < 10 { + t.Errorf("Expected 10 unique CSRF tokens, got %d", len(tokens)) + } +} + +// TestCSRFExcludedPathsTrailingSlash ensures CSRF exclusion works with and without trailing slashes. +func TestCSRFExcludedPathsTrailingSlash(t *testing.T) { + originalPw := config.Config.DigestPassword + defer func() { config.Config.DigestPassword = originalPw }() + config.Config.DigestPassword = "secret" + + mux := http.NewServeMux() + mux.HandleFunc("/api/login", apiLoginHandler) + handler := CSRFMiddleware(&config.Config, mux) + + // POST /api/login/ (with trailing slash) should also be excluded + req := httptest.NewRequest("POST", "/api/login/", strings.NewReader("password=secret")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code == http.StatusForbidden { + t.Error("POST /api/login/ (trailing slash) should be excluded from CSRF protection") + } +} |
