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
+6 -3
View File
@@ -129,7 +129,10 @@ func (o *OIDC) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/login", o.login)
r.Get("/callback", o.callback)
r.Get("/logout", o.logout)
// POST only. Signing out is a state change, and SameSite=Lax deliberately
// *does* send the session cookie on a top-level cross-site GET — so a GET
// route here means any page on the internet can sign her out mid-draft by
// linking to it, or embedding it as an image. Small harm, free to remove.
r.Post("/logout", o.logout)
return r
}
@@ -275,8 +278,8 @@ func (o *OIDC) callback(w http.ResponseWriter, r *http.Request) {
// matters: clearing only the cookie leaves a token that still works if it was
// ever captured.
func (o *OIDC) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
if err := o.sessions.Revoke(c.Value); err != nil {
if token := SessionToken(r); token != "" {
if err := o.sessions.Revoke(token); err != nil {
log.Printf("auth: revoke failed: %v", err)
}
}
+14 -1
View File
@@ -234,7 +234,7 @@ func TestLoginRoundTrip(t *testing.T) {
// Signing out revokes server-side, not just in the browser.
out := httptest.NewRecorder()
flow.ServeHTTP(out, jar.attach(httptest.NewRequest(http.MethodGet, "/logout", nil)))
flow.ServeHTTP(out, jar.attach(httptest.NewRequest(http.MethodPost, "/logout", nil)))
if out.Code != http.StatusFound {
t.Fatalf("logout status=%d", out.Code)
}
@@ -243,6 +243,19 @@ func TestLoginRoundTrip(t *testing.T) {
}
}
// Signing out is a state change, so it must not be reachable by GET: with
// SameSite=Lax the session cookie *is* sent on a top-level cross-site
// navigation, which would let any page on the internet sign her out mid-draft.
func TestLogoutRejectsGET(t *testing.T) {
_, flow, _, _ := newFlow(t, nil)
out := httptest.NewRecorder()
flow.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/logout", nil))
if out.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET /logout status=%d, want 405", out.Code)
}
}
// A callback whose state doesn't match the cookie is a forged one.
func TestCallbackRejectsBadState(t *testing.T) {
idp, flow, sessions, _ := newFlow(t, nil)
+65 -15
View File
@@ -11,8 +11,31 @@ import (
"time"
)
// SessionCookie is the cookie carrying the opaque session token.
const SessionCookie = "petal_session"
// 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:
@@ -78,11 +101,28 @@ func (s *SessionStore) Create(userID, userAgent string) (string, error) {
// 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) {
c, err := r.Cookie(SessionCookie)
if err != nil || c.Value == "" {
token := SessionToken(r)
if token == "" {
return "", ErrNoSession
}
return s.userFor(c.Value)
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.
@@ -146,7 +186,7 @@ func hashToken(token string) string {
// browser drop the cookie and silently break local login.
func SetSessionCookie(w http.ResponseWriter, token string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie,
Name: sessionCookieName(secure),
Value: token,
Path: "/",
HttpOnly: true,
@@ -158,14 +198,24 @@ func SetSessionCookie(w http.ResponseWriter, token string, secure bool) {
// 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) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
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,
})
}
}
+64
View File
@@ -309,3 +309,67 @@ func TestAllowlist(t *testing.T) {
}
}
}
// 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)
}
}
}
+49 -1
View File
@@ -1,7 +1,9 @@
package config
import (
"net/url"
"os"
"strconv"
"strings"
"time"
)
@@ -61,6 +63,22 @@ type Config struct {
// authenticates — right for a single-household instance, wrong the moment
// the IdP serves an audience wider than Petal's.
AllowedSubs string
// RequireAuth refuses to start when OIDC isn't configured, instead of
// falling back to the single local user.
//
// The fallback is the right behaviour on a laptop and a catastrophe on a
// public host: a typo in AUTHENTIK_CLIENT_SECRET turns every anonymous
// visitor into the `local` user, with full read and write over someone's
// private journals, and says so only in a log line nobody is reading. The
// Traefik basic-auth gate that used to stand behind that mistake was
// removed when Petal learned to authenticate for itself, so nothing catches
// it now.
//
// Defaulted from BASE_URL rather than declared: a Petal that knows itself by
// a real public origin has no business running open, and one on localhost
// has no business demanding an IdP. Set PETAL_REQUIRE_AUTH explicitly to
// override in either direction.
RequireAuth bool
}
// TTSVoice is one Piper instance and the single voice it has loaded.
@@ -77,9 +95,10 @@ func (c *Config) AuthEnabled() bool {
// Load reads configuration from the environment, applying sane local-dev defaults.
func Load() *Config {
baseURL := env("BASE_URL", "http://localhost:8080")
return &Config{
Port: env("PORT", "8080"),
BaseURL: env("BASE_URL", "http://localhost:8080"),
BaseURL: baseURL,
DatabasePath: env("DATABASE_PATH", "./data/petal.db"),
ImageDir: env("IMAGE_DIR", "./data/images"),
DictPath: env("DICT_PATH", "./data/dict.db"),
@@ -101,9 +120,26 @@ func Load() *Config {
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
AllowedSubs: env("PETAL_ALLOWED_SUBS", ""),
RequireAuth: envBool("PETAL_REQUIRE_AUTH", !isLoopbackOrigin(baseURL)),
}
}
// isLoopbackOrigin reports whether a base URL names this machine — the shape a
// development checkout has, and the only shape where running without a login is
// a reasonable default. Anything else (a hostname, a public origin) is a
// deployment, however small.
func isLoopbackOrigin(baseURL string) bool {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return false
}
switch strings.ToLower(u.Hostname()) {
case "localhost", "127.0.0.1", "::1", "":
return true
}
return false
}
// ttsVoices reads the Piper instances out of an environment slice (as returned
// by os.Environ) into a map keyed by base language tag.
//
@@ -169,6 +205,18 @@ func env(key, fallback string) string {
return fallback
}
// envBool reads a boolean knob. Anything unparseable keeps the default rather
// than silently reading as false — a mistyped PETAL_REQUIRE_AUTH must not be the
// thing that turns the guard off.
func envBool(key string, fallback bool) bool {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
+48
View File
@@ -86,3 +86,51 @@ func TestTTSVoicesEmptyWithoutEndpoints(t *testing.T) {
t.Errorf("discovered %v, want none", voices)
}
}
// The fallback to the single local user is right on a laptop and a catastrophe
// on a public host, so it is defaulted from the origin Petal knows itself by
// rather than left to be remembered.
func TestRequireAuthDefaultsFromBaseURL(t *testing.T) {
cases := []struct {
baseURL string
want bool
}{
{"http://localhost:8080", false},
{"http://127.0.0.1:8080", false},
{"http://[::1]:8080", false},
{"", false}, // no BASE_URL set at all: the local-dev default
{"https://petal.parodia.dev", true},
{"http://petal.parodia.dev", true},
{"https://petal.example.com/", true},
}
for _, c := range cases {
t.Setenv("BASE_URL", c.baseURL)
t.Setenv("PETAL_REQUIRE_AUTH", "")
if got := Load().RequireAuth; got != c.want {
t.Errorf("BASE_URL=%q: RequireAuth=%v, want %v", c.baseURL, got, c.want)
}
}
}
// The default is a default, not a rule: a trusted private network is a real
// deployment shape, and so is wanting the guard on locally.
func TestRequireAuthExplicitOverride(t *testing.T) {
t.Setenv("BASE_URL", "https://petal.parodia.dev")
t.Setenv("PETAL_REQUIRE_AUTH", "false")
if Load().RequireAuth {
t.Error("an explicit false must be honoured on a public origin")
}
t.Setenv("BASE_URL", "http://localhost:8080")
t.Setenv("PETAL_REQUIRE_AUTH", "true")
if !Load().RequireAuth {
t.Error("an explicit true must be honoured on localhost")
}
// A typo must not be the thing that disables the guard.
t.Setenv("BASE_URL", "https://petal.parodia.dev")
t.Setenv("PETAL_REQUIRE_AUTH", "nope")
if !Load().RequireAuth {
t.Error("an unparseable value must keep the default, not read as false")
}
}
+71 -4
View File
@@ -287,7 +287,14 @@ func mdBlock(n pmNode, depth int) string {
return "---"
case "image":
alt := n.attrStr("alt")
return fmt.Sprintf("![%s](%s)", alt, n.attrStr("src"))
src := safeURL(n.attrStr("src"))
if src == "" {
// Nowhere safe to point. Keep the alt text as plain prose — it is
// the part that carries meaning — rather than emitting an image
// whose destination was rejected. See safeURL.
return alt
}
return fmt.Sprintf("![%s](%s)", alt, src)
case "table":
return mdTable(n)
case "bulletList", "orderedList":
@@ -396,7 +403,9 @@ func applyMdMarks(n pmNode) string {
if n.hasMark("underline") {
t = "<u>" + t + "</u>"
}
if href := n.markAttr("link", "href"); href != "" {
// Same rule as the HTML export: plenty of Markdown renderers pass a
// `javascript:` destination straight through into an <a href>. See safeURL.
if href := safeURL(n.markAttr("link", "href")); href != "" {
t = "[" + t + "](" + href + ")"
}
return t
@@ -518,7 +527,16 @@ func htmlBlock(n pmNode) string {
return "<hr>\n"
case "image":
alt := htmlEscape(n.attrStr("alt"))
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\n", htmlEscape(n.attrStr("src")), alt)
src := safeURL(n.attrStr("src"))
if src == "" {
// Nowhere safe to point: keep the alt text, which is the part that
// carries meaning, rather than emitting a broken image.
if alt == "" {
return ""
}
return "<p>" + alt + "</p>\n"
}
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\n", htmlEscape(src), alt)
case "table":
return htmlTable(n)
case "bulletList", "orderedList":
@@ -617,7 +635,9 @@ func applyHTMLMarks(n pmNode) string {
if n.hasMark("highlight") {
t = "<mark>" + t + "</mark>"
}
if href := n.markAttr("link", "href"); href != "" {
// An unsafe href is dropped, not the link: the words stay, they just stop
// being clickable. See safeURL.
if href := safeURL(n.markAttr("link", "href")); href != "" {
t = fmt.Sprintf("<a href=\"%s\">%s</a>", htmlEscape(href), t)
}
return t
@@ -858,6 +878,53 @@ func htmlEscape(s string) string {
return r.Replace(s)
}
// safeURLSchemes are the schemes an exported document may point at. Escaping
// makes a URL safe to sit inside an attribute; it says nothing about what
// happens when the attribute is followed, and `javascript:` survives it
// untouched.
//
// The toolbar can't produce one — it prefixes anything it doesn't recognise
// with https:// — but the toolbar is not the only way in: PUT /api/docs/{id}
// stores whatever Tiptap JSON it is given. And an export is the one artifact
// here that is *meant* to leave: the passport and the .html backup are files a
// writer hands to a teacher or an editor, opened on a machine that has no
// reason to trust them. A link that runs code when clicked is not something to
// ship inside one.
//
// Relative and fragment links pass through: they're how a document refers to
// its own headings, and they can't reach anything.
var safeURLSchemes = map[string]bool{
"http": true, "https": true, "mailto": true, "tel": true, "ftp": true,
}
// safeURL returns u if it is safe to follow from an exported file, and "" if it
// isn't. A dropped href leaves the link text in place — the reader loses a
// destination, never the writing.
func safeURL(u string) string {
trimmed := strings.TrimSpace(u)
if trimmed == "" {
return ""
}
// A scheme is everything before the first ':', but only when no '/', '?' or
// '#' comes first — otherwise "notes/a:b" would read as the "notes/a" scheme.
// Nothing before a colon means a relative or fragment link, which is fine.
if i := strings.IndexAny(trimmed, ":/?#"); i >= 0 && trimmed[i] == ':' {
// Control characters and whitespace are stripped by browsers *before*
// the scheme is read, so "java\nscript:" is javascript:. Fold them out
// before deciding rather than after.
scheme := strings.Map(func(r rune) rune {
if r <= ' ' || r == 0x7f {
return -1
}
return r
}, trimmed[:i])
if !safeURLSchemes[strings.ToLower(scheme)] {
return ""
}
}
return trimmed
}
func xmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
+72
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// richDocJSON is a Tiptap document exercising headings, marks, and a list —
@@ -243,3 +245,73 @@ func TestExportUnsupportedFormat(t *testing.T) {
t.Fatalf("expected 400 for unsupported format, got %d", rec.Code)
}
}
// Escaping makes a URL safe to sit inside an attribute; it says nothing about
// what happens when the attribute is followed. An export is the one artifact
// here meant to leave — the file handed to a teacher, opened on a machine with
// no reason to trust it — so a destination that runs code is dropped.
func TestExportDropsUnsafeLinkSchemes(t *testing.T) {
unsafe := []string{
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"java\nscript:alert(1)", // browsers strip control characters first
" javascript:alert(1)",
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
"vbscript:msgbox(1)",
}
for _, href := range unsafe {
if got := safeURL(href); got != "" {
t.Errorf("safeURL(%q) = %q, want it dropped", href, got)
}
}
safe := []string{
"https://example.com/a?b=1#c",
"http://example.com",
"mailto:her@example.com",
"/api/images/abc.png",
"#a-heading",
"notes/chapter:one.md", // a colon that isn't a scheme
}
for _, href := range safe {
if got := safeURL(href); got != href {
t.Errorf("safeURL(%q) = %q, want it kept", href, got)
}
}
}
// End to end through the renderers: an unsafe href loses its destination, never
// its words.
func TestRenderedExportsCarryNoScriptURLs(t *testing.T) {
doc := db.Document{
Title: "Notes",
Content: `{"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"click me","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]},
{"type":"image","attrs":{"src":"javascript:alert(2)","alt":"a drawing"}}]}`,
}
html, err := renderHTMLFile(doc)
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(string(html)), "javascript:") {
t.Fatalf("html export carried a javascript: URL:\n%s", html)
}
if !strings.Contains(string(html), "click me") {
t.Fatal("html export dropped the link text along with the href")
}
if !strings.Contains(string(html), "a drawing") {
t.Fatal("html export dropped the alt text of the rejected image")
}
md, err := renderMarkdown(doc)
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(string(md)), "javascript:") {
t.Fatalf("markdown export carried a javascript: URL:\n%s", md)
}
if !strings.Contains(string(md), "click me") {
t.Fatal("markdown export dropped the link text along with the href")
}
}
+15
View File
@@ -35,3 +35,18 @@ func ServerError(w http.ResponseWriter, err error) {
log.Printf("internal error: %v", err)
ErrorJSON(w, http.StatusInternalServerError, "something went wrong")
}
// UpstreamError is ServerError's counterpart for a dependency Petal calls out
// to — the model, chiefly. Same discipline, and for a sharper reason: a dial
// failure's error text contains the endpoint it failed to dial, so relaying it
// hands anyone who can reach Petal the address of the inference box on the far
// side of the VPN, along with which backend is running there.
//
// `what` names the pass for the operator's log ("checkpoint", "chat"). The
// browser is told only that the helper is unreachable, which is all the client
// ever did anything with: every LLM route's 502 renders as the same warm
// "小助手在休息 · Petal's helper is resting".
func UpstreamError(w http.ResponseWriter, what string, err error) {
log.Printf("upstream error (%s): %v", what, err)
ErrorJSON(w, http.StatusBadGateway, "Petal's helper is out of reach right now")
}
+62 -1
View File
@@ -37,6 +37,17 @@ import (
// small enough to keep a careless paste from filling the disk.
const maxUploadBytes = 10 << 20
// maxUserBytes caps what one account may keep stored, at 1 GiB. The per-upload
// limit bounds a single careless paste; nothing bounded ten thousand of them,
// and Petal's data directory is an 8 GiB encrypted volume shared with the
// database, the backups and the TTS cache — the disk filling is the database
// losing writes, not just images failing.
//
// A tenth of the volume per writer is far past any real use: a heavily
// illustrated journal is tens of megabytes. It is a runaway backstop, and it is
// deliberately generous enough that nobody writing normally will ever meet it.
const maxUserBytes = 1 << 30
// extByContentType maps the image types we accept to a canonical extension. The
// allowlist doubles as validation: anything not here is rejected.
var extByContentType = map[string]string{
@@ -177,6 +188,19 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
name := hex.EncodeToString(sum[:])[:32] + ext
path := filepath.Join(h.dir, name)
userID := auth.UserID(r.Context())
within, err := h.withinQuota(userID, name, int64(len(data)))
if err != nil {
log.Printf("images: quota check failed for %s: %v", userID, err)
http.Error(w, "could not store image", http.StatusInternalServerError)
return
}
if !within {
http.Error(w, "you've filled Petal's picture store — delete a few images and try again",
http.StatusInsufficientStorage)
return
}
// Skip the write if this exact content is already stored.
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
if err := os.WriteFile(path, data, 0o644); err != nil {
@@ -190,7 +214,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
if _, err := h.db.Exec(
`INSERT INTO images (name, user_id, content_type, size) VALUES (?, ?, ?, ?)
ON CONFLICT (name, user_id) DO NOTHING`,
name, auth.UserID(r.Context()), ct, len(data),
name, userID, ct, len(data),
); err != nil {
log.Printf("images: could not record ownership of %s: %v", name, err)
http.Error(w, "could not store image", http.StatusInternalServerError)
@@ -221,6 +245,20 @@ func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
}
// Private: a shared cache must never hand one writer's image to another.
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
// SVG is a document format wearing an image's name: it can carry <script>,
// and this route serves it from Petal's own origin. Rendered through an
// <img> — the only way the editor ever shows one — that script never runs.
// Navigated to directly, which is one "open image in new tab" away, it does,
// and it runs with the API of whoever opened it.
//
// So every stored image answers with a CSP that permits nothing at all
// except the inline styles an illustration legitimately carries. It costs
// pasted SVGs nothing (an <img> was already a script-free context) and
// leaves the direct-navigation case inert. nosniff is set at the edge, but
// repeated here so the guarantee doesn't depend on Traefik's config.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, path)
}
@@ -261,6 +299,29 @@ func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// withinQuota reports whether userID may store one more image of size bytes.
//
// An image the caller already owns is free: content addressing means re-pasting
// the same picture stores nothing new, and charging for it would let a document
// that merely repeats one illustration walk into the limit. Deduplication
// across *accounts* is not credited the same way — two people each keep their
// own claim on a shared file, because either of them deleting it must not
// depend on what the other did.
func (h *Handler) withinQuota(userID, name string, size int64) (bool, error) {
var used, already sql.NullInt64
if err := h.db.QueryRow(
`SELECT (SELECT COALESCE(SUM(size), 0) FROM images WHERE user_id = ?),
(SELECT size FROM images WHERE user_id = ? AND name = ?)`,
userID, userID, name,
).Scan(&used, &already); err != nil {
return false, err
}
if already.Valid {
return true, nil // already stored for this account — costs nothing more
}
return used.Int64+size <= maxUserBytes, nil
}
// owns reports whether userID has a claim on a stored image.
func (h *Handler) owns(name, userID string) bool {
var ok bool
+82
View File
@@ -240,3 +240,85 @@ func TestServeMissing(t *testing.T) {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
// An SVG is a document, not a picture: it can carry <script>, and this route
// serves it from Petal's own origin. Rendered through an <img> that script
// never runs, but "open image in new tab" is one click away, and there it
// would — with the API of whoever opened it. Every stored image therefore
// answers with a CSP that permits nothing.
func TestStoredImagesAreServedInert(t *testing.T) {
_, alice, _ := newStore(t)
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>fetch('/api/docs')</script></svg>`)
rec := httptest.NewRecorder()
alice.ServeHTTP(rec, uploadReq(t, "image", svg))
if rec.Code != http.StatusOK {
t.Fatalf("svg upload code=%d body=%s", rec.Code, rec.Body)
}
var resp struct{ URL string }
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
name := strings.TrimPrefix(resp.URL, "/api/images/")
got := get(t, alice, name)
if got.Code != http.StatusOK {
t.Fatalf("serve code=%d", got.Code)
}
csp := got.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "sandbox") {
t.Fatalf("CSP %q does not neutralize the response", csp)
}
if got.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("stored images must be served nosniff")
}
}
// A per-upload cap bounds one careless paste; nothing bounded ten thousand of
// them, on the same volume the database lives on.
func TestUploadQuota(t *testing.T) {
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)
}
h, err := New(t.TempDir(), database.DB, db.LocalUserID)
if err != nil {
t.Fatalf("new store: %v", err)
}
alice := auth.Middleware(auth.StaticResolver(db.LocalUserID))(h.Routes())
bob := auth.Middleware(auth.StaticResolver("bob"))(h.Routes())
// Fill Alice's allowance by hand — uploading a gibibyte in a test would be
// absurd, and what's under test is the accounting, not the arithmetic.
name := upload(t, alice, pngBytes)
if _, err := database.Exec(
`UPDATE images SET size = ? WHERE user_id = ? AND name = ?`,
int64(maxUserBytes), db.LocalUserID, name,
); err != nil {
t.Fatal(err)
}
// Re-storing something she already has costs nothing, so it still works.
if again := upload(t, alice, pngBytes); again != name {
t.Fatalf("a re-upload of an owned image should dedupe, got %q", again)
}
// Anything new does not.
rec := httptest.NewRecorder()
alice.ServeHTTP(rec, uploadReq(t, "image", otherPNG))
if rec.Code != http.StatusInsufficientStorage {
t.Fatalf("over-quota upload code=%d, want 507", rec.Code)
}
// And it is *her* allowance, not the store's: Bob is unaffected.
if got := upload(t, bob, otherPNG); got == "" {
t.Fatal("one writer's quota must not stop another writing")
}
}
+7 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"net/url"
@@ -95,10 +96,15 @@ func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) {
writeLookup(w, res)
}
// writeLookupErr answers a failed lookup. The real error is a dictionary or
// database fault — a file path, a SQLite message — and belongs in the log, not
// in a tooltip. The client treats any non-200 the same way, so nothing is lost
// by saying less.
func writeLookupErr(w http.ResponseWriter, err error) {
log.Printf("lexicon: lookup failed: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
_ = json.NewEncoder(w).Encode(map[string]string{"error": "lookup failed"})
}
func writeLookup(w http.ResponseWriter, v any) {
+1 -1
View File
@@ -75,7 +75,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
if err != nil {
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
// still appropriate since we haven't written SSE headers yet.
httputil.ErrorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
httputil.UpstreamError(w, "chat", err)
return
}
+1 -1
View File
@@ -283,7 +283,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// the per-document slot for the full interval — stranding the frontend's
// auto-retry on the throttle path. Release it so a retry can re-run.
limiter.Release(docID, slotAt)
httputil.ErrorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
httputil.UpstreamError(w, "pass", err)
return
}
+1 -1
View File
@@ -69,7 +69,7 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
if err != nil {
httputil.ErrorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
httputil.UpstreamError(w, "rewrite", err)
return
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, llm.LangFor(pairLang))
if err != nil {
httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
httputil.UpstreamError(w, "translate", err)
return
}
+77
View File
@@ -21,6 +21,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"unicode/utf8"
@@ -97,6 +98,7 @@ type Handler struct {
cacheDir string
format audioFormat
client *http.Client
writes atomic.Uint64 // cache writes since boot; drives the prune throttle
}
// New builds a Handler from config. It returns (nil, false) when TTS_ENDPOINT is
@@ -233,6 +235,7 @@ func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
if err := os.WriteFile(tmp, audio, 0o644); err == nil {
_ = os.Rename(tmp, path)
}
h.pruneCache()
}
w.Header().Set("Content-Type", h.format.contentType)
@@ -240,6 +243,80 @@ func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(audio)
}
// maxCacheBytes bounds the whole clip cache at 512 MiB.
//
// Each clip is small, so nothing about ordinary reading approaches this — a
// year of tapping words is tens of megabytes. What it bounds is the shape of
// the endpoint: the cache key is the *text*, so a client asking for four
// thousand distinct characters at a time writes a new file every request, for
// as long as it cares to. That is an authenticated writer filling the same
// encrypted volume the database lives on, and a full disk is SQLite failing to
// write, not merely read-aloud getting slower.
const maxCacheBytes = 512 << 20
// pruneEvery throttles the sweep: checking the directory on every synthesis
// would stat the whole cache for each new word. Synthesis is already the slow
// path and misses are rare once a writer settles, so one sweep per this many
// cache writes keeps the cost invisible while still converging long before the
// limit means anything.
const pruneEvery = 64
// pruneCache trims the cache back under maxCacheBytes, oldest-first, and is a
// no-op the great majority of the time it is called.
//
// Oldest by modification time is a fair approximation of least-recently-useful
// here: a clip is written once and only ever read afterwards, so its age is how
// long ago someone wanted it. Evicting one costs a re-synthesis, never data —
// which is why this can be as approximate as it likes, and why every error
// along the way is simply given up on.
func (h *Handler) pruneCache() {
if n := h.writes.Add(1); n%pruneEvery != 0 {
return
}
entries, err := os.ReadDir(h.cacheDir)
if err != nil {
return
}
type clip struct {
path string
size int64
mod time.Time
}
var clips []clip
var total int64
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
clips = append(clips, clip{filepath.Join(h.cacheDir, e.Name()), info.Size(), info.ModTime()})
total += info.Size()
}
if total <= maxCacheBytes {
return
}
sort.Slice(clips, func(i, j int) bool { return clips[i].mod.Before(clips[j].mod) })
// Drop to 80% rather than exactly to the line, so the next few hundred
// clips don't each trigger another sweep.
target := int64(maxCacheBytes / 100 * 80)
removed := 0
for _, c := range clips {
if total <= target {
break
}
if os.Remove(c.path) == nil {
total -= c.size
removed++
}
}
fmt.Fprintf(os.Stderr, "tts: cache over %d bytes — evicted %d oldest clip(s)\n", int64(maxCacheBytes), removed)
}
// serve streams a cached clip with a long-lived immutable cache header (the URL
// is content-addressed, so the bytes never change for a given request).
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {