aboutsummaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-02-18 06:18:28 +0000
committerClaude <noreply@anthropic.com>2026-02-18 06:18:28 +0000
commit269e44da41f9feed32214bbab6fc16ec88fffd85 (patch)
tree6c6312b8ad3fd9175b2992e3e044fa6257e3ef43 /web
parent8eb86cdc49c3c2f69d8a64f855220ebd68be336c (diff)
downloadneko-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')
-rw-r--r--web/security_regression_test.go222
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")
+ }
+}