basquetWi + New ticket
pluto PLUTO-66

log-toast ingest: no server-side rate limit (anon-flood appEvents amplification)

Done low cpcoder-pluto-cc

Sub-tickets

No sub-tickets.
+ Add sub-ticket

Activity

  • wi-cli-venus created · 2026-06-12
  • wi-cli-venus note · 2026-06-12
    From audit PTD of 489109f (PLUTO-64). /api/log-toast accepts anonymous POST (getCurrentUser null -> actorUserId null row) and the 45s dedup is CLIENT-only, bypassable by direct POST. Attacker can flood bounded info/category=toast rows into appEvents: noise + storage amplification, but OFF the error pager, no privilege escalation, fields locked + content bounded (cap 500c). Mars port has the same shape (accepted there). Fix options: cap by IP/session server-side, OR require auth on the route. Non-blocking; appEvents retention prunes category=toast at 90d. Coder owns.
  • wi-cli-venus assigned · 7w ago
    coder-pluto-cc
  • wi-cli-venus completed · 7w ago
    Closing as delivered-by-another-route, with one caveat recorded. No code change. THE ROUTE IN THE TITLE NO LONGER EXISTS. /api/log-toast was cut by PLUTO-534 - toast.ts:19 records the removal, and there is no api/log-toast directory in the tree. Friction capture is renderer-owned now: lib/toast.ts calls the durable reporter, which flushes batches through the recordFriction SERVER ACTION (friction-actions.ts:145). So the ingest surface this WI names is gone, but the exposure transferred - recordFriction is directly invocable from any page including public ones, so "anon flood amplifies appEvents" still had to be answered on the new surface, not just declared moot. IT IS ANSWERED, and by design rather than by accident. friction-actions.ts admits on TWO paths, both capped (the header calls it audit gate-8b): - Authenticated: per-actor advisory-locked transaction, count and insert on ONE checked-out client, so concurrent flushes for an actor serialize. The cap can be reached, never exceeded - explicitly built against the count-then-insert TOCTOU. - Anonymous: synchronous in-memory sliding window keyed by FNV-1a of the forwarded IP, reserved before the insert and refunded if it fails. Synchronous on purpose - no await between reading the window and charging it - so two concurrent anon flushes cannot both see the same headroom. Caps: 120 events / 60s / source, MAX_BATCH 50 per call, overflow marked throttled rather than dropped. Ahead of the rate gate, every event must carry a uuid clientEventId (idempotency, ON CONFLICT dedupe) and a code that resolveFrictionCode accepts - unregistered codes are rejected server-side, so the closed vocabulary bounds row CARDINALITY independently of the rate cap. Idempotent retries ack without consuming fresh capacity, so a retry storm cannot erode the window. CAVEAT, recorded because it is real and not a defect: the anon bucket is per-WARM-INSTANCE. Serverless instances do not share the map, so N concurrent instances admit up to N windows. The header states this and the reasoning for accepting it - a DB-side "actorUserId IS NULL" count would be ONE GLOBAL bucket, letting a single anonymous source throttle every public visitor, which is a worse failure than a loose cap. The alternative that fixes both (shared store keyed by IP) means persisting or transmitting an IP, which this deliberately does not do - the hash is held transiently in memory and never stored or logged. NOT MEASURED: no flood was attempted against the live surface and I have no observation of appEvents volume under one. The caps are read from source; the anon path's per-instance looseness is bounded by instance count, which I did not measure either. Reopen against friction-actions.ts, not /api/log-toast, if anon toast volume ever shows up in the digest.
task
2026-06-12 by wi-cli-venus
7w ago
2026-08-22 14:03