Skip to content
Closed
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
14 changes: 12 additions & 2 deletions src/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}

export function isJsonMediaType(raw: string | null): boolean {
const mediaType = (raw ?? "").split(";", 1)[0]!.trim().toLowerCase();
return mediaType === "application/json";
}

export function configuredDistributedRateLimit(raw: string | undefined): number {
const parsed = Number(raw ?? String(DEFAULT_RATE_LIMIT_PER_MINUTE));
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_RATE_LIMIT_PER_MINUTE;
Expand Down Expand Up @@ -142,11 +147,16 @@ export async function checkDistributedRateLimit(
limit: configuredDistributedRateLimit(env.NOEMA_RATE_LIMIT_PER_MINUTE),
}),
});
if (!response.ok) {
if (response.status !== 200) {
throw new DistributedRateLimitUnavailable(
`rate-limit Durable Object returned HTTP ${response.status}`,
);
}
if (!isJsonMediaType(response.headers.get("content-type"))) {
throw new DistributedRateLimitUnavailable(
"rate-limit Durable Object returned an invalid content type",
);
}
const body: unknown = await response.json();
if (!isDecision(body)) {
throw new DistributedRateLimitUnavailable(
Expand Down Expand Up @@ -178,7 +188,7 @@ export class NoemaRateLimiter {
if (request.method !== "POST" || url.pathname !== "/check") {
return jsonResponse({ ok: false, error: "not_found" }, 404);
}
if (!(request.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) {
if (!isJsonMediaType(request.headers.get("content-type"))) {
return jsonResponse({ ok: false, error: "content_type_required" }, 415);
}

Expand Down
27 changes: 27 additions & 0 deletions test/rate-limit-media-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isJsonMediaType, NoemaRateLimiter } from "../src/rate-limit";

describe("rate limit media type", () => {
it("accepts only application/json as the media type", () => {
expect(isJsonMediaType("application/json")).toBe(true);
expect(isJsonMediaType(" APPLICATION/JSON ; charset=utf-8")).toBe(true);
expect(isJsonMediaType("text/plain; profile=application/json")).toBe(false);
expect(isJsonMediaType(null)).toBe(false);
});

it("rejects a misleading JSON profile at the Durable Object boundary", async () => {
const limiter = new NoemaRateLimiter({} as DurableObjectState);
const response = await limiter.fetch(new Request("https://noema-rate-limit.internal/check", {
method: "POST",
headers: { "content-type": "text/plain; profile=application/json" },
body: JSON.stringify({ limit: 60 }),
}));

expect(response.status).toBe(415);
expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8");
await expect(response.json()).resolves.toEqual({
ok: false,
error: "content_type_required",
});
});
});
60 changes: 60 additions & 0 deletions test/rate-limit-response-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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.90" },
});

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,
};
}

describe("distributed rate-limit response protocol", () => {
it("accepts only the exact HTTP 200 JSON decision contract", async () => {
await expect(
checkDistributedRateLimit(
request,
envReturning(new Response(JSON.stringify(decision), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
})),
),
).resolves.toEqual(decision);

await expect(
checkDistributedRateLimit(request, envReturning(Response.json(decision, { status: 201 }))),
).rejects.toThrow(DistributedRateLimitUnavailable);

await expect(
checkDistributedRateLimit(
request,
envReturning(new Response(JSON.stringify(decision), {
status: 200,
headers: { "content-type": "text/plain; profile=application/json" },
})),
),
).rejects.toThrow(DistributedRateLimitUnavailable);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
Loading