Files
petal/internal/auth/session_test.go
T
prosolis 69bf3ffde1 Close the door the edge gate used to hold
A security review of the whole repo. The queries were already scoped, the
OIDC flow already did state and nonce and PKCE, the session tokens were
already stored as hashes. What it found was mostly the seam between the
code and the deployment — and one place where the deployment quietly
undid the code.

The one that matters: with any AUTHENTIK_* variable missing, Petal fell
back to resolving every request to the single `local` user. That is right
on a laptop and a catastrophe on a public host, and Phase 16 removed the
Traefik basic-auth gate that used to stand behind the mistake. A typo in
the client secret would have served her journals to the open internet and
said so only in a log line nobody reads. It now refuses to start, guarded
by default for any BASE_URL that isn't loopback.

Then the one that would have been fixed and wasn't: stored images now
serve under `default-src 'none'; sandbox`, so an SVG pasted into a
document can't run as a page on Petal's own origin. Traefik's
customresponseheaders *overwrites*, so the CSP declared in the compose
labels would have silently replaced that per-route policy in production.
The whole header block moved into the binary, where a route can tighten
its own and a test can prove it; only HSTS stays at the edge, where TLS
actually terminates.

The rest, smaller:

  - PETAL_ALLOWED_SUBS empty means everyone authentik authenticates, and
    authentik here fronts half a dozen applications. Still legal, now
    said out loud every boot, and set in both env examples.
  - LLM failures relayed err.Error() to the browser, which carries the
    address of the inference box on the far side of the VPN. Logged
    instead; the client only ever rendered "the helper is resting".
  - Exports scheme-check their links. Escaping makes a URL safe to sit
    in an attribute and says nothing about following it, and an export
    is the one artifact here meant to leave. Writing the test found the
    markdown image src, which I'd missed reading it.
  - The draft rescue is namespaced per account and cleared on sign-out.
    Everything else in localStorage is a preference; this is her unsaved
    writing, sitting in a profile two people share.
  - /auth/logout is POST-only. With SameSite=Lax a GET route lets any
    page on the internet sign her out mid-draft.
  - Image uploads get a per-account allowance and the TTS cache a size
    cap. Both share the encrypted volume the database is on, and a full
    disk is SQLite failing to write, not a feature degrading.
  - The session cookie takes the __Host- prefix over https, so nothing
    else under parodia.dev can plant one. Old cookies still resolve;
    nobody is signed out to get there.
  - npm audit: linkify-it and postcss.

Verified: go build, go vet, the full Go suite, tsc, 195 frontend tests,
npm audit clean. The startup guard and both CSPs checked against a
running server rather than only asserted.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 18:24:47 -07:00

376 lines
11 KiB
Go

package auth
import (
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// newStores opens a database holding two users and returns the session and user
// stores over it.
func newStores(t *testing.T) (*SessionStore, *UserStore, *db.DB) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
if _, err := database.Exec(
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
"bob", "bob@petal.local", "Bob",
); err != nil {
t.Fatalf("seed second user: %v", err)
}
return NewSessionStore(database.DB), NewUserStore(database.DB), database
}
// withCookie builds a request carrying a session token.
func withCookie(token string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
return r
}
func TestSessionLifecycle(t *testing.T) {
sessions, _, _ := newStores(t)
token, err := sessions.Create(db.LocalUserID, "test-agent")
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := sessions.Resolve(withCookie(token))
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != db.LocalUserID {
t.Fatalf("resolved %q, want %q", got, db.LocalUserID)
}
// Signing out must invalidate the token server-side, not just in the browser:
// clearing only the cookie leaves a token that still works if it ever leaked.
if err := sessions.Revoke(token); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
t.Fatalf("revoked token still resolves (err=%v)", err)
}
}
// A request with no cookie, or a token nobody issued, is simply not signed in.
func TestSessionRejectsUnknown(t *testing.T) {
sessions, _, _ := newStores(t)
if _, err := sessions.Resolve(httptest.NewRequest(http.MethodGet, "/", nil)); !errors.Is(err, ErrNoSession) {
t.Fatalf("bare request err=%v, want ErrNoSession", err)
}
if _, err := sessions.Resolve(withCookie("not-a-real-token")); !errors.Is(err, ErrNoSession) {
t.Fatalf("forged token err=%v, want ErrNoSession", err)
}
}
// The table stores a hash, so a database dump yields no usable session.
func TestSessionTokenIsNotStored(t *testing.T) {
sessions, _, database := newStores(t)
token, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatalf("create: %v", err)
}
var stored string
if err := database.QueryRow(`SELECT id FROM sessions`).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == token || strings.Contains(stored, token) {
t.Fatal("the raw session token is stored in the database")
}
if stored != hashToken(token) {
t.Fatal("stored id is not the token's hash")
}
}
func TestSessionExpiry(t *testing.T) {
sessions, _, database := newStores(t)
token, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := database.Exec(
`UPDATE sessions SET expires_at = datetime('now','-1 minute')`,
); err != nil {
t.Fatal(err)
}
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
t.Fatalf("expired session still resolves (err=%v)", err)
}
n, err := sessions.Prune()
if err != nil {
t.Fatalf("prune: %v", err)
}
if n != 1 {
t.Fatalf("pruned %d rows, want 1", n)
}
}
// The window slides: using a session pushes its expiry back out, so someone who
// writes in Petal every few days is never signed out mid-draft.
func TestSessionSlidesForward(t *testing.T) {
sessions, _, database := newStores(t)
token, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatalf("create: %v", err)
}
// Pretend the session has been idle for a fortnight.
if _, err := database.Exec(
`UPDATE sessions SET expires_at = datetime('now','+16 days')`,
); err != nil {
t.Fatal(err)
}
if _, err := sessions.Resolve(withCookie(token)); err != nil {
t.Fatalf("resolve: %v", err)
}
var extended bool
if err := database.QueryRow(
`SELECT expires_at > datetime('now','+29 days') FROM sessions`,
).Scan(&extended); err != nil {
t.Fatal(err)
}
if !extended {
t.Fatal("using a session did not extend it")
}
}
// Two live sessions must each resolve to their own writer — the whole point.
func TestSessionsAreNotInterchangeable(t *testing.T) {
sessions, _, _ := newStores(t)
aliceToken, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatal(err)
}
bobToken, err := sessions.Create("bob", "")
if err != nil {
t.Fatal(err)
}
for token, want := range map[string]string{aliceToken: db.LocalUserID, bobToken: "bob"} {
got, err := sessions.Resolve(withCookie(token))
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != want {
t.Fatalf("token resolved to %q, want %q", got, want)
}
}
// Revoking one session leaves the other alone.
if err := sessions.Revoke(aliceToken); err != nil {
t.Fatal(err)
}
if _, err := sessions.Resolve(withCookie(bobToken)); err != nil {
t.Fatalf("bob was signed out by alice's logout: %v", err)
}
// RevokeAll signs one writer out everywhere and nobody else.
second, _ := sessions.Create("bob", "phone")
if err := sessions.RevokeAll("bob"); err != nil {
t.Fatal(err)
}
for _, token := range []string{bobToken, second} {
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
t.Fatalf("RevokeAll left a session alive (err=%v)", err)
}
}
}
// The session store is itself the Resolver the API middleware runs on, so a
// valid cookie must carry all the way through to the handler.
func TestSessionStoreDrivesMiddleware(t *testing.T) {
sessions, _, _ := newStores(t)
token, err := sessions.Create("bob", "")
if err != nil {
t.Fatal(err)
}
var seen string
h := Middleware(sessions)(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) { seen = UserID(r.Context()) },
))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie(token))
if rec.Code != http.StatusOK || seen != "bob" {
t.Fatalf("status=%d user=%q, want 200/bob", rec.Code, seen)
}
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/", nil))
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("status=%d for a cookieless request, want 401", rec2.Code)
}
}
func TestUserUpsert(t *testing.T) {
_, users, database := newStores(t)
if err := users.Upsert("sub-123", "her@example.com", "Her Name"); err != nil {
t.Fatalf("upsert: %v", err)
}
user, err := users.Get("sub-123")
if err != nil {
t.Fatalf("get: %v", err)
}
if user.Email != "her@example.com" || user.DisplayName != "Her Name" {
t.Fatalf("unexpected user %+v", user)
}
if user.PairLang != "zh" {
t.Fatalf("pair_lang = %q, want the zh default", user.PairLang)
}
// A rename upstream is reflected here; Petal's own settings are not touched.
if _, err := database.Exec(`UPDATE users SET pair_lang = 'pt-PT' WHERE id = 'sub-123'`); err != nil {
t.Fatal(err)
}
if err := users.Upsert("sub-123", "new@example.com", "New Name"); err != nil {
t.Fatalf("second upsert: %v", err)
}
user, _ = users.Get("sub-123")
if user.Email != "new@example.com" || user.DisplayName != "New Name" {
t.Fatalf("login did not refresh the profile: %+v", user)
}
if user.PairLang != "pt-PT" {
t.Fatalf("login reset pair_lang to %q", user.PairLang)
}
// Falling back to the email keeps the sidebar from showing an empty name.
if err := users.Upsert("sub-456", "them@example.com", ""); err != nil {
t.Fatal(err)
}
if u, _ := users.Get("sub-456"); u.DisplayName != "them@example.com" {
t.Fatalf("display name = %q, want the email fallback", u.DisplayName)
}
if err := users.Upsert("", "nobody@example.com", "Nobody"); err == nil {
t.Fatal("a login with no subject was accepted")
}
}
// Sessions belong to their account: deleting a user takes their logins with it.
func TestSessionsCascadeWithUser(t *testing.T) {
sessions, _, database := newStores(t)
token, err := sessions.Create("bob", "")
if err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`DELETE FROM users WHERE id = 'bob'`); err != nil {
t.Fatal(err)
}
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
t.Fatalf("session outlived its account (err=%v)", err)
}
}
func TestAllowlist(t *testing.T) {
// No list configured = anyone the IdP authenticates, which is the right
// default for a household instance.
if !ParseAllowlist("").Permits("anyone", "anyone@example.com") {
t.Fatal("an empty allowlist turned someone away")
}
if !ParseAllowlist(" ").Permits("anyone", "anyone@example.com") {
t.Fatal("a whitespace-only allowlist turned someone away")
}
list := ParseAllowlist(" sub-123 , Her@Example.com ,, ")
cases := []struct {
sub, email string
want bool
}{
{"sub-123", "someone@example.com", true}, // by subject
{"sub-999", "her@example.com", true}, // by email
{"sub-999", "HER@EXAMPLE.COM", true}, // case-insensitively
{"sub-999", "stranger@example.com", false}, // neither
{"", "", false}, // no claims at all
{"sub-1", "", false}, // a near-miss subject
}
for _, c := range cases {
if got := list.Permits(c.sub, c.email); got != c.want {
t.Fatalf("Permits(%q, %q) = %v, want %v", c.sub, c.email, got, c.want)
}
}
}
// Over https the cookie takes the __Host- prefix, which the browser will only
// accept from the exact host that set it — closing the door on a sibling
// service under the same registrable domain planting a session in her browser.
// Over plain http it cannot: the prefix requires Secure, and a browser drops a
// Secure cookie on an insecure origin, so local development would silently stop
// logging in.
func TestSessionCookieNamePerScheme(t *testing.T) {
secure := httptest.NewRecorder()
SetSessionCookie(secure, "tok", true)
c := secure.Result().Cookies()[0]
if c.Name != HostSessionCookie {
t.Fatalf("https cookie name=%q, want %q", c.Name, HostSessionCookie)
}
// The prefix is a promise about these three attributes; a browser rejects
// the cookie outright if any is wrong.
if !c.Secure || c.Path != "/" || c.Domain != "" {
t.Fatalf("__Host- cookie violates its own contract: %+v", c)
}
insecure := httptest.NewRecorder()
SetSessionCookie(insecure, "tok", false)
if name := insecure.Result().Cookies()[0].Name; name != SessionCookie {
t.Fatalf("http cookie name=%q, want %q", name, SessionCookie)
}
}
// A browser holding a cookie issued before the prefix existed must stay signed
// in — and start using the new name at its next sign-in, not be logged out to
// get there.
func TestResolveAcceptsEitherCookieName(t *testing.T) {
store, _, _ := newStores(t)
token, err := store.Create("bob", "test-agent")
if err != nil {
t.Fatal(err)
}
for _, name := range []string{SessionCookie, HostSessionCookie} {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: name, Value: token})
got, err := store.Resolve(r)
if err != nil || got != "bob" {
t.Fatalf("%s: resolved to %q (err=%v)", name, got, err)
}
}
}
// Signing out must not leave the browser holding either spelling.
func TestClearSessionCookieExpiresBothNames(t *testing.T) {
rec := httptest.NewRecorder()
ClearSessionCookie(rec, true)
cleared := map[string]bool{}
for _, c := range rec.Result().Cookies() {
if c.MaxAge < 0 {
cleared[c.Name] = true
}
}
for _, name := range []string{SessionCookie, HostSessionCookie} {
if !cleared[name] {
t.Errorf("%s was left in the browser after signing out", name)
}
}
}