Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ To get the next payload from the `foo` queue, send a get request to `/dequeue/:q
curl -X GET -H "Authorization: Bearer replace-with-a-secret-token" http://127.0.0.1:1991/dequeue/foo
```

This returns the oldest added payload on queue `foo` as JSON and removes it, guaranteeing both the order and that each payload will only be read once. Strings, numbers, booleans, arrays, and objects all use `application/json` so a string `"0"` is distinct from the number `0`. Numeric values that JavaScript cannot represent without loss are rejected with a `400` response.
This returns the oldest added payload on queue `foo` as JSON and removes it, guaranteeing both the order and that each payload will only be read once. Strings, numbers, booleans, arrays, and objects all use `application/json` so a string `"0"` is distinct from the number `0`. Numeric values that JavaScript cannot represent without loss and request bodies that are not well-formed UTF-8 are rejected with a `400` response.
JSON request bodies nested more than 3,000 container levels are also rejected with a `400` response.

That's all you need to get started! 😎

Expand Down
44 changes: 44 additions & 0 deletions docs/exploratory-testing/2026-09-21-queue/perf_repro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Perf evidence for #125: RateLimiter.isAllowed per-request cost must be
// independent of RATE_LIMIT_REQUESTS (window length).
import { RateLimiter } from "../../../src/rate_limiter.ts";

function bench(limit: number, requests: number): number {
const limiter = new RateLimiter(limit, 60000, 1_000_000, 10_000);
const makeReq = () =>
new Request("http://localhost/length/q", {
headers: { "x-forwarded-for": "10.0.0.1" },
});

// Warmup
for (let i = 0; i < limit; i++) limiter.isAllowed(makeReq());

const start = performance.now();
for (let i = 0; i < requests; i++) {
limiter.isAllowed(makeReq()); // all denied: at limit
}
return (performance.now() - start) / requests; // ms per request
}

// Fill a window of `limit`, then time denied requests. Compare limit=1000 vs 10000.
const perReqSmall = bench(1_000, 2_000);
const perReqLarge = bench(10_000, 2_000);

console.log(`limit=1,000: ${perReqSmall.toFixed(4)} ms/req`);
console.log(`limit=10,000: ${perReqLarge.toFixed(4)} ms/req`);

// The bug: cost grows linearly with window length (10x limit → ~10x cost).
const ratio = perReqLarge / perReqSmall;
console.log(
`ratio: ${ratio.toFixed(2)}x (buggy ≈ 10x, fixed ≈ 1x)`,
);

// Red-capable assertion: per-request cost must not grow with the window.
if (ratio > 3) {
console.error(
`RED: per-request cost scales with window (ratio=${
ratio.toFixed(2)
})`,
);
Deno.exit(1);
}
console.log("GREEN: per-request cost is window-independent");
2 changes: 1 addition & 1 deletion mutation/stryker.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"coverageAnalysis": "off",
"timeoutMS": 30000,
"concurrency": 2,
"concurrency": 4,
"thresholds": {
"high": 80,
"low": 70,
Expand Down
2 changes: 1 addition & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ paths:
schema:
type: string
'400':
description: Bad Request - Invalid JSON, unsupported number, or Queue name too long
description: Bad Request - Invalid JSON (including invalid UTF-8), unsupported number, or Queue name too long
'401':
description: Unauthorized
'413':
Expand Down
71 changes: 56 additions & 15 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { withAuth, withRateLimit } from "./middleware.ts";
import { RouteHandler, Router } from "./router.ts";

const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
const LOG_ENCODER = new TextEncoder();
const LOG_ENCODER = Reflect.construct(TextEncoder, []);

class UnsupportedNumberError extends Error {
constructor() {
super("Payload contains an unsupported number");
}
}

class JsonNestingTooDeepError extends Error {}

function canonicalJsonNumber(source: string): string {
const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(source);
if (match === null) {
Expand Down Expand Up @@ -40,21 +42,52 @@ function isUnsupportedNumber(value: number, source: string): boolean {
return canonicalJsonNumber(source) !== canonicalJsonNumber(serializedValue);
}

function parseJsonBody(body: string) {
return JSON.parse(body, function (key: string, value: unknown) {
void key;
if (typeof value !== "number") {
return value;
// Matches strings, containers, and number literals in valid JSON. Scanning
// the source avoids invoking a JSON.parse reviver once per JSON value.
const JSON_TOKEN = /"[^"\\]*(?:\\.[^"\\]*)*"|[[{]|[\]}]|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
const EXACT_INTEGER = /^-?\d{1,15}$/;
const MAX_JSON_DEPTH = 3000;

function rejectUnsupportedNumber(source: string): void {
// Every integer with at most 15 digits is below Number.MAX_SAFE_INTEGER.
if (EXACT_INTEGER.test(source)) {
return;
}
if (isUnsupportedNumber(Number(source), source)) {
throw new UnsupportedNumberError();
}
}

function validateJsonSource(source: string): void {
let depth = 0;
for (const match of source.matchAll(JSON_TOKEN)) {
const token = match[0];
if (token === "[" || token === "{") {
depth++;
if (depth > MAX_JSON_DEPTH) {
throw new JsonNestingTooDeepError();
}
} else if (token === "]" || token === "}") {
depth--;
} else if (token[0] !== '"') {
rejectUnsupportedNumber(token);
}
}
}

// V8 supplies context.source at runtime; Deno's JSON.parse type still
// only declares the legacy two-argument reviver signature.
const context = arguments[2] as { source: string };
if (isUnsupportedNumber(value, context.source)) {
throw new UnsupportedNumberError();
function parseJsonBody(body: string) {
try {
const json = JSON.parse(body);
validateJsonSource(body);
return json;
} catch (error) {
// Deeply nested JSON can overflow the native parser's stack; treat it
// as unparseable input rather than a server fault.
if (error instanceof RangeError) {
throw new JsonNestingTooDeepError();
}
return value;
});
throw error;
}
}

async function readRequestBody(request: Request): Promise<string | Response> {
Expand Down Expand Up @@ -101,7 +134,15 @@ async function readRequestBody(request: Request): Promise<string | Response> {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return await new Response(body).text();
return decodeUtf8Body(body);
}

function decodeUtf8Body(body: Uint8Array): string | Response {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(body);
} catch {
return new Response("Invalid JSON", { status: 400 });
}
}

function extractQueueName(match: Parameters<RouteHandler>[1]): { name: string } | { error: Response } {
Expand Down Expand Up @@ -153,7 +194,7 @@ function enqueueHandler(mgr: QueueManager<string>): RouteHandler {
}

function enqueueErrorResponse(error: unknown): Response {
if (error instanceof SyntaxError) {
if (error instanceof SyntaxError || error instanceof JsonNestingTooDeepError) {
return new Response("Invalid JSON", { status: 400 });
}
if (error instanceof UnsupportedNumberError) {
Expand Down
35 changes: 30 additions & 5 deletions src/rate_limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ export class RateLimiter {
}
}

// Index of the first timestamp still inside the window (ts > cutoff).
// Timestamps are sorted ascending, so stale entries always form a prefix.
private firstFreshIndex(timestamps: number[], cutoff: number): number {
let lo = 0, hi = timestamps.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (timestamps[mid] > cutoff) {
hi = mid;
} else {
lo = mid + 1;
}
}
return lo;
}

public isAllowed(request: Request, remoteAddr?: string): boolean {
const ip = this.getClientIp(request, remoteAddr);
const now = Date.now();
Expand All @@ -84,16 +99,26 @@ export class RateLimiter {
// Get or create timestamp list for this IP
let timestamps = this.requestTimestamps.get(ip) || [];

// Remove timestamps older than the window
timestamps = timestamps.filter(ts => ts > cutoff);
// Fast path: the newest timestamp is fresh → nothing stale, O(1).
// Otherwise binary search the first fresh timestamp — O(log n) — and
// count the window without filtering or copying it.
const firstFresh = timestamps.length > 0 && timestamps[0] <= cutoff
? this.firstFreshIndex(timestamps, cutoff)
: 0;
const freshCount = timestamps.length - firstFresh;

// If all timestamps are stale, remove this IP entry
if (timestamps.length === 0) {
if (freshCount === 0) {
// All timestamps are stale — remove this IP entry
this.requestTimestamps.delete(ip);
timestamps = [];
} else if (firstFresh > 0 && firstFresh * 2 >= timestamps.length) {
// Drop the stale prefix only once it dominates the array, so the
// copy stays amortized O(1) per recorded request
timestamps = timestamps.slice(firstFresh);
}

// Check if we've exceeded the limit
if (timestamps.length >= this.requestsPerMinute) {
if (freshCount >= this.requestsPerMinute) {
return false;
}

Expand Down
77 changes: 77 additions & 0 deletions tests/handler_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,41 @@ Deno.test("response body: invalid JSON returns 'Invalid JSON'", async () => {
assertEquals(await res.text(), "Invalid JSON");
});

Deno.test("enqueue of JSON nested beyond the parser's stack depth returns 400 and leaves the queue unchanged", async () => {
const handler = makeHandler();
const depth = 100_000;
const res = await handler(new Request("http://localhost/enqueue/q", {
method: "POST",
body: `{"payload":${"[".repeat(depth)}${"]".repeat(depth)}}`,
headers: auth,
}));
assertEquals(res.status, 400);
assertEquals(await res.text(), "Invalid JSON");

const dequeueRes = await handler(new Request("http://localhost/dequeue/q", {
headers: auth,
}));
assertEquals(dequeueRes.status, 204);
});

Deno.test("enqueue of nested JSON within the parser's depth still succeeds", async () => {
const handler = makeHandler();
const depth = 100;
const payload = `${"[".repeat(depth)}${"]".repeat(depth)}`;
const res = await handler(new Request("http://localhost/enqueue/q", {
method: "POST",
body: `{"payload":${payload}}`,
headers: auth,
}));
assertEquals(res.status, 200);

const dequeueRes = await handler(new Request("http://localhost/dequeue/q", {
headers: auth,
}));
assertEquals(dequeueRes.status, 200);
assertEquals(await dequeueRes.text(), payload);
});

Deno.test("enqueue of a JSON primitive returns 400 instead of throwing", async () => {
const handler = makeHandler();
const res = await handler(new Request("http://localhost/enqueue/q", {
Expand Down Expand Up @@ -1173,6 +1208,48 @@ Deno.test("API: rejects rounded decimal payloads instead of changing their preci
assertEquals(dequeueResponse.status, 204);
});

Deno.test("API: rejects invalid UTF-8 in JSON string payloads without enqueueing", async () => {
const mgr = new QueueManager(new Persistency.MemoryStore);
const handler = createHandler(mgr, API_TOKEN);
const invalidUtf8Body = Uint8Array.of(
...new TextEncoder().encode('{"payload":"caf'),
0xe9,
...new TextEncoder().encode('"}'),
);

const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/invalid-utf8", {
method: "POST",
body: invalidUtf8Body,
headers: { ...authHeaders, "Content-Type": "application/json" },
}));

assertEquals(enqueueResponse.status, 400);
assertEquals(await enqueueResponse.text(), "Invalid JSON");

const dequeueResponse = await handler(new Request("http://localhost:3000/dequeue/invalid-utf8", {
headers: authHeaders,
}));
assertEquals(dequeueResponse.status, 204);
});

Deno.test("API: round-trips valid UTF-8 in JSON string payloads", async () => {
const mgr = new QueueManager(new Persistency.MemoryStore);
const handler = createHandler(mgr, API_TOKEN);
const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/valid-utf8", {
method: "POST",
body: JSON.stringify({ payload: "café €𝄞" }),
headers: { ...authHeaders, "Content-Type": "application/json" },
}));

assertEquals(enqueueResponse.status, 200);

const dequeueResponse = await handler(new Request("http://localhost:3000/dequeue/valid-utf8", {
headers: authHeaders,
}));
assertEquals(dequeueResponse.status, 200);
assertEquals(await dequeueResponse.json(), "café €𝄞");
});

Deno.test("dequeue returns application/json for boolean payload", async () => {
const mgr = new QueueManager(new Persistency.MemoryStore);
const handler = createHandler(mgr, API_TOKEN);
Expand Down
Loading
Loading