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

+52 -8
View File
@@ -5,11 +5,42 @@
// die Bilder sehen und herunterladen können, sonst nichts. Keine Navigation,
// kein Zugang zur Bibliothek, kein Hinweis darauf, was es sonst noch gibt.
import Base from '../../layouts/Base.astro';
import { resolveShare, shareItems, trackShare } from '../../lib/shares';
import { resolveShare, shareItems, trackShare, nachweisBauen, nachweisGilt, nachweisAusCookie, nachweisName } from '../../lib/shares';
import { zuOft, echteIp } from '../../lib/limit';
const slug = Astro.params.slug!;
const pw = Astro.url.searchParams.get('pw');
const { status, share } = await resolveShare(slug, pw);
// Zugangswort kommt per POST (nicht als `?pw=` in der Adresse — das landete
// sonst im Verlauf, in Proxy-Protokollen und in jedem weitergeleiteten Link).
// Nach erfolgreicher Prüfung merkt sich ein kurzlebiges, auf diesen Link
// beschränktes Cookie das Ergebnis: `argon2.verify` kostet 130 ms und 64 MiB
// und lief sonst bei **jedem** Bildaufruf erneut.
let eingabe: string | null = null;
let falsch = false;
let gesperrt = false;
if (Astro.request.method === 'POST') {
// Durchprobieren begrenzen: Ein Zugangswort, das man am Telefon durchgibt,
// ist kurz — ohne Bremse wäre Raten aussichtsreich, und jede Prüfung kostet
// 130 ms und 64 MiB.
if (zuOft(`wort:${slug}:${echteIp(Astro.request, Astro.clientAddress)}`, 10, 15 * 60_000)) {
gesperrt = true;
} else {
const f = await Astro.request.formData().catch(() => null);
eingabe = String(f?.get('pw') || '') || null;
}
}
const cookieKopf = Astro.request.headers.get('cookie');
let { status, share } = await resolveShare(slug, eingabe);
// Bereits nachgewiesen? Dann ohne erneutes Hashen durchlassen.
if (status === 'passwort' && share && nachweisGilt(share.id, nachweisAusCookie(cookieKopf, slug))) {
status = 'ok';
}
if (status === 'ok' && share?.password_hash && eingabe) {
Astro.response.headers.append('Set-Cookie',
`${nachweisName(slug)}=${nachweisBauen(share.id)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=21600`);
}
if (status === 'passwort' && eingabe) falsch = true;
let bilder: Awaited<ReturnType<typeof shareItems>> = [];
if (status === 'ok' && share) {
@@ -22,8 +53,9 @@ Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, noimag
if (status !== 'ok') Astro.response.status = status === 'nicht-gefunden' ? 404 : status === 'passwort' ? 401 : 410;
const titel = share?.title || (bilder.length > 1 ? 'Bilder' : 'Bild');
const dl = (id: string) => `/api/s/${slug}/file?item=${id}&download=1${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`;
const gross = (id: string) => `/api/s/${slug}/file?item=${id}${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`;
const dl = (id: string) => `/api/s/${slug}/file?item=${id}&download=1`;
const gross = (id: string) => `/api/s/${slug}/file?item=${id}`;
const klein = (id: string) => `/api/s/${slug}/file?item=${id}&thumb=1`;
const meldung: Record<string, { h: string; t: string }> = {
'nicht-gefunden': { h: 'Diesen Link gibt es nicht', t: 'Vielleicht hat sich beim Kopieren ein Zeichen verloren. Frag am besten noch einmal nach.' },
'abgelaufen': { h: 'Dieser Link ist abgelaufen', t: 'Teilen-Links gelten nur eine begrenzte Zeit. Wer ihn dir geschickt hat, kann einen neuen erzeugen.' },
@@ -50,7 +82,11 @@ const meldung: Record<string, { h: string; t: string }> = {
{bilder.map((b) => (
<figure class="bild">
<a href={gross(b.id)} target="_blank" rel="noopener">
<img src={gross(b.id)} alt={b.filename || 'Bild'} loading="lazy" />
{/* In der Übersicht das Vorschaubild — sonst rechnet der Server
für jede Kachel ein Vollbild neu. Das große Bild kommt erst
beim Antippen. */}
<img src={bilder.length > 1 ? klein(b.id) : gross(b.id)}
alt={b.filename || 'Bild'} loading="lazy" />
</a>
<figcaption>
<span class="mass">{b.output_px?.replace('x', ' × ') || ''}</span>
@@ -67,16 +103,23 @@ const meldung: Record<string, { h: string; t: string }> = {
Am Handy: Bild antippen, dann lange darauf drücken und „Zu Fotos hinzufügen" wählen.
{share.allow_download && <> Über <b>Herunterladen</b> kommt die Datei in voller Auflösung.</>}
</p>
<p class="datenschutz">
Wer dir diesen Link geschickt hat, sieht, <b>wann</b> und mit welcher Art Gerät er geöffnet
wurde — nicht, <b>wer</b> das war. Deine Adresse wird nur verschlüsselt abgelegt und nach
90 Tagen gelöscht.
</p>
</>
) : status === 'passwort' ? (
<section class="tor">
<h1>Dieser Link ist mit einem Wort geschützt</h1>
<p>Gib das Zugangswort ein, das du zusammen mit dem Link bekommen hast.</p>
<form method="get" class="tor-form">
<form method="post" class="tor-form">
<input type="password" name="pw" placeholder="Zugangswort" aria-label="Zugangswort" autofocus required />
<button type="submit">Ansehen</button>
</form>
{pw && <p class="fehler">Das war leider nicht richtig.</p>}
{gesperrt
? <p class="fehler">Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.</p>
: falsch && <p class="fehler">Das war leider nicht richtig.</p>}
</section>
) : (
<section class="tor">
@@ -105,6 +148,7 @@ const meldung: Record<string, { h: string; t: string }> = {
.leer { color: var(--soft); }
.hinweis { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--line);
font-size: 13px; color: var(--ink-soft); line-height: 1.6; max-width: 62ch; }
.datenschutz { margin: 10px 0 0; font-size: 12px; color: var(--soft); line-height: 1.6; max-width: 62ch; }
.tor { max-width: 460px; margin: 8vh auto 0; text-align: center; }
.tor h1 { font-size: 1.4rem; margin: 0 0 12px; }