diff --git a/docs/doctoring/rate-limit-response-integrity.md b/docs/doctoring/rate-limit-response-integrity.md new file mode 100644 index 000000000..0b1a04776 --- /dev/null +++ b/docs/doctoring/rate-limit-response-integrity.md @@ -0,0 +1,46 @@ +# Rate-limit decision response integrity + +## Scope + +Noema's credential-exchange worker delegates distributed rate-limit state to a Cloudflare Durable Object. The worker treats the object response as security-relevant input because `allowed`, `limit`, `remaining`, and `retry_after_seconds` directly control whether credential exchange proceeds and what retry guidance is returned. + +The response boundary therefore fails closed before semantic validation when retained response bytes are ambiguous or exceed the reviewed protocol budget. + +## Implemented boundary + +`checkDistributedRateLimit()` continues to require HTTP 200 and `application/json`. Before JSON parsing, the caller now: + +1. rejects a declared response larger than 4,096 bytes before body consumption; +2. streams an undeclared/chunked body and aborts once the same 4,096-byte ceiling is exceeded; +3. decodes the exact bytes with fatal UTF-8 semantics rather than replacement decoding; +4. detects duplicate decoded top-level decision keys, including escape-equivalent spellings such as `allowed` and `all\u006fwed`, before JavaScript last-key-wins parsing can collapse them; +5. parses JSON only after those byte-integrity checks; and +6. preserves the existing typed decision validation and `DistributedRateLimitUnavailable` fail-closed boundary. + +The duplicate-key detector is intentionally scoped to the four security-relevant decision members instead of creating a second general-purpose JSON parser in the Worker runtime. It tracks JSON string escaping and structural depth so nested or string-contained names do not become false top-level duplicates. + +## Evidence and limitations + +`test/rate-limit-response-integrity.test.ts` exercises malformed UTF-8 embedded in an otherwise valid decision, an escape-equivalent duplicate `allowed` key, an oversized chunked response, and an oversized declared response that must be rejected before the body parser is invoked. Existing response-protocol tests retain the ordinary valid-decision path and the exact HTTP/media-type contract. + +This control protects the local Worker-to-Durable-Object response parser. It does not authenticate a different Cloudflare account, establish production deployment truth, prove release acceptance, choose an outbound license, or create acquisition evidence. Durable Object placement, platform isolation, and provider control-plane identity remain external operational evidence. + +## Standards rationale + +RFC 8259 states that object member names should be unique and notes that software behavior is unpredictable when names are not unique. I-JSON strengthens this into a requirement that object names must not be duplicated. For a decision object whose fields directly control credential-exchange admission, Noema therefore rejects duplicate decoded decision names rather than relying on a parser's last-key-wins behavior. + +Cloudflare documents Durable Object stubs as using the Fetch API model. Treating the returned `Response` as a bounded stream rather than assuming a small trusted object keeps Noema's application protocol fail closed even when `Content-Length` is absent. + +NIST's current SSDF 1.1 remains the finalized baseline, while the December 2025 SP 800-218 Rev. 1 publication is an Initial Public Draft for SSDF 1.2. This change follows the SSDF practice of defining and verifying explicit software security requirements without presenting draft guidance as a finalized standard. + +## References + +Bray, T. (2015). *The I-JSON message format* (RFC 7493). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc7493 + +Bray, T. (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259; STD 90). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc8259 + +Cloudflare. (2026). *Durable Objects API: DurableObjectStub*. Cloudflare Developers. https://developers.cloudflare.com/durable-objects/api/stub/ + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (2025). *Secure Software Development Framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). https://csrc.nist.gov/pubs/sp/800/218/r1/ipd diff --git a/src/rate-limit.ts b/src/rate-limit.ts index 5e4622df7..832132b9b 100644 --- a/src/rate-limit.ts +++ b/src/rate-limit.ts @@ -2,9 +2,16 @@ const RATE_LIMIT_WINDOW_MS = 60_000; const DEFAULT_RATE_LIMIT_PER_MINUTE = 60; const MAX_RATE_LIMIT_PER_MINUTE = 10_000; const MAX_CLIENT_IDENTIFIER_LENGTH = 128; +const MAX_RATE_LIMIT_DECISION_BYTES = 4_096; const strictIpv4SegmentPattern = /^(0|[1-9][0-9]{0,2})$/; const strictIpv6CharacterPattern = /^[0-9A-Fa-f:.]+$/; const BUCKET_KEY = "exchange-rate-limit"; +const rateLimitDecisionKeys = new Set([ + "allowed", + "limit", + "remaining", + "retry_after_seconds", +]); export interface DistributedRateLimitEnv { NOEMA_RATE_LIMIT_PER_MINUTE?: string; @@ -132,6 +139,135 @@ function isDecision(value: unknown): value is DistributedRateLimitDecision { ); } +function hasDuplicateRateLimitDecisionKey(text: string): boolean { + let structureDepth = 0; + let stringStart = -1; + let inString = false; + let escaped = false; + const seen = new Set(); + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character !== '"') continue; + + inString = false; + if (structureDepth !== 1) continue; + let lookahead = index + 1; + while (lookahead < text.length && /\s/.test(text[lookahead]!)) lookahead += 1; + if (text[lookahead] !== ":") continue; + + const encodedKey = text.slice(stringStart + 1, index); + try { + const decodedKey = JSON.parse(`"${encodedKey}"`) as string; + if (!rateLimitDecisionKeys.has(decodedKey)) continue; + if (seen.has(decodedKey)) return true; + seen.add(decodedKey); + } catch { + return false; + } + continue; + } + + if (character === '"') { + inString = true; + stringStart = index; + continue; + } + if (character === "{" || character === "[") { + structureDepth += 1; + continue; + } + if (character === "}" || character === "]") { + structureDepth -= 1; + } + } + return false; +} + +async function readBoundedRateLimitDecision(response: Response): Promise { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null + && /^\d+$/.test(declaredLength) + && Number(declaredLength) > MAX_RATE_LIMIT_DECISION_BYTES + ) { + throw new DistributedRateLimitUnavailable( + "rate-limit Durable Object decision exceeds the response byte limit", + ); + } + + if (response.body === null) { + throw new DistributedRateLimitUnavailable( + "rate-limit Durable Object returned an empty decision body", + ); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_RATE_LIMIT_DECISION_BYTES) { + try { + await reader.cancel("Noema rate-limit decision exceeds byte limit"); + } catch { + // Cancellation is best-effort after the response has already been rejected. + } + throw new DistributedRateLimitUnavailable( + "rate-limit Durable Object decision exceeds the response byte limit", + ); + } + chunks.push(value); + } + } catch (error) { + if (error instanceof DistributedRateLimitUnavailable) throw error; + throw new DistributedRateLimitUnavailable( + "rate-limit Durable Object decision body could not be read", + ); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); + } catch { + throw new DistributedRateLimitUnavailable( + "rate-limit Durable Object decision is not valid UTF-8", + ); + } + if (hasDuplicateRateLimitDecisionKey(text)) { + throw new DistributedRateLimitUnavailable( + "rate-limit Durable Object decision contains duplicate decoded keys", + ); + } + + try { + return JSON.parse(text) as unknown; + } catch { + throw new DistributedRateLimitUnavailable( + "rate-limit Durable Object returned malformed JSON", + ); + } +} + export async function checkDistributedRateLimit( request: Request, env: DistributedRateLimitEnv, @@ -157,7 +293,7 @@ export async function checkDistributedRateLimit( "rate-limit Durable Object returned an invalid content type", ); } - const body: unknown = await response.json(); + const body = await readBoundedRateLimitDecision(response); if (!isDecision(body)) { throw new DistributedRateLimitUnavailable( "rate-limit Durable Object returned an invalid decision", diff --git a/test/rate-limit-response-integrity.test.ts b/test/rate-limit-response-integrity.test.ts new file mode 100644 index 000000000..593e7cbee --- /dev/null +++ b/test/rate-limit-response-integrity.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + checkDistributedRateLimit, + DistributedRateLimitUnavailable, + type DistributedRateLimitEnv, +} from "../src/rate-limit"; + +const request = new Request("https://noema.example/exchange", { + headers: { "cf-connecting-ip": "203.0.113.91" }, +}); + +const decision = { + allowed: true, + limit: 60, + remaining: 59, + retry_after_seconds: 0, +}; + +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, + }; +} + +function jsonResponse(body: BodyInit, headers: HeadersInit = {}): Response { + return new Response(body, { + status: 200, + headers: { + "content-type": "application/json", + ...headers, + }, + }); +} + +function malformedUtf8Decision(): Uint8Array { + const prefix = new TextEncoder().encode( + '{"allowed":true,"limit":60,"remaining":59,"retry_after_seconds":0,"diagnostic":"', + ); + const suffix = new TextEncoder().encode('"}'); + const bytes = new Uint8Array(prefix.byteLength + 1 + suffix.byteLength); + bytes.set(prefix, 0); + bytes[prefix.byteLength] = 0xff; + bytes.set(suffix, prefix.byteLength + 1); + return bytes; +} + +describe("distributed rate-limit response byte integrity", () => { + it("rejects malformed UTF-8 that replacement decoding would otherwise accept", async () => { + await expect( + checkDistributedRateLimit( + request, + envReturning(jsonResponse(malformedUtf8Decision())), + ), + ).rejects.toThrow(DistributedRateLimitUnavailable); + }); + + it("rejects escape-equivalent duplicate decision keys before JSON last-key-wins parsing", async () => { + const ambiguous = + '{"allowed":false,"all\\u006fwed":true,"limit":60,"remaining":59,"retry_after_seconds":0}'; + + await expect( + checkDistributedRateLimit(request, envReturning(jsonResponse(ambiguous))), + ).rejects.toThrow(DistributedRateLimitUnavailable); + }); + + it("accepts diagnostic JSON strings without mistaking values or nested keys for decision keys", async () => { + const diagnostic = + '{"diagnostic":{"allowed":"informational"},"note":"allowed","allowed" :true,"limit":60,"remaining":59,"retry_after_seconds":0}'; + + await expect( + checkDistributedRateLimit(request, envReturning(jsonResponse(diagnostic))), + ).resolves.toMatchObject(decision); + }); + + it("rejects malformed encoded top-level decision keys before malformed JSON can be trusted", async () => { + const malformedKey = + '{"all' + '\\q' + 'wed":true,"limit":60,"remaining":59,"retry_after_seconds":0}'; + + await expect( + checkDistributedRateLimit(request, envReturning(jsonResponse(malformedKey))), + ).rejects.toThrow("rate-limit Durable Object returned malformed JSON"); + }); + + it("rejects a successful response with no decision body", async () => { + const response = new Response(null, { + status: 200, + headers: { "content-type": "application/json" }, + }); + + await expect( + checkDistributedRateLimit(request, envReturning(response)), + ).rejects.toThrow("rate-limit Durable Object returned an empty decision body"); + }); + + it("rejects a decision response when its body stream cannot be read", async () => { + const response = { + status: 200, + headers: new Headers({ "content-type": "application/json" }), + body: { + getReader() { + return { + read: async () => { + throw new Error("simulated response stream failure"); + }, + }; + }, + }, + } as unknown as Response; + + await expect( + checkDistributedRateLimit(request, envReturning(response)), + ).rejects.toThrow("rate-limit Durable Object decision body could not be read"); + }); + + it("rejects an oversized chunked decision response instead of buffering it without a protocol bound", async () => { + const oversized = JSON.stringify({ + ...decision, + diagnostic_padding: "x".repeat(8_192), + }); + + await expect( + checkDistributedRateLimit(request, envReturning(jsonResponse(oversized))), + ).rejects.toThrow(DistributedRateLimitUnavailable); + }); + + it("rejects an oversized declared response before asking the response for JSON bytes", async () => { + let jsonCalls = 0; + const response = { + status: 200, + headers: new Headers({ + "content-type": "application/json", + "content-length": "8192", + }), + json: async () => { + jsonCalls += 1; + return decision; + }, + get body(): never { + throw new Error("body must not be consumed after an oversized declaration"); + }, + } as unknown as Response; + + await expect( + checkDistributedRateLimit(request, envReturning(response)), + ).rejects.toThrow(DistributedRateLimitUnavailable); + expect(jsonCalls).toBe(0); + }); +});