diff --git a/internal/api/issue138_cookie_test.go b/internal/api/issue138_cookie_test.go new file mode 100644 index 0000000..d5e8ca2 --- /dev/null +++ b/internal/api/issue138_cookie_test.go @@ -0,0 +1,67 @@ +package api + +// #138: the vwr viewer cookie must carry the Secure attribute. Tests inspect +// the Set-Cookie header directly rather than relying on cookie round-tripping, +// because Go's HTTP client (and browsers) drop Secure cookies over plain HTTP, +// which is how tests and local dev run. + +import ( + "net/http/httptest" + "strings" + "testing" + + "palette/internal/store" + "palette/internal/web" +) + +func newTestServer138(t *testing.T) *httptest.ResponseRecorder { + t.Helper() + st, err := store.OpenStore(":memory:") + if err != nil { + t.Fatal(err) + } + ui, err := web.New() + if err != nil { + t.Fatal(err) + } + cfg := Config{MaxTextBytes: 5 * 1024 * 1024} + ss := NewTestSettingsStore(t, cfg) + globalSettingsFn = ss.get + t.Cleanup(func() { globalSettingsFn = nil }) + a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} + req := httptest.NewRequest("GET", "/history", nil) + rec := httptest.NewRecorder() + a.routes().ServeHTTP(rec, req) + return rec +} + +func TestViewerCookieAttributes(t *testing.T) { + rec := newTestServer138(t) + var vwr *string + for _, c := range rec.Result().Cookies() { + if c.Name == "vwr" { + cc := c + vwr = &cc.Value + if !c.Secure { + t.Error("vwr cookie missing Secure attribute (#138)") + } + if !c.HttpOnly { + t.Error("vwr cookie missing HttpOnly attribute") + } + if c.Path != "/" { + t.Errorf("vwr cookie Path = %q, want /", c.Path) + } + if c.SameSite != 2 { // http.SameSiteLaxMode + t.Errorf("vwr cookie SameSite = %v, want Lax", c.SameSite) + } + } + } + if vwr == nil { + t.Fatal("no vwr cookie set") + } + // also confirm the raw header form spells out Secure + sc := rec.Header().Get("Set-Cookie") + if !strings.Contains(sc, "Secure") { + t.Errorf("Set-Cookie header %q lacks Secure", sc) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index fb2cd7c..cf2aaf7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -141,9 +141,12 @@ func viewerCookieMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("vwr"); err != nil || c.Value == "" { id := store.GenSlug(16) + // #138: Secure keeps the viewer id off plain-HTTP requests + // (all deployments are HTTPS-only behind traefik). http.SetCookie(w, &http.Cookie{ Name: "vwr", Value: id, Path: "/", - MaxAge: 31536000, HttpOnly: true, SameSite: http.SameSiteLaxMode, + MaxAge: 31536000, HttpOnly: true, Secure: true, + SameSite: http.SameSiteLaxMode, }) r.AddCookie(&http.Cookie{Name: "vwr", Value: id}) // remember that this cookie was minted here, not sent by the client