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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user