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:
22 files changed
+752
-108
No files matched your search
@@ -0,0 +1,66 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { istMail, mailLayout, esc } from '../src/lib/mail.ts';
|
||||
|
||||
test('Gültige Adressen werden erkannt', () => {
|
||||
for (const gut of ['mail@tillheidrich.de', 'a.b+c@sub.beispiel.co.uk', 'x@y.de', 'Oma_1949@web.de']) {
|
||||
assert.ok(istMail(gut), `fälschlich abgelehnt: ${gut}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Ungültige Adressen werden abgelehnt — auch die mit angehängtem Zeilenumbruch', () => {
|
||||
const schlecht = [
|
||||
'', ' ', 'keine-adresse', 'a@b', '@b.de', 'a@.de', 'a b@c.de', 'a@b .de',
|
||||
'a@b.de\nBcc: opfer@example.com', // Kopfzeilen-Einschleusung
|
||||
'a@b.de\r\nSubject: x',
|
||||
'a@b.de\n', // JS-„$" passt auch vor dem Zeilenende
|
||||
'a'.repeat(250) + '@b.de', // zu lang
|
||||
];
|
||||
for (const s of schlecht) assert.ok(!istMail(s), `durchgerutscht: ${JSON.stringify(s)}`);
|
||||
});
|
||||
|
||||
test('esc entschärft alles, was HTML zerlegen könnte', () => {
|
||||
assert.equal(esc('<b>x</b>'), '<b>x</b>');
|
||||
assert.equal(esc('a & b'), 'a & b');
|
||||
assert.equal(esc('sagte "hallo"'), 'sagte "hallo"');
|
||||
// Reihenfolge: & zuerst, sonst würde < zu &lt;
|
||||
assert.equal(esc('<&>'), '<&>');
|
||||
});
|
||||
|
||||
test('Die Überschrift der Mail wird immer maskiert', () => {
|
||||
const html = mailLayout('</h1><script>alert(1)</script>', ['Text']);
|
||||
assert.ok(!html.includes('<script>'), 'Skript-Tag im HTML gelandet');
|
||||
assert.match(html, /<script>/);
|
||||
});
|
||||
|
||||
test('Die Adresse des Knopfes wird maskiert', () => {
|
||||
const html = mailLayout('Titel', ['Text'], { text: 'Ansehen', url: 'https://x.de/"><script>alert(1)</script>' });
|
||||
assert.ok(!html.includes('<script>'), 'Skript-Tag über die Adresse eingeschleust');
|
||||
});
|
||||
|
||||
test('Absätze dürfen bewusst HTML enthalten — deshalb müssen Aufrufer maskieren', () => {
|
||||
// Das ist Absicht (für <b>), und genau deshalb ist es die Pflicht der
|
||||
// Aufrufer, Nutzereingaben vorher durch esc() zu schicken. Der Fehler, den
|
||||
// das Review gefunden hat, war eine Stelle, die das vergessen hatte.
|
||||
const html = mailLayout('Titel', ['Ein <b>fettes</b> Wort']);
|
||||
assert.match(html, /<b>fettes<\/b>/);
|
||||
// Und mit esc() ist es entschärft:
|
||||
assert.ok(!mailLayout('T', [esc('<b>x</b>')]).includes('<b>x</b>'));
|
||||
});
|
||||
|
||||
test('Die Mail lädt nichts von fremden Servern nach', () => {
|
||||
const html = mailLayout('Titel', ['Text'], { text: 'Los', url: 'https://klarbild.example/s/abc' });
|
||||
// Keine Bilder, keine Schriften, keine Stylesheets von außen — sonst wäre es
|
||||
// ein Zählpixel und würde in vielen Postfächern ohnehin blockiert.
|
||||
assert.ok(!/<img/i.test(html), 'Bild im Mail-HTML');
|
||||
assert.ok(!/<link/i.test(html), 'externe Ressource im Mail-HTML');
|
||||
assert.ok(!/@import|url\(/i.test(html), 'CSS lädt etwas nach');
|
||||
// Genau ein Ziel: der übergebene Link.
|
||||
const ziele = [...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
|
||||
assert.deepEqual([...new Set(ziele)], ['https://klarbild.example/s/abc']);
|
||||
});
|
||||
|
||||
test('Ohne Knopf entsteht auch kein Link', () => {
|
||||
const html = mailLayout('Titel', ['Nur Text']);
|
||||
assert.ok(!html.includes('href='), 'unerwarteter Link');
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import sharp from 'sharp';
|
||||
import {
|
||||
buildXmp, embedProvenance, readXmp,
|
||||
TRAINED_ALGORITHMIC_MEDIA, COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA,
|
||||
stripXmp,
|
||||
} from '../src/lib/provenance.ts';
|
||||
|
||||
const bild = (w = 240, h = 160) =>
|
||||
@@ -78,3 +79,40 @@ test('Zweimal kennzeichnen bricht die Datei nicht', async () => {
|
||||
const m = await sharp(zweimal).metadata();
|
||||
assert.equal(m.width, 240);
|
||||
});
|
||||
|
||||
test('Die APP1-Kennung endet auf ein echtes Nullbyte (sonst erkennt es kein Werkzeug)', async () => {
|
||||
const jpg = embedProvenance(await bild().jpeg().toBuffer(), { sourceType: TRAINED_ALGORITHMIC_MEDIA });
|
||||
const i = jpg.indexOf('http://ns.adobe.com/xap/1.0/');
|
||||
assert.ok(i > 0, 'Kennung nicht gefunden');
|
||||
assert.equal(jpg[i + 28], 0x00, 'Kennung endet nicht auf 0x00');
|
||||
});
|
||||
|
||||
test('Zweimal kennzeichnen ersetzt die alte Angabe, statt eine zweite anzuhängen', async () => {
|
||||
for (const [name, machen] of [
|
||||
['PNG', async () => bild().png().toBuffer()],
|
||||
['JPEG', async () => bild().jpeg().toBuffer()],
|
||||
] as const) {
|
||||
const roh = await machen();
|
||||
const alt = embedProvenance(roh, { sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'altes/modell' });
|
||||
const neu = embedProvenance(alt, { sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'neues/modell' });
|
||||
|
||||
// Genau **ein** Paket, und zwar das neue.
|
||||
const pakete = neu.toString('latin1').split('<x:xmpmeta').length - 1;
|
||||
assert.equal(pakete, 1, `${name}: ${pakete} XMP-Pakete statt einem`);
|
||||
assert.match(readXmp(neu)!, /neues\/modell/, `${name}: falsches Modell`);
|
||||
assert.ok(!readXmp(neu)!.includes('altes/modell'), `${name}: altes Modell noch da`);
|
||||
|
||||
// Und das Bild ist weiterhin heil.
|
||||
const m = await sharp(neu).metadata();
|
||||
assert.equal(m.width, 240, `${name}: Maße verändert`);
|
||||
assert.ok((await sharp(roh).raw().toBuffer()).equals(await sharp(neu).raw().toBuffer()),
|
||||
`${name}: Bildinhalt verändert`);
|
||||
}
|
||||
});
|
||||
|
||||
test('stripXmp lässt Dateien ohne Kennzeichnung unangetastet', async () => {
|
||||
const png = await bild().png().toBuffer();
|
||||
assert.ok(stripXmp(png).equals(png));
|
||||
const jpg = await bild().jpeg().toBuffer();
|
||||
assert.ok(stripXmp(jpg).equals(jpg));
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { neuerSlug, tageLesen, geraet, gleich, nachweisBauen, nachweisGilt, nachweisAusCookie, nachweisName,
|
||||
MAX_TITEL, MAX_NOTIZ } from '../src/lib/shares.ts';
|
||||
|
||||
process.env.SESSION_SECRET ||= 'test-geheimnis-fuer-die-tests';
|
||||
|
||||
test('Slugs sind lang genug, gut vorlesbar und verwechslungsfrei', () => {
|
||||
const gesehen = new Set<string>();
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const s = neuerSlug();
|
||||
assert.equal(s.length, 12, `falsche Länge: ${s}`);
|
||||
// Kein i, l, 0, O, 1 — sonst verhört man sich am Telefon.
|
||||
assert.match(s, /^[abcdefghjkmnpqrstuvwxyz23456789]+$/, `verbotenes Zeichen: ${s}`);
|
||||
assert.ok(!gesehen.has(s), `Slug doppelt nach ${i} Zügen: ${s}`);
|
||||
gesehen.add(s);
|
||||
}
|
||||
});
|
||||
|
||||
test('Die Zeichen sind gleichmäßig verteilt — sonst schrumpft der Suchraum', () => {
|
||||
const zaehler = new Map<string, number>();
|
||||
for (let i = 0; i < 4000; i++) for (const c of neuerSlug()) zaehler.set(c, (zaehler.get(c) || 0) + 1);
|
||||
const werte = [...zaehler.values()];
|
||||
const schnitt = werte.reduce((a, b) => a + b, 0) / werte.length;
|
||||
for (const [c, n] of zaehler) {
|
||||
assert.ok(Math.abs(n - schnitt) / schnitt < 0.25, `${c} kommt ${n}× statt ~${Math.round(schnitt)}× vor`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Unsinnige Gültigkeiten werden abgelehnt, nicht als „unbegrenzt" gedeutet', () => {
|
||||
// Genau der Fehler, den das Review gefunden hat: Number('abc') ist NaN,
|
||||
// NaN > 0 ist false — und der Link galt dann für immer.
|
||||
for (const schlecht of ['abc', -5, 1e9, Infinity, NaN, {}, [], 'morgen']) {
|
||||
assert.throws(() => tageLesen(schlecht, 30), /Gültigkeit/, `durchgerutscht: ${JSON.stringify(schlecht)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Sinnvolle Gültigkeiten kommen durch, Voreinstellung greift bei Weglassen', () => {
|
||||
assert.equal(tageLesen(7, 30), 7);
|
||||
assert.equal(tageLesen('30', 30), 30);
|
||||
assert.equal(tageLesen(0, 30), 0); // 0 = bewusst unbegrenzt
|
||||
assert.equal(tageLesen(3650, 30), 3650);
|
||||
assert.equal(tageLesen(undefined, 30), 30);
|
||||
assert.equal(tageLesen(null, 30), 30);
|
||||
assert.equal(tageLesen('', 30), 30);
|
||||
assert.equal(tageLesen(7.9, 30), 7); // abgeschnitten, nicht gerundet
|
||||
});
|
||||
|
||||
test('Aus dem User-Agent bleibt nur Browser und System übrig', () => {
|
||||
const f = [
|
||||
['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1', 'Safari · iPhone'],
|
||||
['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', 'Chrome · Windows'],
|
||||
['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/121.0', 'Firefox · Mac'],
|
||||
['Mozilla/5.0 (Linux; Android 14) Chrome/120.0 Mobile Safari/537.36', 'Chrome · Android'],
|
||||
] as const;
|
||||
for (const [ua, soll] of f) assert.equal(geraet(ua), soll, ua.slice(0, 40));
|
||||
assert.equal(geraet(''), null);
|
||||
assert.equal(geraet(null), null);
|
||||
// Der Fingerabdruck darf nicht mehr hergeben als Browser und System.
|
||||
for (const [ua] of f) assert.ok((geraet(ua) || '').length < 25, 'zu viel Detail behalten');
|
||||
});
|
||||
|
||||
test('Der Nachweis fürs Zugangswort gilt nur für seinen Link', () => {
|
||||
const a = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
const b = 'bbbbbbbb-0000-0000-0000-000000000002';
|
||||
const n = nachweisBauen(a);
|
||||
assert.ok(nachweisGilt(a, n), 'eigener Link abgelehnt');
|
||||
assert.ok(!nachweisGilt(b, n), 'Nachweis gilt für einen fremden Link');
|
||||
assert.ok(!nachweisGilt(a, 'quatsch'), 'Unsinn akzeptiert');
|
||||
assert.ok(!nachweisGilt(a, ''), 'Leerwert akzeptiert');
|
||||
assert.ok(!nachweisGilt(a, null), 'null akzeptiert');
|
||||
});
|
||||
|
||||
test('Ein abgelaufener oder gefälschter Nachweis wird abgelehnt', () => {
|
||||
const id = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
const [, sig] = nachweisBauen(id).split('.');
|
||||
const vergangen = Math.floor(Date.now() / 1000) - 10;
|
||||
assert.ok(!nachweisGilt(id, `${vergangen}.${sig}`), 'abgelaufener Nachweis kam durch');
|
||||
// Ablaufzeit hochsetzen, ohne die Signatur zu kennen → muss scheitern.
|
||||
const zukunft = Math.floor(Date.now() / 1000) + 999999;
|
||||
assert.ok(!nachweisGilt(id, `${zukunft}.${sig}`), 'manipulierte Laufzeit kam durch');
|
||||
});
|
||||
|
||||
test('Der Nachweis wird aus dem richtigen Cookie gelesen', () => {
|
||||
const kopf = `andere=x; ${nachweisName('abc123def456')}=wert.sig; noch=y`;
|
||||
assert.equal(nachweisAusCookie(kopf, 'abc123def456'), 'wert.sig');
|
||||
assert.equal(nachweisAusCookie(kopf, 'einanderer12'), null);
|
||||
assert.equal(nachweisAusCookie(null, 'abc123def456'), null);
|
||||
});
|
||||
|
||||
test('gleich() vergleicht zeitkonstant und stolpert nicht über Längen', () => {
|
||||
assert.ok(gleich('abc', 'abc'));
|
||||
assert.ok(!gleich('abc', 'abd'));
|
||||
assert.ok(!gleich('abc', 'abcd'));
|
||||
assert.ok(!gleich('', 'x'));
|
||||
assert.ok(gleich('', ''));
|
||||
});
|
||||
|
||||
test('Die Längengrenzen sind gesetzt', () => {
|
||||
assert.ok(MAX_TITEL > 0 && MAX_TITEL <= 500);
|
||||
assert.ok(MAX_NOTIZ > MAX_TITEL);
|
||||
});
|
||||
Reference in new issue
Block a user