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
+23 -6
View File
@@ -5,6 +5,7 @@ import { SearchBox } from './SearchBox'
import { TagChip } from './TagChip'
import { LanguagePicker } from './LanguagePicker'
import { usePack, type Pack } from '../../i18n'
import { forgetAllDrafts } from '../../lib/drafts'
interface Props {
docs: DocSummary[]
@@ -172,13 +173,29 @@ export function DocList({
<span className="min-w-0 flex-1 truncate" title={account.name}>
🌸 {account.name}
</span>
<a
href="/auth/logout"
className="shrink-0 font-bold hover:underline"
style={{ color: 'var(--color-accent-hover)' }}
{/* A form, not a link: /auth/logout is POST-only, because with
SameSite=Lax a plain GET route would let any page on the internet
sign her out of her own draft. The browser still does a normal
navigation and follows the redirect home, so this behaves exactly
as the link did.
onSubmit fires before the navigation and clears the drafts this
browser is holding for her — signing out of a shared machine
should not leave her unsaved sentences behind in it. */}
<form
method="post"
action="/auth/logout"
className="shrink-0"
onSubmit={() => forgetAllDrafts()}
>
{t.docs.signOut}
</a>
<button
type="submit"
className="font-bold hover:underline"
style={{ color: 'var(--color-accent-hover)' }}
>
{t.docs.signOut}
</button>
</form>
</div>
)}
</aside>
+49 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { clearDraft, peekDraft, stashDraft, takeDraft } from './drafts'
import { clearDraft, forgetAllDrafts, peekDraft, stashDraft, takeDraft } from './drafts'
import { resetPrefsScopeForTests, setPrefsScope } from './prefs'
// The draft stash is the last thing between an expired session and lost
// writing, so these tests care about two things above all: that a rescued body
@@ -22,11 +23,13 @@ function fakeStorage(): Storage {
beforeEach(() => {
vi.stubGlobal('localStorage', fakeStorage())
resetPrefsScopeForTests()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
resetPrefsScopeForTests()
})
describe('draft rescue', () => {
@@ -76,6 +79,51 @@ describe('draft rescue', () => {
expect(peekDraft('doc-1')).toBeNull()
})
// A rescue is unsaved writing, so it belongs to the writer, not to the
// browser profile two people may be sharing.
describe('per account', () => {
it('keeps two writers apart on one browser', () => {
setPrefsScope('claire')
stashDraft('doc-1', { content_text: 'hers' })
resetPrefsScopeForTests()
setPrefsScope('wei')
expect(peekDraft('doc-1')).toBeNull()
stashDraft('doc-1', { content_text: 'his' })
expect(peekDraft('doc-1')?.body.content_text).toBe('his')
resetPrefsScopeForTests()
setPrefsScope('claire')
expect(peekDraft('doc-1')?.body.content_text).toBe('hers')
})
// A draft stashed before this browser knew who was writing still has to
// reach her — an in-flight rescue must survive the upgrade that introduced
// namespacing.
it('adopts a pre-account draft for the first writer to sign in', () => {
stashDraft('doc-1', { content_text: 'stashed before login' })
setPrefsScope('claire')
expect(peekDraft('doc-1')?.body.content_text).toBe('stashed before login')
expect(localStorage.getItem('petal.draft.doc-1')).toBeNull()
})
it('forgets only the signed-in writers drafts on sign-out', () => {
setPrefsScope('claire')
stashDraft('doc-1', { content_text: 'hers' })
resetPrefsScopeForTests()
setPrefsScope('wei')
stashDraft('doc-2', { content_text: 'his' })
forgetAllDrafts()
expect(peekDraft('doc-2')).toBeNull()
resetPrefsScopeForTests()
setPrefsScope('claire')
expect(peekDraft('doc-1')?.body.content_text).toBe('hers')
})
})
// Storage can be full, disabled, or absent. Losing the safety net is bad;
// throwing from inside a failed save is worse.
it('survives storage that refuses to write', () => {
+15 -1
View File
@@ -13,6 +13,7 @@
// server, and the entry is cleared the moment a normal save succeeds.
import type { DocUpdate } from '../api/client'
import { forgetScopedKeys, scopedKey } from './prefs'
const PREFIX = 'petal.draft.'
@@ -27,8 +28,21 @@ export interface StashedDraft {
// week later is more likely to be a surprise than a save.
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
// Namespaced by account, like every other thing this browser holds on a
// writer's behalf (see prefs). A draft is the heaviest of them: not a
// preference but unsaved writing, sitting in a profile two people may share.
// Keying it on the document id alone meant one person's rescue could surface in
// the other's editor the moment they opened the same document id — and, more
// plainly, that her sentences stayed in the browser under a name anyone looking
// could read.
function key(docId: string): string {
return PREFIX + docId
return scopedKey(PREFIX + docId)
}
// forgetAllDrafts drops every draft this browser is holding for the signed-in
// writer. Called on the way out — see the sign-out control in DocList.
export function forgetAllDrafts(): void {
forgetScopedKeys(PREFIX)
}
// stashDraft records the unsaved body for a document, replacing any earlier one
+56
View File
@@ -27,6 +27,20 @@ const listeners = new Set<Listener>()
// moment the scope becomes known.
const SCOPED_KEYS = ['petal.sound', 'petal.petals', 'petal.companion'] as const
// Families of keys whose names aren't known ahead of time — the draft rescue is
// one per document id — but which follow the account for the same reason. Held
// here so adoption can sweep them, and so there is one list of "what belongs to
// a writer in this browser" rather than two.
export const SCOPED_PREFIXES = ['petal.draft.'] as const
// isLegacyKey spots a pre-namespacing key under one of those prefixes. A scoped
// key always carries the `.u.<id>` suffix that scopedKey adds, and a document id
// never contains it, so its absence is what marks the key as belonging to the
// era before accounts.
function isLegacyKey(key: string): boolean {
return SCOPED_PREFIXES.some((p) => key.startsWith(p)) && !key.includes('.u.')
}
// scopedKey is the storage key actually used for `base` right now. Before the
// caller is known it is the legacy key, so a reload keeps working offline and
// pre-login reads see the browser's existing preference.
@@ -80,11 +94,53 @@ function adoptLegacy(): void {
}
localStorage.removeItem(base)
}
// The same move for the prefixed families. Snapshot the key list first:
// removing while iterating localStorage by index skips entries.
const legacyKeys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k && isLegacyKey(k)) legacyKeys.push(k)
}
for (const k of legacyKeys) {
const value = localStorage.getItem(k)
if (value === null) continue
if (localStorage.getItem(scopedKey(k)) === null) {
localStorage.setItem(scopedKey(k), value)
}
localStorage.removeItem(k)
}
} catch {
/* storage unavailable — nothing to adopt, and nothing breaks */
}
}
// forgetScopedKeys removes everything this browser is holding for the current
// account under the given prefix — and anything still sitting un-namespaced,
// which on a browser that has only ever had one writer is the same content.
//
// Sign-out is the moment this matters. Everything else here is a preference;
// the draft stash is unsaved *writing*, and leaving it in localStorage after
// someone has deliberately signed out of a shared machine is the one case where
// the rescue net becomes the leak.
export function forgetScopedKeys(prefix: string): void {
try {
const doomed: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (!k || !k.startsWith(prefix)) continue
// Un-namespaced keys predate accounts, so on this browser they are ours;
// namespaced ones are ours only if they carry our id. Another writer's
// rescued draft on a shared laptop is not ours to throw away.
const ours = !k.includes('.u.') || (userID !== null && k.endsWith(`.u.${userID}`))
if (ours) doomed.push(k)
}
doomed.forEach((k) => localStorage.removeItem(k))
} catch {
/* storage unavailable — there is nothing held to forget */
}
}
// resetPrefsScopeForTests unbinds the account again. Exported for tests only;
// the app sets the scope once and never clears it (signing out leaves the
// editor mounted, and the same person usually signs back in).