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
374 lines
15 KiB
Go
374 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"flag"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/auth"
|
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/docs"
|
|
"gitea.parodia.dev/drwily/petal/internal/images"
|
|
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
|
"gitea.parodia.dev/drwily/petal/internal/spell"
|
|
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
|
"gitea.parodia.dev/drwily/petal/internal/tts"
|
|
"gitea.parodia.dev/drwily/petal/internal/vocab"
|
|
"gitea.parodia.dev/drwily/petal/web"
|
|
)
|
|
|
|
func main() {
|
|
backupTo := flag.String("backup", "",
|
|
"write a consistent copy of the database to this path and exit (no server)")
|
|
flag.Parse()
|
|
|
|
cfg := config.Load()
|
|
|
|
// Backup mode short-circuits before anything else starts: no migrations, no
|
|
// seed, no listener. It runs against the live database safely (VACUUM INTO
|
|
// takes only a read transaction), so the nightly job is
|
|
// docker compose exec petal /app/petal -backup /data/backups/<name>.db
|
|
// against the running container rather than a copy of three WAL files.
|
|
if *backupTo != "" {
|
|
if err := db.Backup(cfg.DatabasePath, *backupTo); err != nil {
|
|
log.Fatalf("backup: %v", err)
|
|
}
|
|
log.Printf("backup written to %s", *backupTo)
|
|
return
|
|
}
|
|
|
|
database, err := db.Open(cfg.DatabasePath)
|
|
if err != nil {
|
|
log.Fatalf("database: %v", err)
|
|
}
|
|
defer database.Close()
|
|
log.Printf("database ready at %s", cfg.DatabasePath)
|
|
|
|
// Identity. With Authentik configured, Petal is an OIDC client in its own
|
|
// right: /auth/login starts a real login and the session cookie it issues is
|
|
// what every API request is resolved from. Without it — local development,
|
|
// and every deployment before auth landed — StaticResolver hands out the
|
|
// single hardcoded local user, so nothing about running Petal on a laptop
|
|
// changes.
|
|
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: 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)
|
|
}
|
|
|
|
// The dictionary behind word lookups. dict.db is DreamDict's built database
|
|
// — French, European Portuguese, Spanish and Mandarin in one read-only file
|
|
// beside petal.db. It is optional on purpose: a laptop checkout has never
|
|
// had one, and the Chinese pair doesn't need one, so its absence downgrades
|
|
// lookups rather than stopping Petal. A file that is present but broken is
|
|
// a different matter and gets said out loud.
|
|
dict, err := lexicon.OpenDreamDict(cfg.DictPath)
|
|
if err != nil {
|
|
log.Printf("dictionary: %s unusable (%v) — falling back to the embedded datasets", cfg.DictPath, err)
|
|
}
|
|
defer dict.Close()
|
|
lexSet := lexicon.NewSet(dict)
|
|
if lexSet.HasDreamDict() {
|
|
log.Printf("dictionary: DreamDict open at %s (%s)", cfg.DictPath, lexSet.Contents())
|
|
} else {
|
|
log.Printf("dictionary: no dict.db at %s — English/Chinese only", cfg.DictPath)
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
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
|
|
// exactly when a new frontend is deployed — the client polls it to know when
|
|
// to offer a refresh.
|
|
version := buildVersion()
|
|
log.Printf("frontend build version %s", version)
|
|
|
|
r.Route("/api", func(api chi.Router) {
|
|
// Cap request bodies so a runaway or hostile client can't stream an
|
|
// unbounded payload into a JSON decoder. Image uploads carry their own
|
|
// (larger) limit inside the images handler, so they're exempt here.
|
|
api.Use(limitBody(maxAPIBodyBytes, "/api/images"))
|
|
|
|
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
api.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Never cache: a stale cached version would defeat the whole check.
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write([]byte(`{"version":"` + version + `"}`))
|
|
})
|
|
|
|
// Everything below serves or mutates a particular user's data, so it sits
|
|
// behind the auth middleware. /health and /version deliberately stay
|
|
// outside it: they carry no user data, and a monitoring probe (or the
|
|
// client's update poll) must not need a session to reach them.
|
|
//
|
|
// The middleware resolves the caller once and hands handlers the answer via
|
|
// auth.UserID(r.Context()). Which resolver it runs is the only thing that
|
|
// changed when auth landed: the session store in a deployment with
|
|
// Authentik configured, the static local user otherwise. No handler or
|
|
// query moved for either.
|
|
api.Group(func(pr chi.Router) {
|
|
pr.Use(auth.Middleware(resolver))
|
|
|
|
// Who am I? The frontend namespaces its per-account browser state by
|
|
// this id and shows the signed-in writer.
|
|
pr.Get("/me", users.MeHandler())
|
|
|
|
// …and the one thing about herself she can change: which language
|
|
// Petal is her pair in. It lives here rather than under a /settings
|
|
// tree because there is exactly one setting and it is a property of
|
|
// the user row — the same row /me reads back.
|
|
pr.Patch("/me", users.UpdateMeHandler())
|
|
|
|
llmClient := llm.NewLLMClient(cfg)
|
|
sug := suggestions.New(database, llmClient)
|
|
|
|
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
|
// both under /api/docs.
|
|
docsHandler := docs.New(database)
|
|
docsRouter := docsHandler.Routes()
|
|
sug.RegisterDocRoutes(docsRouter)
|
|
pr.Mount("/docs", docsRouter)
|
|
|
|
// Tag management (the roster) and cross-document full-text search.
|
|
pr.Mount("/tags", docsHandler.TagRoutes())
|
|
pr.Mount("/search", docsHandler.SearchRoutes())
|
|
|
|
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
|
pr.Mount("/suggestions", sug.Routes())
|
|
|
|
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
|
|
// the right-click popover, and the lightweight gloss-only lookup for the
|
|
// inline hover/select tooltip. One handler over one provider Set, so the
|
|
// embedded datasets and dict.db are each opened once. Which of them
|
|
// answers depends on the caller's language pair — so unlike before, the
|
|
// response is no longer identical for everyone, and it stays behind auth
|
|
// for that reason as much as for the API surface.
|
|
lex := lexicon.NewHandler(database.DB, lexSet)
|
|
pr.Mount("/word", lex.Routes())
|
|
pr.Mount("/gloss", lex.GlossRoutes())
|
|
|
|
// Vocabulary garden: words the writer looks up are captured here and
|
|
// surfaced for gentle spaced-repetition review.
|
|
pr.Mount("/vocab", vocab.New(database).Routes())
|
|
|
|
// The personal spelling dictionary — the words she's told Petal to stop
|
|
// flagging. Kept server-side (rather than in the browser) so it belongs
|
|
// to her account and follows her between devices.
|
|
pr.Mount("/spell", spell.New(database).Routes())
|
|
|
|
// Editor image uploads, stored on disk and served back by content hash
|
|
// to whoever owns them. Files already on disk from before ownership
|
|
// existed are claimed for the local user at startup.
|
|
imgHandler, err := images.New(cfg.ImageDir, database.DB, db.LocalUserID)
|
|
if err != nil {
|
|
log.Fatalf("image store: %v", err)
|
|
}
|
|
pr.Mount("/images", imgHandler.Routes())
|
|
|
|
// Read-aloud: proxy short passages to a local Piper TTS server. Only
|
|
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
|
|
// back to the browser's Web Speech API on its own.
|
|
if ttsHandler, ok := tts.New(cfg); ok {
|
|
pr.Mount("/tts", ttsHandler.Routes())
|
|
// Name the languages, not just the English endpoint: which
|
|
// voices a deployment actually reached is the thing worth
|
|
// seeing at boot, and a missing sidecar is silent otherwise
|
|
// (a 404 the client answers by quietly using Web Speech).
|
|
log.Printf("read-aloud enabled (voices: %s)", strings.Join(ttsHandler.Languages(), ", "))
|
|
}
|
|
})
|
|
})
|
|
|
|
// Login lives outside /api: these are browser navigations, and they must be
|
|
// reachable without a session — that is their entire job.
|
|
if oidcClient != nil {
|
|
r.Mount("/auth", oidcClient.Routes())
|
|
}
|
|
|
|
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
|
r.NotFound(spaHandler())
|
|
|
|
addr := ":" + cfg.Port
|
|
log.Printf("petal listening on %s (LLM backend=%s)", addr, cfg.LLMBackend)
|
|
if err := http.ListenAndServe(addr, r); err != nil {
|
|
log.Fatalf("server error: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// handler's json.Decode fail, which surfaces as a 400.
|
|
const maxAPIBodyBytes = 2 << 20
|
|
|
|
// limitBody wraps each request body in an http.MaxBytesReader so handlers can't
|
|
// be made to read an unbounded payload. Paths under any of exemptPrefixes are
|
|
// left alone (e.g. image uploads, which set their own, larger limit).
|
|
func limitBody(max int64, exemptPrefixes ...string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
for _, p := range exemptPrefixes {
|
|
if strings.HasPrefix(r.URL.Path, p) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
if r.Body != nil {
|
|
r.Body = http.MaxBytesReader(w, r.Body, max)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// buildVersion derives a short, stable identifier for the currently embedded
|
|
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
|
|
// into that file each build, so the digest is a reliable "did the deploy
|
|
// change?" signal. Falls back to "dev" when the frontend hasn't been built.
|
|
func buildVersion() string {
|
|
data, err := fs.ReadFile(web.DistFS, "dist/index.html")
|
|
if err != nil {
|
|
return "dev"
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
// spaHandler serves the embedded web/dist as a single-page app: static files
|
|
// when they exist, falling back to index.html for unknown paths. If the
|
|
// frontend hasn't been built yet, it returns a friendly dev hint instead.
|
|
func spaHandler() http.HandlerFunc {
|
|
sub, err := fs.Sub(web.DistFS, "dist")
|
|
if err != nil {
|
|
log.Fatalf("embed sub: %v", err)
|
|
}
|
|
|
|
if _, err := fs.Stat(sub, "index.html"); errors.Is(err, fs.ErrNotExist) {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("Petal backend is running, but the frontend isn't built yet.\n" +
|
|
"Run `npm run build` in web/, or use `npm run dev` for the dev server on :5173.\n"))
|
|
}
|
|
}
|
|
|
|
fileServer := http.FileServer(http.FS(sub))
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
p := strings.TrimPrefix(req.URL.Path, "/")
|
|
if p == "" {
|
|
p = "index.html"
|
|
}
|
|
if _, err := fs.Stat(sub, p); errors.Is(err, fs.ErrNotExist) {
|
|
// Unknown path → let the SPA router handle it.
|
|
req2 := new(http.Request)
|
|
*req2 = *req
|
|
req2.URL.Path = "/"
|
|
fileServer.ServeHTTP(w, req2)
|
|
return
|
|
}
|
|
fileServer.ServeHTTP(w, req)
|
|
}
|
|
}
|