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
10 changes: 8 additions & 2 deletions worker/src/github-ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,14 @@ function parseIPv4(ip: string): number | null {
return ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
}

/** Check if an IPv4 address matches any of the given CIDRs. */
function ipMatchesCIDRs(ip: string, cidrs: string[]): boolean {
/**
* Check if an IPv4 address matches any of the given CIDRs.
*
* Exported for unit testing (#223). IPv6 CIDRs in the list are skipped, and an
* IPv6 / unparseable client IP passes through as `true` (GitHub rarely sends
* webhooks over IPv6, so the allowlist does not block them).
*/
export function ipMatchesCIDRs(ip: string, cidrs: string[]): boolean {
const ipInt = parseIPv4(ip);
if (ipInt === null) {
// IPv6 — check string prefix match against IPv6 CIDRs
Expand Down
92 changes: 92 additions & 0 deletions worker/test/github-ip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Unit tests for worker/src/github-ip.ts (#223).
*
* Two surfaces:
* - ipMatchesCIDRs: pure IPv4 CIDR membership math (boundaries, /32, /0,
* IPv6-CIDR skip, IPv6 / unparseable client passthrough).
* - isGitHubWebhookIP: the request-level guard. The GitHub-meta fetch is
* stubbed to fail so the function falls back to the hardcoded FALLBACK_CIDRS
* deterministically (no real network in CI).
*/
import { test } from "node:test";
import assert from "node:assert/strict";

import { ipMatchesCIDRs, isGitHubWebhookIP } from "../src/github-ip.js";

// A GitHub webhook range from the hardcoded fallback list.
const GH_RANGE = "140.82.112.0/20"; // covers 140.82.112.0 – 140.82.127.255

// ── ipMatchesCIDRs: membership math ──────────────────────────────────

test("matches an address inside a /20 range", () => {
assert.equal(ipMatchesCIDRs("140.82.112.5", [GH_RANGE]), true);
assert.equal(ipMatchesCIDRs("140.82.120.200", [GH_RANGE]), true);
});

test("respects /20 range boundaries", () => {
assert.equal(ipMatchesCIDRs("140.82.127.255", [GH_RANGE]), true, "last addr in range");
assert.equal(ipMatchesCIDRs("140.82.128.0", [GH_RANGE]), false, "first addr past range");
assert.equal(ipMatchesCIDRs("140.82.111.255", [GH_RANGE]), false, "addr just below range");
});

test("rejects an address outside every CIDR", () => {
assert.equal(ipMatchesCIDRs("8.8.8.8", [GH_RANGE, "185.199.108.0/22"]), false);
});

test("/32 matches only the exact host", () => {
assert.equal(ipMatchesCIDRs("1.2.3.4", ["1.2.3.4/32"]), true);
assert.equal(ipMatchesCIDRs("1.2.3.5", ["1.2.3.4/32"]), false);
});

test("/0 matches any IPv4 address", () => {
assert.equal(ipMatchesCIDRs("203.0.113.7", ["0.0.0.0/0"]), true);
});

test("IPv6 CIDRs in the list are skipped (no match for an IPv4 client)", () => {
assert.equal(ipMatchesCIDRs("8.8.8.8", ["2606:50c0::/32"]), false);
});

test("IPv6 client address passes through as true", () => {
assert.equal(ipMatchesCIDRs("2606:50c0::1", [GH_RANGE]), true);
});

test("unparseable IPv4 (octet > 255) passes through as true", () => {
// parseIPv4 returns null → treated as the IPv6/passthrough branch.
assert.equal(ipMatchesCIDRs("999.1.1.1", [GH_RANGE]), true);
});

// ── isGitHubWebhookIP: request guard ─────────────────────────────────

test("isGitHubWebhookIP allows requests without CF-Connecting-IP (local dev)", async () => {
const req = new Request("https://worker/webhooks/github", { method: "POST" });
assert.equal(await isGitHubWebhookIP(req), true);
});

test("isGitHubWebhookIP allows a GitHub-range IP via the fallback list", async () => {
const realFetch = globalThis.fetch;
// Force the meta fetch to fail so getGitHubHookCIDRs uses FALLBACK_CIDRS.
globalThis.fetch = (async () => { throw new Error("network disabled in test"); }) as typeof fetch;
try {
const req = new Request("https://worker/webhooks/github", {
method: "POST",
headers: { "CF-Connecting-IP": "140.82.112.50" },
});
assert.equal(await isGitHubWebhookIP(req), true);
} finally {
globalThis.fetch = realFetch;
}
});

test("isGitHubWebhookIP blocks a non-GitHub IP via the fallback list", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () => { throw new Error("network disabled in test"); }) as typeof fetch;
try {
const req = new Request("https://worker/webhooks/github", {
method: "POST",
headers: { "CF-Connecting-IP": "8.8.8.8" },
});
assert.equal(await isGitHubWebhookIP(req), false);
} finally {
globalThis.fetch = realFetch;
}
});
118 changes: 118 additions & 0 deletions worker/test/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Unit tests for worker/src/rate-limit.ts (#223).
*
* Covers the in-memory sliding-window limiter (webhook 300/min, api 120/min),
* window reset after expiry, the DO-backed checkTenantQuota branch logic, and
* the 429 helper. The limiter keeps module-level per-key state, so each test
* uses a unique IP to stay independent of the others.
*/
import { test } from "node:test";
import assert from "node:assert/strict";

import {
checkWebhookRateLimit,
checkApiRateLimit,
checkTenantQuota,
rateLimitResponse,
} from "../src/rate-limit.js";

// ── Sliding window limits ────────────────────────────────────────────

test("webhook limiter allows exactly 300 requests then blocks", () => {
const ip = "10.0.0.1";
for (let i = 1; i <= 300; i++) {
assert.equal(checkWebhookRateLimit(ip), true, `request ${i} should pass`);
}
assert.equal(checkWebhookRateLimit(ip), false, "301st request must be blocked");
});

test("api limiter allows exactly 120 requests then blocks", () => {
const ip = "10.0.0.2";
for (let i = 1; i <= 120; i++) {
assert.equal(checkApiRateLimit(ip), true, `request ${i} should pass`);
}
assert.equal(checkApiRateLimit(ip), false, "121st request must be blocked");
});

test("webhook and api counters are independent for the same IP", () => {
const ip = "10.0.0.3";
// Exhaust webhook budget.
for (let i = 0; i < 300; i++) checkWebhookRateLimit(ip);
assert.equal(checkWebhookRateLimit(ip), false, "webhook exhausted");
// API budget for the same IP is untouched.
assert.equal(checkApiRateLimit(ip), true, "api budget independent");
});

test("counter resets after the window expires", () => {
const ip = "10.0.0.4";
const realNow = Date.now;
let t = realNow();
Date.now = () => t;
try {
for (let i = 0; i < 120; i++) checkApiRateLimit(ip);
assert.equal(checkApiRateLimit(ip), false, "blocked within the window");
// Advance past the 60s window.
t += 60_001;
assert.equal(checkApiRateLimit(ip), true, "allowed again after window reset");
} finally {
Date.now = realNow;
}
});

// ── checkTenantQuota: DO-backed branch logic ─────────────────────────

/** Minimal DurableObjectStub mock that returns a fixed response and records the request. */
function stubRegistry(response: Response, captured?: { req?: Request }) {
return {
fetch: async (input: Request | string | URL, init?: RequestInit) => {
const req = input instanceof Request ? input : new Request(input, init);
if (captured) captured.req = req;
return response;
},
} as unknown as DurableObjectStub;
}

test("checkTenantQuota allows when the registry returns 200", async () => {
const captured: { req?: Request } = {};
const registry = stubRegistry(
Response.json({ allowed: true, events_stored: 1, events_limit: 10000 }),
captured,
);
const result = await checkTenantQuota(registry, 42);
assert.deepEqual(result, { allowed: true });
// It posts to the /quota-check endpoint with the account_id.
assert.equal(captured.req!.method, "POST");
assert.match(captured.req!.url, /\/quota-check$/);
assert.deepEqual(await captured.req!.json(), { account_id: 42 });
});

test("checkTenantQuota blocks with a 429 response when the registry returns 429", async () => {
const registry = stubRegistry(
Response.json({ allowed: false, reason: "quota exceeded" }, { status: 429 }),
);
const result = await checkTenantQuota(registry, 7);
assert.equal(result.allowed, false);
if (result.allowed === false) {
assert.equal(result.response.status, 429);
assert.equal(result.response.headers.get("Retry-After"), "3600");
assert.deepEqual(await result.response.json(), { error: "tenant quota exceeded" });
}
});

test("checkTenantQuota passes through (allowed) on 404 unknown tenant", async () => {
const registry = stubRegistry(
Response.json({ allowed: false, reason: "tenant not found" }, { status: 404 }),
);
const result = await checkTenantQuota(registry, 999);
// 404 is left to downstream tenant resolution; the quota gate does not block.
assert.deepEqual(result, { allowed: true });
});

// ── 429 helper ───────────────────────────────────────────────────────

test("rateLimitResponse returns 429 with Retry-After: 60", async () => {
const res = rateLimitResponse();
assert.equal(res.status, 429);
assert.equal(res.headers.get("Retry-After"), "60");
assert.equal(await res.text(), "Too Many Requests");
});
Loading
Loading