From e755e86908bbd3cf7cb838380cb1f85cf3a8db21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:32:07 +0900 Subject: [PATCH 1/3] test: reproduce locked rate-limit reader acquisition --- test/rate-limit-locked-body.test.ts | 77 +++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 test/rate-limit-locked-body.test.ts diff --git a/test/rate-limit-locked-body.test.ts b/test/rate-limit-locked-body.test.ts new file mode 100644 index 000000000..4eba34dba --- /dev/null +++ b/test/rate-limit-locked-body.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { + checkDistributedRateLimit, + DistributedRateLimitUnavailable, + type DistributedRateLimitEnv, + NoemaRateLimiter, +} from "../src/rate-limit"; + +function stateWithoutStorageAuthority(transaction: ReturnType): DurableObjectState { + return { + storage: { transaction }, + } as unknown as DurableObjectState; +} + +function envReturning(response: Response): DistributedRateLimitEnv { + return { + NOEMA_RATE_LIMITER: { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return { + fetch: async () => response, + } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace, + }; +} + +describe("distributed rate-limit locked body acquisition", () => { + it("rejects a locked internal request as malformed before storage authority", async () => { + const transaction = vi.fn(async () => { + throw new Error("storage must not be reached for a locked limiter request"); + }); + const limiter = new NoemaRateLimiter(stateWithoutStorageAuthority(transaction)); + const request = new Request("https://noema-rate-limit.internal/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"limit":60}', + }); + vi.spyOn(request.body!, "getReader").mockImplementation(() => { + throw new TypeError("simulated locked rate-limit request body"); + }); + + const response = await limiter.fetch(request); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + ok: false, + error: "malformed_json", + }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("normalizes a locked decision body to the stable unavailable contract", async () => { + const response = { + status: 200, + headers: new Headers({ "content-type": "application/json" }), + body: { + getReader(): never { + throw new TypeError("simulated locked rate-limit decision body"); + }, + }, + } as unknown as Response; + const request = new Request("https://noema.example/exchange", { + headers: { "cf-connecting-ip": "203.0.113.92" }, + }); + + await expect( + checkDistributedRateLimit(request, envReturning(response)), + ).rejects.toThrow( + new DistributedRateLimitUnavailable( + "rate-limit Durable Object decision body could not be read", + ), + ); + }); +}); From 0cf1104d2b59e8086d172ae519872c2880612ffc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:36:08 +0900 Subject: [PATCH 2/3] fix: normalize rate-limit reader acquisition --- src/rate-limit.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/rate-limit.ts b/src/rate-limit.ts index 4d4c900db..186394a76 100644 --- a/src/rate-limit.ts +++ b/src/rate-limit.ts @@ -158,7 +158,7 @@ async function sha256Hex(value: string): Promise { /** * Derives the stable privacy-preserving Durable Object name for the trusted client represented by a request. - * @param request Edge request carrying the trusted Cloudflare client-address header. + * @param request Edge request carrying the trusted Cloudflare-supplied client address. * @returns A SHA-256 hash-derived bucket name that does not expose the raw client identifier. * @throws {DistributedRateLimitUnavailable} When no trustworthy client identifier can be established. */ @@ -273,7 +273,7 @@ function cancelDecisionBodyBestEffort(response: Response, reason: string): void /** * Reads the private Durable Object rate-limit request through a fixed 256-byte buffer. - * After `getReader()` succeeds, every terminal path releases the reader lock in `finally`; declared-length overflow and null-body validation occur before reader acquisition and therefore hold no reader lock. + * Reader-acquisition failure is normalized before storage authority; after `getReader()` succeeds, every terminal path releases the reader lock in `finally`. Declared-length overflow and null-body validation occur before reader acquisition and therefore hold no reader lock. * The byte ceiling, fatal UTF-8/JSON admission, and fail-closed cancellation semantics remain authoritative. */ async function readBoundedRateLimitRequest(request: Request): Promise { @@ -294,7 +294,12 @@ async function readBoundedRateLimitRequest(request: Request): Promise; + try { + reader = request.body.getReader(); + } catch { + return { ok: false, status: 400, error: "malformed_json" }; + } const requestStorage = new Uint8Array(MAX_RATE_LIMIT_REQUEST_BYTES); let totalBytes = 0; try { @@ -341,7 +346,7 @@ async function readBoundedRateLimitRequest(request: Request): Promise { @@ -366,7 +371,14 @@ async function readBoundedRateLimitDecision(response: Response): Promise; + try { + reader = response.body.getReader(); + } catch { + throw new DistributedRateLimitUnavailable( + "rate-limit Durable Object decision body could not be read", + ); + } const decisionStorage = new Uint8Array(MAX_RATE_LIMIT_DECISION_BYTES); let totalBytes = 0; try { From 98ec07552f4081bd4ff6b25f1f3ed691c0f3f2b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:37:51 +0900 Subject: [PATCH 3/3] chore: keep rate-limit documentation delta causal --- src/rate-limit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rate-limit.ts b/src/rate-limit.ts index 186394a76..dc970898d 100644 --- a/src/rate-limit.ts +++ b/src/rate-limit.ts @@ -158,7 +158,7 @@ async function sha256Hex(value: string): Promise { /** * Derives the stable privacy-preserving Durable Object name for the trusted client represented by a request. - * @param request Edge request carrying the trusted Cloudflare-supplied client address. + * @param request Edge request carrying the trusted Cloudflare client-address header. * @returns A SHA-256 hash-derived bucket name that does not expose the raw client identifier. * @throws {DistributedRateLimitUnavailable} When no trustworthy client identifier can be established. */