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
This commit is contained in:
prosolis
2026-07-27 18:24:47 -07:00
parent 9a0edd6679
commit 69bf3ffde1
28 changed files with 1023 additions and 72 deletions
+67 -1
View File
@@ -64,21 +64,45 @@ func main() {
sessions := auth.NewSessionStore(database.DB)
users := auth.NewUserStore(database.DB)
// …and the fallback is exactly what must not happen quietly on a public
// host. Refuse to start rather than serve someone's journals to the open
// internet because one environment variable was misspelled. See
// config.RequireAuth for why this defaults on for any non-loopback BASE_URL.
if !cfg.AuthEnabled() && cfg.RequireAuth {
log.Fatalf("auth: refusing to start unauthenticated at %s.\n"+
" Petal would resolve every anonymous request to the single %q user, with full\n"+
" read and write over every document in the database.\n"+
" Set AUTHENTIK_URL, AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET, or set\n"+
" PETAL_REQUIRE_AUTH=false if this really is a trusted private network.",
cfg.BaseURL, db.LocalUserID)
}
var resolver auth.Resolver = auth.StaticResolver(db.LocalUserID)
var oidcClient *auth.OIDC
if cfg.AuthEnabled() {
allowed := auth.ParseAllowlist(cfg.AllowedSubs)
oidcClient = auth.NewOIDC(context.Background(), auth.Options{
IssuerURL: cfg.AuthentikURL,
ClientID: cfg.AuthentikClientID,
ClientSecret: cfg.AuthentikClientSecret,
BaseURL: cfg.BaseURL,
Allowed: auth.ParseAllowlist(cfg.AllowedSubs),
Allowed: allowed,
}, sessions, users)
resolver = sessions
if n, err := sessions.Prune(); err == nil && n > 0 {
log.Printf("auth: pruned %d expired session(s)", n)
}
log.Printf("auth: OIDC enabled (issuer=%s, redirect=%s)", cfg.AuthentikURL, oidcClient.RedirectURI())
// An empty allowlist is a legitimate choice for a single-household
// instance and a wide-open door in front of an IdP that fronts anything
// else. Petal cannot tell which it is, so it says so every boot rather
// than assuming.
if len(allowed) == 0 {
log.Printf("auth: WARNING — PETAL_ALLOWED_SUBS is empty, so EVERY account %s "+
"authenticates may sign in and start writing here. Set it to the "+
"comma-separated emails (or subject ids) that belong in this Petal.",
cfg.AuthentikURL)
}
} else {
log.Printf("auth: OIDC not configured — running as the single %q user", db.LocalUserID)
}
@@ -106,6 +130,7 @@ func main() {
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
// Build version: a hash of the embedded SPA shell. Vite rewrites index.html
// with content-hashed asset names on every build, so this string changes
@@ -231,6 +256,47 @@ func main() {
}
}
// contentSecurityPolicy is the default policy for everything Petal serves.
//
// It lives here rather than in the Traefik labels, and that move is the point:
// Traefik's customResponseHeaders *sets* a header, overwriting whatever the
// application chose, so a policy declared at the edge silently replaces the
// stricter one an individual route needs. Stored images need exactly that (an
// uploaded SVG is a document that can carry script — see internal/images), and
// a rule the edge can quietly undo is not a rule.
//
// The allowances are what the built frontend actually uses, no more: script
// only from Petal itself (Vite emits no inline script — this policy is checked
// against dist/index.html), inline *styles* because React's style={{…}} props
// compile to style attributes, and Google's font hosts because index.html links
// them. object-src and base-uri close the two attribute-injection routes that
// survive HTML escaping.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' data: https://fonts.gstatic.com; " +
"img-src 'self' data: blob:; " +
"media-src 'self' data: blob:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'self'"
// securityHeaders lays down the baseline response headers before the handler
// runs, so a route that needs something stricter — the image store — simply
// overwrites its own copy on the way past. Ordering is the mechanism: this is a
// floor, not a ceiling.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
// maxAPIBodyBytes caps a JSON API request body at 2 MiB. That's far above any
// real document save (the body is text plus lightweight marks; images upload
// separately by reference) while still bounding abuse. Exceeding it makes the