Skip to content

Commit afc7993

Browse files
authored
fix(auth): fail open when the rate-limit Durable Object errors (#5000) (#5041)
enforceRateLimit runs as global middleware ahead of every route's own try/catch, and no app.onError is registered anywhere -- an uncaught Durable Object hiccup (eviction, migration, a rolling-deploy blip) escaped as Hono's bare, unstructured 500 for whatever route the caller happened to be hitting. Traced the 95 orb_broker_unavailable (500) events back to this middleware, not the /v1/orb/token handler itself: that handler and its DB-touching helpers were already hardened by the earlier #orb-broker-500 fix, but this shared rate-limit check ran ahead of it, unguarded, for every route. Fail open on both the DO check and its 429-denial audit write -- the rate limiter protects the app, it must not crash the request it's gating.
1 parent 5aa103b commit afc7993

2 files changed

Lines changed: 99 additions & 5 deletions

File tree

src/auth/rate-limit.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,39 @@ export async function enforceRateLimit(c: Context<{ Bindings: Env }>, routeClass
5858
if (!c.env.RATE_LIMITER) return null;
5959
const config = CONFIG[routeClass];
6060
const key = await rateLimitKey(c, routeClass);
61-
const id = c.env.RATE_LIMITER.idFromName(key);
62-
const decisionResponse = await c.env.RATE_LIMITER.get(id).fetch("https://rate-limit/check", {
63-
method: "POST",
64-
body: JSON.stringify({ key, ...config }),
65-
});
61+
let decisionResponse: Response;
62+
try {
63+
const id = c.env.RATE_LIMITER.idFromName(key);
64+
decisionResponse = await c.env.RATE_LIMITER.get(id).fetch("https://rate-limit/check", {
65+
method: "POST",
66+
body: JSON.stringify({ key, ...config }),
67+
});
68+
} catch (error) {
69+
// Fail OPEN (#5000): this middleware runs on every route ahead of the handler's own try/catch, and no
70+
// app.onError is registered anywhere -- an uncaught Durable Object hiccup (eviction, migration, a
71+
// rolling-deploy blip) previously escaped as Hono's bare, unstructured 500 for whatever route the caller
72+
// happened to be hitting, indistinguishable from a real application bug in that route. The rate limiter
73+
// exists to protect the app, not crash the request it's supposed to be gating.
74+
console.error(JSON.stringify({ level: "error", event: "rate_limit_check_failed", routeClass, message: error instanceof Error ? error.message : String(error) }));
75+
return null;
76+
}
6677
const decision = (await decisionResponse.json().catch(() => ({}))) as Partial<RateLimitDecision>;
6778
if (decisionResponse.status !== 429) {
6879
c.res.headers.set("x-ratelimit-limit", String(decision.limit ?? config.limit));
6980
c.res.headers.set("x-ratelimit-remaining", String(decision.remaining ?? config.limit));
7081
if (decision.resetAt) c.res.headers.set("x-ratelimit-reset", decision.resetAt);
7182
return null;
7283
}
84+
// Best-effort: the 429 itself must still reach the caller even if this audit write fails (#5000, same
85+
// fail-open reasoning as the DO call above).
7386
await recordAuditEvent(c.env, {
7487
eventType: "rate_limit.denied",
7588
actor: await actorHint(c),
7689
route: c.req.path,
7790
outcome: "denied",
7891
metadata: { routeClass, retryAfterSeconds: decision.retryAfterSeconds ?? null },
92+
}).catch((error) => {
93+
console.warn(JSON.stringify({ level: "warn", event: "rate_limit_denied_audit_failed", routeClass, message: error instanceof Error ? error.message : String(error) }));
7994
});
8095
return c.json(
8196
{

test/unit/auth.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,85 @@ describe("private-beta auth and rate limiting", () => {
479479
expect(JSON.parse(tokenAudit?.metadata_json ?? "{}")).toMatchObject({ retryAfterSeconds: 17 });
480480
});
481481

482+
it("REGRESSION (#5000): fails OPEN when the rate-limit Durable Object itself throws, instead of crashing the request with a bare framework 500", async () => {
483+
const throwingLimiter = {
484+
idFromName(name: string) {
485+
return name;
486+
},
487+
get() {
488+
return {
489+
async fetch() {
490+
throw new Error("Durable Object reset");
491+
},
492+
};
493+
},
494+
};
495+
const env = createTestEnv({ RATE_LIMITER: throwingLimiter as unknown as DurableObjectNamespace });
496+
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
497+
498+
await expect(enforceRateLimit(fakeContext(env, "/v1/orb/token", { "cf-connecting-ip": "203.0.113.9" }), "strict")).resolves.toBeNull();
499+
500+
expect(errors.mock.calls.some(([line]) => typeof line === "string" && line.includes("rate_limit_check_failed") && line.includes("\"routeClass\":\"strict\""))).toBe(true);
501+
errors.mockRestore();
502+
});
503+
504+
it("REGRESSION (#5000): still fails open when the Durable Object rejects with a non-Error value", async () => {
505+
const throwingLimiter = {
506+
idFromName(name: string) {
507+
return name;
508+
},
509+
get() {
510+
return {
511+
async fetch() {
512+
// Deliberately a non-Error rejection -- exercises the `error instanceof Error` false branch in the fail-open catch below.
513+
throw "not an Error instance";
514+
},
515+
};
516+
},
517+
};
518+
const env = createTestEnv({ RATE_LIMITER: throwingLimiter as unknown as DurableObjectNamespace });
519+
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
520+
521+
await expect(enforceRateLimit(fakeContext(env, "/v1/orb/token", { "cf-connecting-ip": "203.0.113.10" }), "strict")).resolves.toBeNull();
522+
523+
expect(errors.mock.calls.some(([line]) => typeof line === "string" && line.includes("rate_limit_check_failed") && line.includes("not an Error instance"))).toBe(true);
524+
errors.mockRestore();
525+
});
526+
527+
it("REGRESSION (#5000): a failed rate_limit.denied audit write does not stop the 429 itself from reaching the caller", async () => {
528+
const deniedEnv = createTestEnv({ RATE_LIMITER: rateLimiterNamespace({ status: 429, body: { resetAt: "2026-05-25T00:04:00.000Z" } }) as unknown as DurableObjectNamespace });
529+
const realPrepare = deniedEnv.DB.prepare.bind(deniedEnv.DB);
530+
deniedEnv.DB.prepare = ((sql: string) => {
531+
if (/^insert into "?audit_events"?/i.test(sql)) throw new Error("poisoned query");
532+
return realPrepare(sql);
533+
}) as typeof deniedEnv.DB.prepare;
534+
const warnings = vi.spyOn(console, "warn").mockImplementation(() => undefined);
535+
536+
const response = await enforceRateLimit(fakeContext(deniedEnv, "/v1/local/branch-analysis", { "cf-connecting-ip": "203.0.113.44" }), "expensive");
537+
538+
expect(response?.status).toBe(429);
539+
await expect(response?.json()).resolves.toMatchObject({ error: "rate_limited", routeClass: "expensive" });
540+
expect(warnings.mock.calls.some(([line]) => typeof line === "string" && line.includes("rate_limit_denied_audit_failed") && line.includes("poisoned query"))).toBe(true);
541+
warnings.mockRestore();
542+
});
543+
544+
it("REGRESSION (#5000): still fails open on a non-Error audit-write rejection", async () => {
545+
const deniedEnv = createTestEnv({ RATE_LIMITER: rateLimiterNamespace({ status: 429, body: { resetAt: "2026-05-25T00:05:00.000Z" } }) as unknown as DurableObjectNamespace });
546+
const realPrepare = deniedEnv.DB.prepare.bind(deniedEnv.DB);
547+
deniedEnv.DB.prepare = ((sql: string) => {
548+
// Deliberately a non-Error throw -- exercises the `error instanceof Error` false branch in the fail-open catch below.
549+
if (/^insert into "?audit_events"?/i.test(sql)) throw "not an Error instance";
550+
return realPrepare(sql);
551+
}) as typeof deniedEnv.DB.prepare;
552+
const warnings = vi.spyOn(console, "warn").mockImplementation(() => undefined);
553+
554+
const response = await enforceRateLimit(fakeContext(deniedEnv, "/v1/local/branch-analysis", { "cf-connecting-ip": "203.0.113.45" }), "expensive");
555+
556+
expect(response?.status).toBe(429);
557+
expect(warnings.mock.calls.some(([line]) => typeof line === "string" && line.includes("rate_limit_denied_audit_failed") && line.includes("not an Error instance"))).toBe(true);
558+
warnings.mockRestore();
559+
});
560+
482561
it("starts GitHub device flow and rejects malformed provider responses", async () => {
483562
const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id" });
484563
vi.stubGlobal("fetch", async () =>

0 commit comments

Comments
 (0)