Ten more site claims pinned to the code, and the work list retaken - #367
Conversation
docs/site-claims.md was written against the site as it stood after #323. The rewrites in #328 to #366 moved most of it, so the inventory is retaken over the 40 public pages that exist now and the coverage column names the suite that actually pins each claim. Ten claims are pinned in tests/site-claims.test.mjs, six of which the code did not carry: - two help pages offered "files up to 5 GB"; MAX_BLOB is 5 MB - /help/iot-integration sold an nginx per-IP zone as 60 uploads a minute per key - the account lockout struck from /security in #327 survived on /help - /security described the session cookie as SameSite=Strict; it is Lax - /security named SHA-256 where totp.js dual-verifies SHA-256 and SHA-1 - /security promised single-use TOTP codes over a replay guard that fails open - Argon2id was listed as blob encryption; it hashes a transfer password - the padding claim contradicted finding 5 of the audit the site publishes - the homepage sold "no IP rate limit"; no per-IP rate reads a plan - nothing held /parasend's "no third-party requests" in place Each pin is sabotage-tested from both sides: the page and the source.
…an hide from Five things the review caught on the first commit. The replacement text on /help/session-issues introduced a new untruth. It said nobody can lock you out by guessing at your address. The per-address counter in the /api/user/login handler is keyed on the address alone, incremented before the code is checked and never cleared on a successful login, so eleven requests spread over three addresses hold the victim at 429 for the rest of the window. The sentence now says what the counters do, and row 15 pins it: the three properties are read out of the handler, and while they hold no page may deny the effect. The clause rode along unpinned, which is the failure mode this file exists to prevent. Row 22 scanned tags only. A bare @import without url() and a fetch() in an inline script both reach off-origin without one, so those are checked now, along with XMLHttpRequest, WebSocket, EventSource, Worker and importScripts. The page set gains /ontvang and /parashare: PRIVATE keeps them out of the sitemap, but a share link opens them without an account, and audit finding 17 was a font stylesheet on exactly that kind of page. Row 20 now pins frontend/docs/security-audit-2026-04.md byte-identical to the copy under docs/. /security and /trust link the served one; the row is bounded by a finding in the repo one. Row 16 cited ADR R018 for the SameSite=Lax decision. The ADR says nothing about SameSite. The comment above setUserCookie is the source, and the note now says so in both the test and docs/site-claims.md. An index comparison in row 15 was hardened: indexOf returns -1 when a counter is gone, and -1 sorts before every real offset, so a deleted counter read as one that runs first. Both offsets are now required to exist.
|
Review fixes pushed in e24a4cd. The new untruth. You are right, and it is the exact failure mode this file
Row 15 reads the three properties out of the handler (keyed on the address, While pinning it I found a bug in my own check: (a) The scan. (b) /ontvang and /parashare are in the scan. The set is the public list plus (c) The report copies are pinned byte-identical in row 20, plus the two (d) The source of the SameSite decision is the comment above Tests: The relay side (the per-address counter as a lockout vector, and the NX guard |
… closed Two findings from the review of #367. 1. /api/user/login incremented paramant:user:ratelimit:email:<email> before it called findUserByEmail, refused at eleven inside fifteen minutes, and never deleted the key on a success. The address is request input, so eleven posts over three IP addresses put the owner of that address on 429 for the full window with no way to clear it. relay.js carried the same shape one layer down: userMfaAttemptOk counted attempts against a caller-supplied user_id. The per-IP refusal stays as it was, because an IP is the caller's own resource. The per-account counters now count failures only, are cleared by a successful sign-in, and impose cost rather than denial past the threshold: a proof-of-work at the admin edge (the challenge signup and password reset already use), a capped delay at the relay, which has no client to run one. 2. relay/lib/totp.js swallowed a replay-store error with .catch(() => 'OK'), so a Redis failure accepted a code the single-use key could not mark as spent. On the path that mints admin sessions that is a replay window inside the 30 second slot, and the availability it bought was imaginary: the session store is the same Redis. It fails closed now, with an error the call sites answer as 503 rather than 401, and the admin login passes that through instead of reporting a wrong code. Docs, and the pages that described the old behaviour, follow the code: SECURITY.md carries both decisions and what was given up, docs/api.md gets the two limits and the new status codes, and /security no longer advertises a per-email limit that is not one.
…bed is gone #367 landed while this branch was open and pinned the lockout it found, on purpose: /help/session-issues was made to carry the caveat that "attempts someone else makes on your address count against you too" for exactly as long as the handler kept an address-keyed counter that ran before authentication and survived a success. That is no longer the handler, so the caveat is no longer true and the row's own else-branch says to drop it. Row 15 now reads the three properties instead of requiring the old shape to exist, so the page follows whichever way they fall. Row 6 and row 18 move to the numbers and the fail-closed behaviour this branch implements. The page states the per-IP limit as the only refusal and the ten failures as the point where an attempt starts costing work.
docs/site-claims.mdwas written against the site as it stood after #323. Therewrites since (#328 to #366) renamed the free tier, gave ParaSend its own page
and rebuilt the homepage, /pricing, /about, /security, /trust, /docs, /help and
/download, so most of that inventory pointed at text that no longer exists.
Two things here. The work list is retaken over the 40 public pages that exist
now, with the suite that pins each claim named per row. And the next ten
heaviest uncovered claims are pinned in
tests/site-claims.test.mjsas rows 13to 22, each one read out of the code or the configuration rather than off
another page.
Six of the ten were claims the code does not carry. Those are corrected on the
page, not pinned as they stood.
What changed, per claim
relay/lib/tiers.jsfile_mb,relay/relay.jsMAX_BLOB(5242880)file_mbinrelay/lib/tiers.js, bounded again by the relay'sMAX_BLOB); a larger file is refused with a 413."deploy/nginx-selfhost.conf(zone=apirate=60r/m, keyed on$binary_remote_addr;zone=inboundrate=5r/m),relay/lib/tiers.jsoutbound_per_hournginx-selfhost.conf). The ceiling that is per key applies to retrieval, not upload: 50 downloads an hour on Community and 500 an hour on Pro (outbound_per_hourinrelay/lib/tiers.js), answered with a 429."admin/server.jscheckLoginRateLimit(5 per 15 min) and the/api/user/loginRedis counters (5 per IP, 10 per email, 900 s). Nothing inadmin/server.jsorrelay/relay.jslocks an accountadmin/server.jssetUserCookie:HttpOnly; Secure; SameSite=Lax; Max-Age=3600, ADR R018relay/lib/totp.js: default algorithm list['sha256', 'sha1']relay/lib/totp.js:store.set(slotKey, '1', { NX: true, EX: 90 }).catch(() => 'OK')relay/relay.js:argon2Lib.hash(password, ...)intopw_hash, verified on retrieval; optionaltry/require, 501 when absentdocs/security-audit-2026-04.mdfinding 5 (accepted trade-off);frontend/js/parashare.page.jssendstotal_chunksin the cleartotal_chunksin the clear, which places the size within an order of magnitude, per finding 5relay/lib/tiers.jsoutbound_per_hour(50 / 500); the only per-IP rate isANON_RATE_PER_HOURon the deprecated/v2/anon-inbound, which reads no planscript,link,img,iframe,source,video,audio,embed,objectorurl()pointing offparamant.appWhere the code did not carry the claim
Six of the ten were false or overstated, and are weakened or corrected rather
than pinned as written:
MAX_BLOB. Corrected to 5 MB.$binary_remote_addr, so per IP, and it was the general API zone rather thanthe stricter upload one. Rewritten to name both, and to point at the ceiling
that really is per key.
it survived word for word on /help/session-issues, with an email address for
an "early unlock" nobody can grant. Replaced with the rate limits that exist.
/dpa quoted the same limiter with a one-minute window instead of fifteen.
Lax, deliberately, and the reason is now on the page.
accepting the code gives you. The fail-open is now stated.
does not encrypt the blob and it does not derive its key, and it is an
optional module. Both crypto tables now say so.
finding 5 of the audit report this site publishes. Bounded to what the padding
actually does.
/v2/anon-inboundfossil again(the same one row 11 hunts): no per-IP rate reads a plan. Replaced with the
hourly retrieval ceiling, which is the thing a paid plan raises.
Sabotage
Every pin was broken from both sides and the run confirmed to go red, then
restored. Twenty two sabotages, all caught:
back; the lockout paragraph back; SameSite=Strict back; SHA-256 alone back;
the absolute single-use sentence back; Argon2id as blob encryption back; the
absolute padding sentence back; "no IP rate limit" back; a Google Fonts
stylesheet added to /pricing; the promise removed from /parasend.
file_mb5 to 50;MAX_BLOBto 10 MB; the api zone to 120r/m;the per-IP login cap 5 to 8; the cookie switched to Strict;
sha1droppedfrom the algorithm list; the
.catchfail-open removed; the optional argon2require made mandatory;
total_chunksremoved from both ParaShare call sites;finding 5 marked fixed instead of accepted; pro
outbound_per_hour500 to 900.Tests
node --test tests/site-claims.test.mjs: 22 pass, 0 fail (was 12).node --test $(grep -L "from 'playwright'" tests/*.mjs): 150 pass, 2 skipped,1 fail. The failure is
tests/heartbeat-lib.test.mjs, which needs@noble/post-quantum; it fails identically onorigin/mainin a checkoutwithout
npm ciand is unrelated to this branch.relay/test/pricing-page.test.js: 47 checks passed.test/parasign-store.test.js, unmetprecondition
redis, same onorigin/mainhere).bash tests/static-sanity.sh: PASS, all 11 checks clear.first-screen,pricing-fold,sign-full) were notrun: no browser binary in this environment. Every edit here sits well below
the fold on the pages those suites measure, and /pricing is untouched.
Head elements are not touched. New test blocks keep their declarations in
function scope, and the test-scope guard reports 111 suites clean.