crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 58b19fde1e01df66c63e5347001b24c0e1ca76a0
parent 6cfa469467c4937df01dc14a4cef87cbdecc8fa5
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 13:26:33 +0900

Fail closed and sweep stale keys in the room register limiter

This commit reverses the two softnesses knowingly accepted when the
room-creation rate limit landed. The register gate treated a missing
ENGAGEMENT_REGISTER_LIMITER binding as permission to skip the check, so
a misconfigured deploy would silently reopen unmetered room creation —
exactly the abuse the limiter exists to stop. The worker now refuses
registration with a 503 instead: clients re-register idempotently before
every connect and durable Moves sync backstops realtime, so a bad deploy
degrades live presence rather than dropping the gate.

The limiter also never deleted its per-IP rate keys, so an address that
stopped registering left its entry in Durable Object storage forever.
EngagementRegisterLimiter now arms an alarm when a write finds none
pending — at most one sweep per window even under constant traffic — and
the alarm deletes every `rate:`-prefixed key whose newest entry has aged
out of the window, re-arming only while live keys remain. The stored
entry format is unchanged, so keys already in production sweep
compatibly.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MWorkers/room-worker.js | 42+++++++++++++++++++++++++++++++++++++++++-
1 file changed, 41 insertions(+), 1 deletion(-)

diff --git a/Workers/room-worker.js b/Workers/room-worker.js @@ -40,19 +40,53 @@ export class EngagementRegisterLimiter { if (existing) { existing.at = now; await this.state.storage.put(key, recent); + await this.ensureSweepScheduled(windowMillis); return { ok: true }; } if (recent.length >= config.limit) { const retryAfterSeconds = Math.max(1, Math.ceil((recent[0].at + windowMillis - now) / 1000)); await this.state.storage.put(key, recent); + await this.ensureSweepScheduled(windowMillis); return { ok: false, retryAfterSeconds }; } recent.push({ room: roomToken, at: now }); await this.state.storage.put(key, recent); + await this.ensureSweepScheduled(windowMillis); return { ok: true }; } + + // Rate keys are written per client IP and would otherwise persist forever + // once an IP stops registering. Arm-if-unarmed keeps the sweep at most one + // per window even under constant traffic. + async ensureSweepScheduled(windowMillis) { + const scheduled = await this.state.storage.getAlarm(); + if (scheduled === null) { + await this.state.storage.setAlarm(Date.now() + windowMillis); + } + } + + async alarm() { + const config = rateLimitConfig(this.env, "ROOM_REGISTER_IP", 60, 60 * 60); + const windowMillis = config.windowSeconds * 1000; + const cutoff = Date.now() - windowMillis; + const entries = await this.state.storage.list({ prefix: "rate:" }); + let liveKeys = 0; + for (const [key, stored] of entries) { + const newest = Array.isArray(stored) + ? stored.reduce((max, entry) => (entry && typeof entry.at === "number" && entry.at > max ? entry.at : max), 0) + : 0; + if (newest <= cutoff) { + await this.state.storage.delete(key); + } else { + liveKeys += 1; + } + } + if (liveKeys > 0) { + await this.state.storage.setAlarm(Date.now() + windowMillis); + } + } } export class EngagementRoom { @@ -300,7 +334,13 @@ function roomRouteFromPath(pathname) { } async function checkRegisterRateLimit(request, env, roomID) { - if (!env.ENGAGEMENT_REGISTER_LIMITER) return null; + // Fail closed: without the limiter binding, register would be unmetered + // room creation. A misconfigured deploy should refuse registration (clients + // re-register idempotently and durable Moves sync backstops realtime), not + // silently drop the gate. + if (!env.ENGAGEMENT_REGISTER_LIMITER) { + return new Response("Rate limiter unavailable", { status: 503 }); + } const id = env.ENGAGEMENT_REGISTER_LIMITER.idFromName("register"); const url = new URL(request.url); url.searchParams.set("roomID", roomID);