From b3288bce0f0b0efbcf186fda28b987fe42c6aa23 Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Wed, 18 Mar 2026 16:35:30 +0000 Subject: [PATCH 1/2] fix: replace dashboard ?token= query param with HttpOnly session cookie (#22) Co-Authored-By: Claude Opus 4.6 --- internal/serve/serve_test.go | 197 +++++++++++++++++++++++++++++++++++ internal/serve/server.go | 137 +++++++++++++++++++++--- 2 files changed, 322 insertions(+), 12 deletions(-) diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 95e5225..65a02e5 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -5,8 +5,10 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "strings" "testing" ) @@ -284,6 +286,201 @@ func TestDB_tokenRoundtrip(t *testing.T) { } } +// ─── Dashboard session auth ────────────────────────────────────────────────── + +const testDashToken = "test-dashboard-token-0123456789abcdef" + +// testDashServer creates a Server with DashboardToken set. +func testDashServer(t *testing.T) *Server { + t.Helper() + dir := t.TempDir() + cfg := Config{ + Port: 0, + DBPath: filepath.Join(dir, "test.db"), + DashboardToken: testDashToken, + } + s, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { s.db.close() }) + return s +} + +func TestLoginPost_validToken(t *testing.T) { + s := testDashServer(t) + + form := url.Values{"token": {testDashToken}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + s.handleLogin(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d: %s", rr.Code, rr.Body.String()) + } + cookies := rr.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == sessionCookieName { + found = true + if !c.HttpOnly { + t.Error("cookie should be HttpOnly") + } + if !c.Secure { + t.Error("cookie should be Secure") + } + if c.SameSite != http.SameSiteStrictMode { + t.Error("cookie should be SameSite=Strict") + } + } + } + if !found { + t.Error("session cookie not set") + } +} + +func TestLoginPost_invalidToken(t *testing.T) { + s := testDashServer(t) + + form := url.Values{"token": {"wrong-token"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + s.handleLogin(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Fatalf("expected 303 redirect, got %d", rr.Code) + } + if loc := rr.Header().Get("Location"); !strings.Contains(loc, "error=invalid") { + t.Errorf("expected redirect to /?error=invalid, got %q", loc) + } +} + +func TestLoginPost_JSON(t *testing.T) { + s := testDashServer(t) + + body, _ := json.Marshal(map[string]string{"token": testDashToken}) + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.handleLogin(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestLogin_methodNotAllowed(t *testing.T) { + s := testDashServer(t) + + req := httptest.NewRequest(http.MethodGet, "/login", nil) + rr := httptest.NewRecorder() + s.handleLogin(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", rr.Code) + } +} + +func TestDashboard_sessionCookie(t *testing.T) { + s := testDashServer(t) + + // First, login to get the cookie + form := url.Values{"token": {testDashToken}} + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRR := httptest.NewRecorder() + s.handleLogin(loginRR, loginReq) + + // Extract session cookie + var sessionCookie *http.Cookie + for _, c := range loginRR.Result().Cookies() { + if c.Name == sessionCookieName { + sessionCookie = c + } + } + if sessionCookie == nil { + t.Fatal("no session cookie after login") + } + + // Access dashboard with cookie + dashReq := httptest.NewRequest(http.MethodGet, "/", nil) + dashReq.AddCookie(sessionCookie) + dashRR := httptest.NewRecorder() + s.mux.ServeHTTP(dashRR, dashReq) + + if dashRR.Code != http.StatusOK { + t.Errorf("expected 200 with session cookie, got %d", dashRR.Code) + } +} + +func TestDashboard_bearerStillWorks(t *testing.T) { + s := testDashServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+testDashToken) + rr := httptest.NewRecorder() + s.mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200 with Bearer token, got %d", rr.Code) + } +} + +func TestDashboard_queryParamRejected(t *testing.T) { + s := testDashServer(t) + + req := httptest.NewRequest(http.MethodGet, "/?token="+testDashToken, nil) + req.Header.Set("Accept", "text/html") + rr := httptest.NewRecorder() + s.mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for query param auth (removed), got %d", rr.Code) + } +} + +func TestDashboard_noCreds(t *testing.T) { + s := testDashServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept", "text/html") + rr := httptest.NewRecorder() + s.mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } + // Login form should POST to /login, not GET with ?token= + body := rr.Body.String() + if !strings.Contains(body, `action="/login"`) { + t.Error("login form should POST to /login") + } + if !strings.Contains(body, `method="POST"`) { + t.Error("login form should use POST method") + } +} + +func TestLogout_clearsCookie(t *testing.T) { + s := testDashServer(t) + + req := httptest.NewRequest(http.MethodGet, "/logout", nil) + req.Header.Set("Accept", "text/html") + rr := httptest.NewRecorder() + s.handleLogout(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Errorf("expected 303, got %d", rr.Code) + } + for _, c := range rr.Result().Cookies() { + if c.Name == sessionCookieName && c.MaxAge == -1 { + return // cookie cleared + } + } + t.Error("session cookie not cleared on logout") +} + // TestMain ensures any temp test databases are cleaned up. func TestMain(m *testing.M) { os.Exit(m.Run()) diff --git a/internal/serve/server.go b/internal/serve/server.go index 2732837..e6fdbc7 100644 --- a/internal/serve/server.go +++ b/internal/serve/server.go @@ -2,7 +2,9 @@ package serve import ( "context" + "crypto/hmac" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -17,6 +19,8 @@ import ( "golang.org/x/crypto/acme/autocert" ) +const sessionCookieName = "snare_session" + // Config holds the server configuration. type Config struct { Port int // listen port (default 8080) @@ -37,9 +41,10 @@ func DefaultConfig() Config { // Server is the snare self-hosted server. type Server struct { - cfg Config - db *DB - mux *http.ServeMux + cfg Config + db *DB + mux *http.ServeMux + sessionSecret []byte // random secret generated at startup for HMAC session cookies } // tokenPattern validates token IDs in URL paths. @@ -61,7 +66,12 @@ func New(cfg Config) (*Server, error) { return nil, fmt.Errorf("opening database: %w", err) } - s := &Server{cfg: cfg, db: db, mux: http.NewServeMux()} + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("generating session secret: %w", err) + } + + s := &Server{cfg: cfg, db: db, mux: http.NewServeMux(), sessionSecret: secret} s.routes() return s, nil } @@ -78,13 +88,115 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/revoke", s.handleRevoke) s.mux.HandleFunc("/api/events/", s.handleEvents) - // Dashboard: authenticated by DashboardToken (operator-level access) + // Dashboard auth: login / logout + s.mux.HandleFunc("/login", s.handleLogin) + s.mux.HandleFunc("/logout", s.handleLogout) + + // Dashboard: authenticated by session cookie or Bearer token (operator-level access) s.mux.HandleFunc("/api/dashboard/alerts", s.requireDashboardAuth(s.handleDashboardAlerts)) s.mux.HandleFunc("/api/dashboard/devices", s.requireDashboardAuth(s.handleDashboardDevices)) s.mux.HandleFunc("/", s.requireDashboardAuth(s.handleDashboard)) } -// requireDashboardAuth wraps a handler to require Bearer token authentication. +// sessionMAC returns the expected session cookie value for the current server instance. +func (s *Server) sessionMAC() string { + mac := hmac.New(sha256.New, s.sessionSecret) + mac.Write([]byte("snare_session")) + return hex.EncodeToString(mac.Sum(nil)) +} + +// validSession checks whether the request carries a valid session cookie. +func (s *Server) validSession(r *http.Request) bool { + c, err := r.Cookie(sessionCookieName) + if err != nil { + return false + } + return hmac.Equal([]byte(c.Value), []byte(s.sessionMAC())) +} + +// setSessionCookie writes the session cookie to the response. +func (s *Server) setSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: s.sessionMAC(), + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +// clearSessionCookie removes the session cookie. +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) +} + +// handleLogin accepts a POST with the dashboard token and sets a session cookie. +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.cfg.DashboardToken == "" { + http.Error(w, "dashboard auth not configured", http.StatusServiceUnavailable) + return + } + + var token string + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + var body struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + token = body.Token + } else { + token = r.FormValue("token") + } + + if token != s.cfg.DashboardToken { + // For HTML form submissions, redirect back to / which shows the login form + if strings.Contains(r.Header.Get("Accept"), "text/html") || + r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { + http.Redirect(w, r, "/?error=invalid", http.StatusSeeOther) + return + } + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + s.setSessionCookie(w) + + // For HTML form submissions, redirect to dashboard + if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + jsonResp(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleLogout clears the session cookie. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + clearSessionCookie(w) + if r.Method == http.MethodGet && strings.Contains(r.Header.Get("Accept"), "text/html") { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + jsonResp(w, http.StatusOK, map[string]string{"status": "logged out"}) +} + +// requireDashboardAuth wraps a handler to require a valid session cookie or Bearer token. // The token must match cfg.DashboardToken. If DashboardToken is empty, the // server refuses to start (enforced in cmdServe). func (s *Server) requireDashboardAuth(next http.HandlerFunc) http.HandlerFunc { @@ -93,13 +205,13 @@ func (s *Server) requireDashboardAuth(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "dashboard auth not configured", http.StatusServiceUnavailable) return } - auth := r.Header.Get("Authorization") - if auth == "Bearer "+s.cfg.DashboardToken { + // Accept Bearer token in Authorization header (for API clients) + if r.Header.Get("Authorization") == "Bearer "+s.cfg.DashboardToken { next(w, r) return } - // Also accept token as query param for browser access to the dashboard - if r.URL.Query().Get("token") == s.cfg.DashboardToken { + // Accept valid session cookie (for browser sessions) + if s.validSession(r) { next(w, r) return } @@ -111,8 +223,9 @@ func (s *Server) requireDashboardAuth(next http.HandlerFunc) http.HandlerFunc { -

🪤 snare

+button{background:#f5a623;color:#0a0a0b;border:none;padding:.625rem 1.25rem;border-radius:5px;font-weight:600;cursor:pointer} +.error{color:#e84040;font-size:12px} +

🪤 snare

`) return From f5c1670116466211698f367c1cbb017757c9b05b Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Wed, 18 Mar 2026 16:53:23 +0000 Subject: [PATCH 2/2] fix: make session cookie Secure flag conditional on TLSDomain Secure=true breaks plain HTTP self-hosted instances (localhost, internal servers without TLS). Only set Secure when TLSDomain is configured. --- internal/serve/serve_test.go | 6 ++++-- internal/serve/server.go | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 65a02e5..45eccaa 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -327,8 +327,10 @@ func TestLoginPost_validToken(t *testing.T) { if !c.HttpOnly { t.Error("cookie should be HttpOnly") } - if !c.Secure { - t.Error("cookie should be Secure") + // Secure flag is only set when TLSDomain is configured; + // test server has no TLSDomain so Secure should be false here. + if c.Secure { + t.Error("cookie should not be Secure when TLSDomain is empty") } if c.SameSite != http.SameSiteStrictMode { t.Error("cookie should be SameSite=Strict") diff --git a/internal/serve/server.go b/internal/serve/server.go index e6fdbc7..ca0d8ad 100644 --- a/internal/serve/server.go +++ b/internal/serve/server.go @@ -115,25 +115,27 @@ func (s *Server) validSession(r *http.Request) bool { } // setSessionCookie writes the session cookie to the response. +// Secure flag is only set when TLS is configured — plain HTTP self-hosted +// instances (localhost, internal servers) would break with Secure=true. func (s *Server) setSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: s.sessionMAC(), Path: "/", HttpOnly: true, - Secure: true, + Secure: s.cfg.TLSDomain != "", SameSite: http.SameSiteStrictMode, }) } // clearSessionCookie removes the session cookie. -func clearSessionCookie(w http.ResponseWriter) { +func (s *Server) clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, - Secure: true, + Secure: s.cfg.TLSDomain != "", SameSite: http.SameSiteStrictMode, MaxAge: -1, }) @@ -188,7 +190,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // handleLogout clears the session cookie. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - clearSessionCookie(w) + s.clearSessionCookie(w) if r.Method == http.MethodGet && strings.Contains(r.Header.Get("Accept"), "text/html") { http.Redirect(w, r, "/", http.StatusSeeOther) return