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:
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);