Files
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

222 lines
7.7 KiB
Go

package auth
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"net/http"
"time"
)
// The cookie carrying the opaque session token, in its two spellings.
//
// Over https the name takes the __Host- prefix, which is not decoration: the
// browser will only accept such a cookie if it is Secure, Path=/, and carries
// no Domain attribute — and, crucially, refuses to let any other host set it.
// Without the prefix, anything that can write cookies for a sibling name under
// parodia.dev (another service on the box, a subdomain takeover) can plant a
// session cookie in her browser that Petal will then read as hers.
//
// The prefix is impossible over plain http, because it requires Secure and a
// browser drops a Secure cookie on an insecure origin. So local development
// keeps the bare name, and the name in use follows the same `secure` flag the
// rest of the cookie does.
const (
SessionCookie = "petal_session"
HostSessionCookie = "__Host-petal_session"
)
// sessionCookieName is the name to *write* under this scheme.
func sessionCookieName(secure bool) string {
if secure {
return HostSessionCookie
}
return SessionCookie
}
const (
// sessionTTL is how long a session lives without use. Thirty days, sliding:
// every authenticated request pushes the expiry back out. An editor that
// logs you out mid-draft is hostile, and Petal auto-saves every 1.5s, so a
// surprise 401 costs real writing.
sessionTTL = 30 * 24 * time.Hour
// sessionTTLModifier is the same span as a SQLite datetime() modifier. All
// expiry math happens inside SQLite so stored values stay canonical UTC and
// never depend on the server's local clock or on Go/SQLite parsing agreeing.
sessionTTLModifier = "+30 days"
// sessionRenewAfter throttles the sliding extension: a session is only
// pushed forward once its expiry has drifted this far from the maximum. It
// turns "a write on every request" into "a write at most once an hour per
// session" while leaving the sliding window indistinguishable to the user.
sessionRenewAfter = "-1 hour"
)
// ErrNoSession means the request carried no session cookie, or one that is
// unknown or expired. It is not an internal failure: the caller is simply not
// signed in.
var ErrNoSession = errors.New("no valid session")
// SessionStore issues, validates and revokes login sessions, and is itself the
// [Resolver] the API middleware runs on.
//
// The cookie holds a random token; the table stores only its SHA-256. A dump of
// the database therefore hands an attacker no usable session — the same reason
// passwords are never stored as given. Server-side rows (rather than a signed
// stateless cookie) are what make logout and revocation actually revoke.
type SessionStore struct {
db *sql.DB
}
// NewSessionStore returns a store backed by the given database.
func NewSessionStore(db *sql.DB) *SessionStore { return &SessionStore{db: db} }
// Create issues a new session for userID and returns the token to put in the
// cookie. The token is never stored; only its hash is.
func (s *SessionStore) Create(userID, userAgent string) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(raw)
if len(userAgent) > 256 {
userAgent = userAgent[:256]
}
_, err := s.db.Exec(
`INSERT INTO sessions (id, user_id, expires_at, user_agent)
VALUES (?, ?, datetime('now', ?), ?)`,
hashToken(token), userID, sessionTTLModifier, userAgent,
)
if err != nil {
return "", err
}
return token, nil
}
// Resolve implements [Resolver]: it reads the session cookie, validates it, and
// returns the user it belongs to — extending the session's life while it does.
func (s *SessionStore) Resolve(r *http.Request) (string, error) {
token := SessionToken(r)
if token == "" {
return "", ErrNoSession
}
return s.userFor(token)
}
// SessionToken pulls the raw session token out of a request, preferring the
// __Host- spelling.
//
// Both are read because a deployment that was signing people in before the
// prefix existed has browsers holding the old name; those sessions stay valid
// and quietly re-issue under the new name at the next sign-in. The prefixed one
// wins where both are present, since it is the one another host could not have
// planted.
func SessionToken(r *http.Request) string {
for _, name := range []string{HostSessionCookie, SessionCookie} {
if c, err := r.Cookie(name); err == nil && c.Value != "" {
return c.Value
}
}
return ""
}
// userFor validates a raw token and slides its expiry forward.
func (s *SessionStore) userFor(token string) (string, error) {
id := hashToken(token)
var userID string
err := s.db.QueryRow(
`SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now')`, id,
).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNoSession
}
if err != nil {
return "", err
}
// Slide the window. Throttled, and deliberately not fatal: a failed
// extension shortens one session's life, which is no reason to reject a
// request that is otherwise perfectly authenticated.
_, _ = s.db.Exec(
`UPDATE sessions SET expires_at = datetime('now', ?)
WHERE id = ? AND expires_at < datetime('now', ?, ?)`,
sessionTTLModifier, id, sessionTTLModifier, sessionRenewAfter,
)
return userID, nil
}
// Revoke deletes the session behind a token. Unknown tokens are not an error —
// signing out of a session that is already gone is a success, not a failure.
func (s *SessionStore) Revoke(token string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, hashToken(token))
return err
}
// RevokeAll deletes every session for a user, signing them out everywhere.
func (s *SessionStore) RevokeAll(userID string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
// Prune removes expired rows and returns how many it deleted. Nothing depends
// on it for correctness — expired sessions are already rejected on lookup — it
// just keeps the table from accumulating dead rows forever.
func (s *SessionStore) Prune() (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= datetime('now')`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// hashToken maps a raw session token to the id stored in the table.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// SetSessionCookie writes the session cookie. Secure is set only when Petal is
// served over https — flagging it on a plain-http dev server would make the
// browser drop the cookie and silently break local login.
func SetSessionCookie(w http.ResponseWriter, token string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName(secure),
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL / time.Second),
})
}
// ClearSessionCookie expires the session cookie in the browser. The matching
// server-side row must be revoked separately — that's the half that counts.
//
// Both spellings are expired, not just the one currently written: a browser
// carrying a pre-prefix cookie must not be left holding it after signing out,
// which is precisely the case where "clear the cookie" is the part the user can
// see working.
func ClearSessionCookie(w http.ResponseWriter, secure bool) {
for _, name := range []string{HostSessionCookie, SessionCookie} {
if name == HostSessionCookie && !secure {
continue // the browser would reject a non-Secure __Host- cookie
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
}