Files
krow_backend/go-api/internal/httpserver/samesite_test.go
2026-08-28 12:21:44 +05:30

134 lines
4.4 KiB
Go

package httpserver_test
import (
"io"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/krow/krow-backend/go-api/internal/config"
"github.com/krow/krow-backend/go-api/internal/db"
"github.com/krow/krow-backend/go-api/internal/httpserver"
"github.com/krow/krow-backend/go-api/internal/testutil"
)
// The session cookie's SameSite mode.
//
// This exists because the mode is a security decision that nothing else in the
// suite observes, and because it was silently unreadable for a release: config
// parsed and validated HTTP_COOKIE_SAMESITE and no code path consulted it, so
// a deployment that set `lax` got `none` and lost its only CSRF protection.
//
// CORS and SameSite answer different questions. CORS is about ORIGIN;
// SameSite is about SITE. A frontend on platform.krowforce.com calling
// mcp.krowforce.com is cross-origin — it needs the allowlist — and same-site,
// so a Lax cookie reaches it regardless. Deriving None from "an allowlist
// exists" is therefore a guess, and these tests pin who gets the final word.
// sameSiteFor builds a server with the given cookie and CORS configuration and
// reports the SameSite attribute it writes. Read off the logout response,
// because clearSessionCookie writes the same attributes the login path does and
// needs no credentials to reach.
func sameSiteFor(t *testing.T, h *testutil.Harness, configured string, origins []string) string {
t.Helper()
cfg := &config.Config{
AppEnv: "production",
HTTP: config.HTTPConfig{
Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second,
CookieSameSite: configured,
CORSOrigins: origins,
},
DB: config.DBConfig{Schema: "public"},
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log)
if err != nil {
t.Fatalf("build the server: %v", err)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/auth/logout", nil))
for _, c := range rec.Header().Values("Set-Cookie") {
if !strings.HasPrefix(c, sessionCookie+"=") {
continue
}
for _, part := range strings.Split(c, ";") {
part = strings.TrimSpace(part)
if v, ok := strings.CutPrefix(part, "SameSite="); ok {
return v
}
}
return "(absent)"
}
return "(no cookie)"
}
func TestSessionCookieSameSite(t *testing.T) {
h := testutil.New(t)
origins := []string{"https://platform.krowforce.com"}
cases := []struct {
name string
configured string
origins []string
want string
}{
// The deployment this was written for: CORS is genuinely required
// (cross-origin) and Lax is genuinely correct (same-site). Before the
// fix this combination was unreachable.
{"explicit lax survives a CORS allowlist", "lax", origins, "Lax"},
{"explicit none is honoured", "none", nil, "None"},
{"explicit strict is honoured", "strict", origins, "Strict"},
// Unset: the allowlist decides, which is the behaviour b6f8655
// introduced and the right default for an unconfigured deployment.
{"unset with an allowlist defaults to None", "", origins, "None"},
{"unset with no allowlist defaults to Lax", "", nil, "Lax"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := sameSiteFor(t, h, c.configured, c.origins); got != c.want {
t.Fatalf("SameSite=%s, want %s", got, c.want)
}
})
}
}
// SameSite=None is meaningless without Secure — browsers reject the pairing
// outright, so the cookie would simply never be stored.
func TestSameSiteNoneAlwaysCarriesSecure(t *testing.T) {
h := testutil.New(t)
cfg := &config.Config{
AppEnv: "development", // Secure would otherwise be off
HTTP: config.HTTPConfig{
Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second,
CookieSameSite: "none",
},
DB: config.DBConfig{Schema: "public"},
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log)
if err != nil {
t.Fatalf("build the server: %v", err)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/auth/logout", nil))
var cookie string
for _, c := range rec.Header().Values("Set-Cookie") {
if strings.HasPrefix(c, sessionCookie+"=") {
cookie = c
}
}
if cookie == "" {
t.Fatal("no session cookie written")
}
if !strings.Contains(cookie, "SameSite=None") || !strings.Contains(cookie, "Secure") {
t.Fatalf("SameSite=None must be paired with Secure, got %q", cookie)
}
}