Skip to content
Merged
46 changes: 46 additions & 0 deletions docs/doctoring/rate-limit-response-integrity.md
Original file line number Diff line number Diff line change
@@ -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
138 changes: 137 additions & 1 deletion src/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();

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<unknown> {
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,
Expand All @@ -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",
Expand Down
157 changes: 157 additions & 0 deletions test/rate-limit-response-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading