fix: findings from the code review of the mail and sharing work

Two reviews, both dimensions. The serious ones, verified before fixing:

Sessions never expired server-side and survived a password reset. `Max-Age` is
a request to the browser, not a rule for the server, and `readSession` only
checked the signature -- a leaked cookie stayed valid indefinitely, and the very
reset meant to rescue a compromised account left the intruder logged in. A
demoted admin also kept admin, because the role came from the cookie. Now the
cookie carries a session epoch, `verifySession` checks the account against the
database on every request and takes the role from there, and the epoch is
bumped on password reset and on any role change. This was pre-existing, but the
reset feature promised safety it did not deliver.

"Allow download: off" did nothing for images with transparency -- exactly the
cutouts and stickers this tool exists for. The view path skipped resizing when
`has_alpha`, so the untouched original went out at full resolution, right-click
saveable. Verified: now 1500x2000 instead of 2400x3200, and the download route
returns 403.

The share title went into outgoing mail HTML unescaped while the message was
escaped -- a foreign link could be posted through the account's own
SPF/DKIM-signed mailserver. Both escaped now, plus length limits.

argon2 ran on every request for passphrase-protected links, including every
image: 130 ms and 64 MiB each. A 40-image gallery would have walked into the
memory limit, and a few dozen wrong guesses could have stalled the process.
Checked once, then a short-lived signed cookie scoped to that one link. The
passphrase moved from the query string to POST (it was landing in history and
proxy logs) and wrong guesses are now rate limited.

`days` accepted nonsense and quietly produced an unlimited link -- `Number('abc')`
is NaN and `NaN > 0` is false. `[]` coerced to 0, same result. Now rejected
outright, which matters when the pictures are of children.

Rate limits keyed on `clientAddress`, which is the *first* X-Forwarded-For
value -- set by the caller. Counting from the right instead, with the number of
trusted proxies configurable (TRUSTED_PROXIES, default 1), plus per-username and
per-recipient counters, a real limit on outgoing mail, and a limiter that
expires entries individually instead of clearing everyone's counter.

Also: expired links could still be mailed; missing PUBLIC_BASE_URL sent dead
links while reporting success; the reset endpoint's response time revealed
whether an account existed (send is no longer awaited); invalid UUIDs produced
500s; the email uniqueness race returned an HTML error page; share_views grew
without bound and is now one row per visitor per hour, hashed per link, user
agent reduced to browser and system, deleted after 90 days; double provenance
marking appended a second XMP packet with the older model instead of replacing.

21 new tests (97 total) for shares, mail and provenance -- all three were
completely untested, and that is where both reviews found the most.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-01 03:25:02 +00:00
1 parent 63d4b7d894
commit 1d78535662
22 files changed
+752 -108

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
-- Sitzungen serverseitig entwertbar machen ---------------------------------
-- Bis hierher war das Sitzungs-Cookie nur HMAC-signiert: Wer es einmal hatte,
-- kam damit unbegrenzt hinein — auch nachdem das Passwort zurückgesetzt oder
-- die Rolle entzogen wurde. Genau der Vorgang, der ein übernommenes Konto
-- retten soll, half also nicht.
--
-- `session_epoch` wird bei jedem Passwortwechsel und jeder Rollenänderung
-- hochgezählt. Das Cookie trägt den Stand mit; stimmt er nicht mehr, gilt es
-- nicht mehr. Ein Zähler statt eines Zeitstempels, weil er keine Uhrzeit-
-- Ungenauigkeiten kennt.
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_epoch integer NOT NULL DEFAULT 0;
-- Für die Namensvergabe: `renames.ts` fragt bei jedem fertigen Bild, ob ein
-- Name schon vergeben ist — ohne Index war das ein voller Tabellendurchlauf.
CREATE INDEX IF NOT EXISTS items_filename_idx ON items(filename);
-- Aufräumen: Beide Tabellen wuchsen unbegrenzt.
CREATE INDEX IF NOT EXISTS share_views_at_idx ON share_views(at);
CREATE INDEX IF NOT EXISTS password_resets_expires_idx ON password_resets(expires_at);
-- Zähler für verschiedene Besucher je Teilen-Link. Vorher wurde er bei jedem
-- Aufruf der Verwaltungsseite über `count(DISTINCT ip_hash)` aus share_views
-- gerechnet — das wurde mit wachsender Tabelle zäh und machte die Seite
-- angreifbar, weil jeder mit dem Link beliebig viele Zeilen erzeugen konnte.
ALTER TABLE shares ADD COLUMN IF NOT EXISTS besucher integer NOT NULL DEFAULT 0;
-- Nur eine Zeile je Link, Besucher und Stunde: Ohne diese Grenze konnte ein
-- Empfänger (oder ein <img>-Tag auf irgendeiner Seite) die Tabelle unbegrenzt
-- füllen und damit die Platte volllaufen lassen.
--
-- Der Stundenwert wird beim Schreiben mitgegeben statt im Index gerechnet:
-- `date_trunc` auf einem timestamptz hängt an der Zeitzonen-Einstellung und ist
-- damit nicht unveränderlich genug für einen Index.
ALTER TABLE share_views ADD COLUMN IF NOT EXISTS stunde text;
CREATE UNIQUE INDEX IF NOT EXISTS share_views_einmal_je_stunde_idx
ON share_views (share_id, kind, ip_hash, stunde)
WHERE ip_hash IS NOT NULL AND stunde IS NOT NULL;