From 74c30464414fbb0675761de621147040ef21e120 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Mon, 13 Jul 2026 16:55:06 -0700 Subject: [PATCH 1/5] feat(security): add download-beacon rate-limit WAF applier + wizard integration (F5) --- README.md | 1 + package.json | 1 + scripts/apply-download-ratelimit.ts | 71 +++++++ scripts/setup.ts | 30 ++- scripts/waf-lib.test.ts | 274 ++++++++++++++++++++++++++++ scripts/waf-lib.ts | 206 +++++++++++++++++++++ 6 files changed, 581 insertions(+), 2 deletions(-) create mode 100644 scripts/apply-download-ratelimit.ts create mode 100644 scripts/waf-lib.test.ts create mode 100644 scripts/waf-lib.ts diff --git a/README.md b/README.md index eaa22522..9c9fe653 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ original deployment it grew out of). The project home is | Account · D1 · Edit | create + migrate the database | | Account · Workers R2 Storage · Edit | create the image bucket | | Zone · DNS · Edit | **only if** attaching a custom domain (writes the apex record) | + | Zone · Firewall Services · Edit | **only if** attaching a custom domain — adds a WAF rate limit on the public download beacon (`POST /api/metrics/download`) | | Zone · Zone Settings · Edit | *optional* — lets setup enable image resizing for you | Without **DNS · Edit**, registering the Pages apex domain succeeds but the DNS diff --git a/package.json b/package.json index 98896a47..c18ea2b6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "setup": "tsx scripts/setup.ts", "reset-password": "tsx scripts/reset-password.ts", "connect-domains": "tsx scripts/connect-domains.ts", + "apply-download-ratelimit": "tsx scripts/apply-download-ratelimit.ts", "predev": "tsx scripts/dev-bootstrap.ts", "dev": "vite dev", "build": "vite build", diff --git a/scripts/apply-download-ratelimit.ts b/scripts/apply-download-ratelimit.ts new file mode 100644 index 00000000..132dc4cb --- /dev/null +++ b/scripts/apply-download-ratelimit.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env tsx +/** + * Sona apply-download-ratelimit — standalone runner that applies the WAF + * rate-limit rule protecting POST /api/metrics/download to an EXISTING fork's + * zone (finding F5). New forks get the rule automatically during `npm run setup`; + * this is the one-off for forks that were already deployed. + * + * CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- + * + * The token comes from the environment (never an argv, so it can't land in shell + * history or a process listing) and is read, used as a Bearer header, and never + * printed. The domain is the fork's site domain (e.g. akito.dog); scheme/path are + * stripped. The operation is idempotent — re-running on an already-protected zone + * is a no-op. Exit 0 on created/updated/exists, 1 on error. + * + * Token scope required: Zone → Firewall Services: Edit, on a token whose Zone + * Resources include the fork's domain. (Read-only Zone·Read is enough to resolve + * the zone, but writing the rule needs Firewall Services: Edit.) + */ +import { env, argv, exit } from 'node:process'; +import { applyDownloadRateLimit } from './waf-lib.ts'; + +const TOKEN_RECIPE = + 'Set CLOUDFLARE_API_TOKEN to a Cloudflare API token (dash → My Profile → API Tokens →\n' + + 'Create Token → Custom token) with:\n' + + ' • Zone · Firewall Services · Edit\n' + + ' and a Zone Resource that includes the fork domain, then re-run:\n' + + ' CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- '; + +async function main(): Promise { + console.log('— Sona apply-download-ratelimit —\n'); + + const cfToken = env.CLOUDFLARE_API_TOKEN; + if (!cfToken) { + console.error('✖ CLOUDFLARE_API_TOKEN is not set in the environment.\n'); + console.error(TOKEN_RECIPE); + return 1; + } + + const domain = argv.slice(2).find((a) => !a.startsWith('-')) ?? ''; + if (!domain) { + console.error('✖ No domain given. Pass the fork domain as an argument:'); + console.error(' CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- '); + return 1; + } + + const res = await applyDownloadRateLimit(cfToken, domain); + switch (res.status) { + case 'created': + console.log(`✔ ${res.detail}`); + return 0; + case 'updated': + console.log(`✔ ${res.detail}`); + return 0; + case 'exists': + console.log(`✔ ${res.detail}`); + return 0; + default: + console.error(`✖ ${res.detail}\n`); + console.error(TOKEN_RECIPE); + return 1; + } +} + +main() + .then((code) => exit(code)) + .catch((err) => { + // Never surface the token; print only the error class/message. + console.error('✖ Unexpected error:', err instanceof Error ? err.message : String(err)); + exit(1); + }); diff --git a/scripts/setup.ts b/scripts/setup.ts index 715d1163..dbbbc4e3 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -39,6 +39,7 @@ import { imageResizingOutcome, cfApi } from './setup-lib.ts'; +import { applyDownloadRateLimit, type RateLimitStatus } from './waf-lib.ts'; // Shared with the admin Settings save so the seeded siteUrl passes the same // https-URL validation (validate.ts has no imports, so tsx loads it directly). import { normalizeHttpsUrl } from '../src/lib/server/validate.ts'; @@ -113,8 +114,9 @@ const TOKEN_RECIPE = ' • Account · Cloudflare Pages · Edit\n' + ' • Account · D1 · Edit\n' + ' • Account · Workers R2 Storage · Edit\n' + - ' • Zone · DNS · Edit (only if you are attaching a custom domain)\n' + - ' • Zone · Zone Settings · Edit (optional; lets setup enable image resizing for you)'; + ' • Zone · DNS · Edit (only if you are attaching a custom domain)\n' + + ' • Zone · Firewall Services · Edit (only with a custom domain; adds the download-beacon rate limit)\n' + + ' • Zone · Zone Settings · Edit (optional; lets setup enable image resizing for you)'; async function main() { console.log('— Sona setup —\n'); @@ -279,6 +281,10 @@ async function main() { // before provisioning so a missing DNS scope fails early. `imageResizingOn`: // true = on, false = off (couldn't enable), null = unknown/not checked. let imageResizingOn: boolean | null = null; + // Download-beacon WAF rate limit (finding F5). Only meaningful when the fork + // runs on a zone the operator controls — a *.pages.dev-only fork has no zone to + // attach it to. Null = not attempted (no domain / no zone / no token). + let downloadRateLimit: RateLimitStatus | null = null; if (domain) { const host = hostFromDomain(domain); if (cfToken && cfAccount) { @@ -313,6 +319,17 @@ async function main() { patchOk = enabled.ok; } imageResizingOn = imageResizingOutcome(ir, patchOk); + + // WAF rate limit for the public download beacon (finding F5). Non-fatal: + // a token without Zone · Firewall Services · Edit just yields an 'error' + // result we warn about in Next steps — setup keeps going regardless. + const rl = await applyDownloadRateLimit(cfToken, host); + downloadRateLimit = rl.status; + if (rl.status === 'error') { + console.warn(`\n⚠ Could not attach the download-beacon rate-limit rule: ${rl.detail}`); + } else { + console.log(`✔ Download-beacon rate limit: ${rl.detail}`); + } } } else { console.warn( @@ -566,6 +583,15 @@ async function main() { console.log(' "Resize images from any origin". Free tier: 5,000 transformations/month.'); console.log(' Until on, gallery thumbnails serve the full-size original (slow) or 404.'); } + // Download-beacon rate limit (finding F5). null = not attempted (no zone); + // 'error' = token lacked Zone · Firewall Services · Edit — tell them to add it. + if (downloadRateLimit === 'error') { + console.log(' • Download-beacon rate limit: NOT set (token lacks Zone · Firewall Services · Edit).'); + console.log(' Add that permission to the token, then run:'); + console.log(` CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- ${host}`); + } else if (downloadRateLimit && downloadRateLimit !== 'exists') { + console.log(` • Download-beacon rate limit: applied to the ${host} zone (blocks POST floods).`); + } } console.log('\n Your one-time setup token (enter it in the wizard):\n'); console.log(` SETUP_TOKEN = ${setupToken}`); diff --git a/scripts/waf-lib.test.ts b/scripts/waf-lib.test.ts new file mode 100644 index 00000000..9e6328c4 --- /dev/null +++ b/scripts/waf-lib.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect } from 'vitest'; +import type { CfApiResult } from './setup-lib.ts'; +import { + applyDownloadRateLimit, + buildRule, + RULE_REF, + RULE_DESCRIPTION, + RULE_EXPRESSION, + RULE_RATELIMIT +} from './waf-lib.ts'; + +const SECRET = 'cf-secret-token-value-should-never-leak'; + +interface Call { + token: string; + path: string; + method: string; + body?: unknown; +} + +/** + * Builds a fake `cfApi` that never touches the network: it records every call and + * answers from a path+method → result map. Any path not in the map returns a 500, + * which surfaces as an unexpected-call failure in assertions. + */ +function fakeApi(routes: Record) { + const calls: Call[] = []; + const api = async ( + token: string, + path: string, + init: { method?: string; body?: unknown } = {} + ): Promise => { + const method = init.method ?? 'GET'; + calls.push({ token, path, method, body: init.body }); + return routes[`${method} ${path}`] ?? routes[path] ?? { ok: false, status: 500 }; + }; + return { api, calls }; +} + +const ZONE = 'zone123'; +const RULESET = 'ruleset456'; +const zonePath = 'GET /zones?name=akito.dog'; +const entryPath = `GET /zones/${ZONE}/rulesets/phases/http_ratelimit/entrypoint`; +const zoneOk: CfApiResult = { ok: true, status: 200, result: [{ id: ZONE }] }; + +describe('buildRule', () => { + it('encodes the beacon expression, block action, ref, and rate-limit knobs', () => { + const rule = buildRule(); + expect(rule.action).toBe('block'); + expect(rule.expression).toBe(RULE_EXPRESSION); + expect(rule.ref).toBe(RULE_REF); + expect(rule.description).toBe(RULE_DESCRIPTION); + expect(rule.ratelimit).toEqual({ + characteristics: ['ip.src'], + period: 10, + requests_per_period: 20, + mitigation_timeout: 60 + }); + }); + + it('targets exactly POST /api/metrics/download', () => { + expect(RULE_EXPRESSION).toBe( + '(http.request.method eq "POST" and http.request.uri.path eq "/api/metrics/download")' + ); + }); +}); + +describe('applyDownloadRateLimit — zone resolves, rule created', () => { + it('creates the entrypoint ruleset when none exists yet (PUT payload asserted)', async () => { + const { api, calls } = fakeApi({ + [zonePath]: zoneOk, + // No http_ratelimit ruleset on the zone yet. + [entryPath]: { ok: false, status: 404 }, + [`PUT /zones/${ZONE}/rulesets/phases/http_ratelimit/entrypoint`]: { ok: true, status: 200 } + }); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.status).toBe('created'); + + const put = calls.find((c) => c.method === 'PUT'); + expect(put?.path).toBe(`/zones/${ZONE}/rulesets/phases/http_ratelimit/entrypoint`); + const rules = (put?.body as { rules: Record[] }).rules; + expect(rules).toHaveLength(1); + expect(rules[0]).toMatchObject({ + action: 'block', + expression: RULE_EXPRESSION, + ref: RULE_REF, + ratelimit: RULE_RATELIMIT + }); + }); + + it('appends only our rule when a ruleset already exists (POST add-rule, others untouched)', async () => { + const { api, calls } = fakeApi({ + [zonePath]: zoneOk, + [entryPath]: { + ok: true, + status: 200, + result: { id: RULESET, rules: [{ id: 'other', ref: 'someone_elses_rule' }] } + }, + [`POST /zones/${ZONE}/rulesets/${RULESET}/rules`]: { ok: true, status: 200 } + }); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.status).toBe('created'); + + const post = calls.find((c) => c.method === 'POST'); + // Adds a single rule to the existing ruleset — no PUT that would rewrite the set. + expect(post?.path).toBe(`/zones/${ZONE}/rulesets/${RULESET}/rules`); + expect(post?.body).toMatchObject({ ref: RULE_REF, action: 'block' }); + expect(calls.some((c) => c.method === 'PUT')).toBe(false); + }); +}); + +describe('applyDownloadRateLimit — idempotent no-op', () => { + it('returns exists and writes nothing when our rule is already present and identical', async () => { + const { api, calls } = fakeApi({ + [zonePath]: zoneOk, + [entryPath]: { + ok: true, + status: 200, + result: { + id: RULESET, + rules: [ + { + id: 'mine', + ref: RULE_REF, + description: RULE_DESCRIPTION, + action: 'block', + enabled: true, + expression: RULE_EXPRESSION, + ratelimit: { ...RULE_RATELIMIT } + } + ] + } + } + }); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.status).toBe('exists'); + // Only the two GETs happened — no POST/PUT/PATCH mutation. + expect(calls.every((c) => c.method === 'GET')).toBe(true); + }); + + it('updates in place (PATCH by rule id) when our rule exists but params changed', async () => { + const { api, calls } = fakeApi({ + [zonePath]: zoneOk, + [entryPath]: { + ok: true, + status: 200, + result: { + id: RULESET, + rules: [ + { + id: 'mine', + ref: RULE_REF, + description: RULE_DESCRIPTION, + action: 'block', + enabled: true, + expression: RULE_EXPRESSION, + // Stale threshold from an earlier version — should be updated, not duplicated. + ratelimit: { characteristics: ['ip.src'], period: 10, requests_per_period: 5, mitigation_timeout: 60 } + } + ] + } + }, + [`PATCH /zones/${ZONE}/rulesets/${RULESET}/rules/mine`]: { ok: true, status: 200 } + }); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.status).toBe('updated'); + const patch = calls.find((c) => c.method === 'PATCH'); + expect(patch?.path).toBe(`/zones/${ZONE}/rulesets/${RULESET}/rules/mine`); + expect(patch?.body).toMatchObject({ ratelimit: RULE_RATELIMIT }); + // No duplicate append. + expect(calls.some((c) => c.method === 'POST')).toBe(false); + }); +}); + +describe('applyDownloadRateLimit — clear errors, no mutation', () => { + it('token has no access to the zone (empty result) → error naming Firewall scope, no ruleset touched', async () => { + const { api, calls } = fakeApi({ + [zonePath]: { ok: true, status: 200, result: [] } + }); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.status).toBe('error'); + expect(res.detail).toContain('no access to zone akito.dog'); + expect(res.detail).toContain('Firewall Services: Edit'); + // Never proceeded to the ruleset endpoint. + expect(calls).toHaveLength(1); + }); + + it('domain is not a zone / zones query fails → error, no ruleset touched', async () => { + const { api, calls } = fakeApi({ + [zonePath]: { ok: false, status: 403, errors: [{ message: 'not authorized' }] } + }); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.status).toBe('error'); + expect(res.detail).toContain('akito.dog'); + expect(calls).toHaveLength(1); + }); + + it('token lacks Firewall scope (entrypoint 403) → error, no write attempted', async () => { + const { api, calls } = fakeApi({ + [zonePath]: zoneOk, + [entryPath]: { ok: false, status: 403 } + }); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.status).toBe('error'); + expect(res.detail).toContain('Firewall Services: Edit'); + // GET zone + GET entrypoint only — no mutation on a scope failure. + expect(calls.every((c) => c.method === 'GET')).toBe(true); + }); + + it('empty domain → error before any network call', async () => { + const { api, calls } = fakeApi({}); + const res = await applyDownloadRateLimit(SECRET, ' ', api); + expect(res.status).toBe('error'); + expect(calls).toHaveLength(0); + }); +}); + +describe('applyDownloadRateLimit — never leaks the token', () => { + it('the secret appears in no returned detail across every branch', async () => { + const scenarios: Record[] = [ + // error: no zone access + { [zonePath]: { ok: true, status: 200, result: [] } }, + // error: zones query failed + { [zonePath]: { ok: false, status: 403 } }, + // error: entrypoint scope failure + { [zonePath]: zoneOk, [entryPath]: { ok: false, status: 403 } }, + // error: write failed + { + [zonePath]: zoneOk, + [entryPath]: { ok: false, status: 404 }, + [`PUT /zones/${ZONE}/rulesets/phases/http_ratelimit/entrypoint`]: { ok: false, status: 500 } + }, + // success: created + { + [zonePath]: zoneOk, + [entryPath]: { ok: false, status: 404 }, + [`PUT /zones/${ZONE}/rulesets/phases/http_ratelimit/entrypoint`]: { ok: true, status: 200 } + } + ]; + for (const routes of scenarios) { + const { api } = fakeApi(routes); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.detail).not.toContain(SECRET); + } + }); + + it('passes the token through to cfApi as the first arg (used as Bearer, not in path/body)', async () => { + const { api, calls } = fakeApi({ + [zonePath]: zoneOk, + [entryPath]: { ok: false, status: 404 }, + [`PUT /zones/${ZONE}/rulesets/phases/http_ratelimit/entrypoint`]: { ok: true, status: 200 } + }); + await applyDownloadRateLimit(SECRET, 'akito.dog', api); + // Token is the first arg on every call; never embedded in a path or body. + for (const c of calls) { + expect(c.token).toBe(SECRET); + expect(c.path).not.toContain(SECRET); + expect(JSON.stringify(c.body ?? '')).not.toContain(SECRET); + } + }); +}); + +describe('applyDownloadRateLimit — write failure', () => { + it('surfaces a scoped error when the create PUT fails', async () => { + const { api } = fakeApi({ + [zonePath]: zoneOk, + [entryPath]: { ok: false, status: 404 }, + [`PUT /zones/${ZONE}/rulesets/phases/http_ratelimit/entrypoint`]: { ok: false, status: 500 } + }); + const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); + expect(res.status).toBe('error'); + expect(res.detail).toContain('failed to write'); + }); +}); diff --git a/scripts/waf-lib.ts b/scripts/waf-lib.ts new file mode 100644 index 00000000..02a0c76c --- /dev/null +++ b/scripts/waf-lib.ts @@ -0,0 +1,206 @@ +/** + * Cloudflare WAF rate-limit provisioning for the download-metrics beacon + * (security finding F5). The public POST /api/metrics/download endpoint is an + * open, unauthenticated write: harmless per hit (one bounded UPSERT), but it can + * be looped to run up requests and D1 writes. This applies a zone-level rate-limit + * rule that blocks a single IP that pounds the beacon, without touching any other + * WAF rule on the zone. + * + * The core `applyDownloadRateLimit` is shared by two callers: the fork setup CLI + * (scripts/setup.ts, for future forks) and the standalone runner + * (scripts/apply-download-ratelimit.ts, for existing forks). It reuses `cfApi` + * from setup-lib for token handling + fetch style, and — like the other CLI + * helpers — lives here (not in the self-executing runner) so it is unit-testable. + * The Cloudflare token is passed in, used only as a Bearer header by `cfApi`, and + * never logged or returned in any result. + */ +import { cfApi, hostFromDomain, type CfApiResult } from './setup-lib.ts'; + +/** + * Stable identifier so re-runs find-and-skip our rule (idempotent) and a future + * parameter change updates it in place rather than appending a duplicate. `ref` + * is the machine key we match on; `description` is the human label shown in the + * dashboard. Both are stable — do not change `ref` or old rules become orphans. + */ +export const RULE_REF = 'sona_download_beacon_ratelimit'; +export const RULE_DESCRIPTION = 'sona: download beacon rate limit'; + +/** Matches exactly the beacon route: POST /api/metrics/download (see +server.ts). */ +export const RULE_EXPRESSION = + '(http.request.method eq "POST" and http.request.uri.path eq "/api/metrics/download")'; + +/** + * Rate-limit knobs: at most 20 POSTs per 10s from one IP, then that IP is blocked + * for 60s. Generous enough that a real visitor mashing download never trips it, + * tight enough that a scripted loop is throttled to a trickle. + */ +export const RULE_RATELIMIT = { + characteristics: ['ip.src'], + period: 10, + requests_per_period: 20, + mitigation_timeout: 60 +} as const; + +/** The rate-limit rule body sent to the Rulesets API (http_ratelimit phase). */ +export function buildRule(): Record { + return { + action: 'block', + expression: RULE_EXPRESSION, + description: RULE_DESCRIPTION, + ref: RULE_REF, + enabled: true, + ratelimit: { ...RULE_RATELIMIT } + }; +} + +/** A rule already on the zone, as returned by GET ...entrypoint. */ +interface ExistingRule { + id?: string; + ref?: string; + description?: string; + action?: string; + expression?: string; + enabled?: boolean; + ratelimit?: { + characteristics?: string[]; + period?: number; + requests_per_period?: number; + mitigation_timeout?: number; + }; +} + +/** + * True when an existing rule already encodes exactly what `buildRule` wants, so + * the run is a no-op. Compares only the fields we own; ignores server-managed + * fields (id, version, last_updated) that the GET returns. + */ +function ruleMatches(rule: ExistingRule): boolean { + const rl = rule.ratelimit ?? {}; + return ( + rule.action === 'block' && + rule.enabled === true && + rule.expression === RULE_EXPRESSION && + rl.period === RULE_RATELIMIT.period && + rl.requests_per_period === RULE_RATELIMIT.requests_per_period && + rl.mitigation_timeout === RULE_RATELIMIT.mitigation_timeout && + Array.isArray(rl.characteristics) && + rl.characteristics.length === RULE_RATELIMIT.characteristics.length && + rl.characteristics.every((c, i) => c === RULE_RATELIMIT.characteristics[i]) + ); +} + +export type RateLimitStatus = 'created' | 'updated' | 'exists' | 'error'; + +export interface RateLimitResult { + status: RateLimitStatus; + /** Human-readable, secret-free summary safe to print. */ + detail: string; +} + +/** The token permission a fork operator must add, quoted verbatim in errors. */ +const SCOPE_HINT = 'Zone → Firewall Services: Edit (plus a Zone resource covering the domain)'; + +/** + * Idempotently apply the download-beacon rate-limit rule to `domain`'s zone. + * + * Sequence (all via `cfApi`, Bearer `cfToken`): + * 1. GET /zones?name= → resolve the zone id (host derived from + * the domain input; scheme/path stripped). No zone in the account, or the + * token can't see it, → a clear error naming the missing scope. No mutation. + * 2. GET /zones//rulesets/phases/http_ratelimit/entrypoint → the zone's + * rate-limit ruleset. 404 = no ruleset yet (fine — we create it). 401/403 or + * other non-ok = token lacks Firewall scope → clear error, no mutation. + * 3. Reconcile by `ref`/`description`: + * - found & identical → no-op, status 'exists'. + * - found & differs (param bump) → PATCH that one rule, status 'updated'. + * - not found, ruleset exists → POST add our rule only, status 'created'. + * - no ruleset (404) → PUT the phase entrypoint with just our + * rule (creates the ruleset), status 'created'. + * The add/patch paths touch only our rule, never other WAF rules on the zone. + * + * `api` is injectable (defaults to the real `cfApi`) so tests exercise every + * branch without network. Never logs the token; no secret appears in any result. + */ +export async function applyDownloadRateLimit( + cfToken: string, + domain: string, + api: typeof cfApi = cfApi +): Promise { + const host = hostFromDomain(domain); + if (!host) return { status: 'error', detail: 'no domain given' }; + + // 1. Resolve the zone id. + const zoneRes = await api(cfToken, `/zones?name=${encodeURIComponent(host)}`); + if (!zoneRes.ok) { + return { + status: 'error', + detail: `could not query zones for ${host} (HTTP ${zoneRes.status}); token needs ${SCOPE_HINT}` + }; + } + const zoneId = ((zoneRes.result as { id?: string }[] | undefined) ?? [])[0]?.id; + if (!zoneId) { + return { + status: 'error', + detail: `token has no access to zone ${host}: add ${SCOPE_HINT}` + }; + } + + // 2. Read the zone's http_ratelimit entrypoint ruleset. + const entry = await api(cfToken, `/zones/${zoneId}/rulesets/phases/http_ratelimit/entrypoint`); + let rulesetId: string | undefined; + let existing: ExistingRule[] = []; + if (entry.ok) { + const r = entry.result as { id?: string; rules?: ExistingRule[] } | undefined; + rulesetId = r?.id; + existing = r?.rules ?? []; + } else if (entry.status !== 404) { + // 401/403 (no Firewall scope) or a transient error — do NOT mutate. + return { + status: 'error', + detail: `could not read the rate-limit ruleset for ${host} (HTTP ${entry.status}); token needs ${SCOPE_HINT}` + }; + } + + // 3. Reconcile against any rule we already own. Match on our stable ref ONLY — + // not the human description — so we never PATCH an operator's own rule that + // merely happens to share the label. + const mine = existing.find((r) => r.ref === RULE_REF); + if (mine && ruleMatches(mine)) { + return { status: 'exists', detail: `rate-limit rule already present on ${host} — no change` }; + } + + const write: CfApiResult = await (async () => { + if (mine && rulesetId && mine.id) { + // Param bump: update just our rule in place. + return api(cfToken, `/zones/${zoneId}/rulesets/${rulesetId}/rules/${mine.id}`, { + method: 'PATCH', + body: buildRule() + }); + } + if (rulesetId) { + // Ruleset exists, our rule is absent: append only our rule. + return api(cfToken, `/zones/${zoneId}/rulesets/${rulesetId}/rules`, { + method: 'POST', + body: buildRule() + }); + } + // No http_ratelimit ruleset yet: create the entrypoint with our rule. + return api(cfToken, `/zones/${zoneId}/rulesets/phases/http_ratelimit/entrypoint`, { + method: 'PUT', + body: { rules: [buildRule()] } + }); + })(); + + if (!write.ok) { + return { + status: 'error', + detail: `failed to write the rate-limit rule to ${host} (HTTP ${write.status}); token needs ${SCOPE_HINT}` + }; + } + return mine + ? { status: 'updated', detail: `updated the download-beacon rate-limit rule on ${host}` } + : { + status: 'created', + detail: `created the download-beacon rate-limit rule on ${host} (POST /api/metrics/download: max ${RULE_RATELIMIT.requests_per_period} / ${RULE_RATELIMIT.period}s per IP, ${RULE_RATELIMIT.mitigation_timeout}s block)` + }; +} From 9034758942bd3e1a9bd8b58c222e8c11e47856e7 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Mon, 13 Jul 2026 18:09:38 -0700 Subject: [PATCH 2/5] fix(security): correct the required token permission to Zone WAF Edit (F5) --- README.md | 2 +- scripts/apply-download-ratelimit.ts | 8 ++++---- scripts/setup.ts | 8 ++++---- scripts/waf-lib.test.ts | 8 ++++---- scripts/waf-lib.ts | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9c9fe653..975843a8 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ original deployment it grew out of). The project home is | Account · D1 · Edit | create + migrate the database | | Account · Workers R2 Storage · Edit | create the image bucket | | Zone · DNS · Edit | **only if** attaching a custom domain (writes the apex record) | - | Zone · Firewall Services · Edit | **only if** attaching a custom domain — adds a WAF rate limit on the public download beacon (`POST /api/metrics/download`) | + | Zone · WAF · Edit | **only if** attaching a custom domain — adds a WAF rate limit on the public download beacon (`POST /api/metrics/download`) | | Zone · Zone Settings · Edit | *optional* — lets setup enable image resizing for you | Without **DNS · Edit**, registering the Pages apex domain succeeds but the DNS diff --git a/scripts/apply-download-ratelimit.ts b/scripts/apply-download-ratelimit.ts index 132dc4cb..ef00a40a 100644 --- a/scripts/apply-download-ratelimit.ts +++ b/scripts/apply-download-ratelimit.ts @@ -13,9 +13,9 @@ * stripped. The operation is idempotent — re-running on an already-protected zone * is a no-op. Exit 0 on created/updated/exists, 1 on error. * - * Token scope required: Zone → Firewall Services: Edit, on a token whose Zone - * Resources include the fork's domain. (Read-only Zone·Read is enough to resolve - * the zone, but writing the rule needs Firewall Services: Edit.) + * Token scope required: Zone → WAF: Edit, on a token whose Zone Resources + * include the fork's domain. (Read-only Zone·Read is enough to resolve the + * zone, but writing the rule needs WAF: Edit.) */ import { env, argv, exit } from 'node:process'; import { applyDownloadRateLimit } from './waf-lib.ts'; @@ -23,7 +23,7 @@ import { applyDownloadRateLimit } from './waf-lib.ts'; const TOKEN_RECIPE = 'Set CLOUDFLARE_API_TOKEN to a Cloudflare API token (dash → My Profile → API Tokens →\n' + 'Create Token → Custom token) with:\n' + - ' • Zone · Firewall Services · Edit\n' + + ' • Zone · WAF · Edit\n' + ' and a Zone Resource that includes the fork domain, then re-run:\n' + ' CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- '; diff --git a/scripts/setup.ts b/scripts/setup.ts index dbbbc4e3..d2594415 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -115,7 +115,7 @@ const TOKEN_RECIPE = ' • Account · D1 · Edit\n' + ' • Account · Workers R2 Storage · Edit\n' + ' • Zone · DNS · Edit (only if you are attaching a custom domain)\n' + - ' • Zone · Firewall Services · Edit (only with a custom domain; adds the download-beacon rate limit)\n' + + ' • Zone · WAF · Edit (only with a custom domain; adds the download-beacon rate limit)\n' + ' • Zone · Zone Settings · Edit (optional; lets setup enable image resizing for you)'; async function main() { @@ -321,7 +321,7 @@ async function main() { imageResizingOn = imageResizingOutcome(ir, patchOk); // WAF rate limit for the public download beacon (finding F5). Non-fatal: - // a token without Zone · Firewall Services · Edit just yields an 'error' + // a token without Zone · WAF · Edit just yields an 'error' // result we warn about in Next steps — setup keeps going regardless. const rl = await applyDownloadRateLimit(cfToken, host); downloadRateLimit = rl.status; @@ -584,9 +584,9 @@ async function main() { console.log(' Until on, gallery thumbnails serve the full-size original (slow) or 404.'); } // Download-beacon rate limit (finding F5). null = not attempted (no zone); - // 'error' = token lacked Zone · Firewall Services · Edit — tell them to add it. + // 'error' = token lacked Zone · WAF · Edit — tell them to add it. if (downloadRateLimit === 'error') { - console.log(' • Download-beacon rate limit: NOT set (token lacks Zone · Firewall Services · Edit).'); + console.log(' • Download-beacon rate limit: NOT set (token lacks Zone · WAF · Edit).'); console.log(' Add that permission to the token, then run:'); console.log(` CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- ${host}`); } else if (downloadRateLimit && downloadRateLimit !== 'exists') { diff --git a/scripts/waf-lib.test.ts b/scripts/waf-lib.test.ts index 9e6328c4..4c5583a5 100644 --- a/scripts/waf-lib.test.ts +++ b/scripts/waf-lib.test.ts @@ -173,14 +173,14 @@ describe('applyDownloadRateLimit — idempotent no-op', () => { }); describe('applyDownloadRateLimit — clear errors, no mutation', () => { - it('token has no access to the zone (empty result) → error naming Firewall scope, no ruleset touched', async () => { + it('token has no access to the zone (empty result) → error naming WAF scope, no ruleset touched', async () => { const { api, calls } = fakeApi({ [zonePath]: { ok: true, status: 200, result: [] } }); const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); expect(res.status).toBe('error'); expect(res.detail).toContain('no access to zone akito.dog'); - expect(res.detail).toContain('Firewall Services: Edit'); + expect(res.detail).toContain('WAF: Edit'); // Never proceeded to the ruleset endpoint. expect(calls).toHaveLength(1); }); @@ -195,14 +195,14 @@ describe('applyDownloadRateLimit — clear errors, no mutation', () => { expect(calls).toHaveLength(1); }); - it('token lacks Firewall scope (entrypoint 403) → error, no write attempted', async () => { + it('token lacks WAF scope (entrypoint 403) → error, no write attempted', async () => { const { api, calls } = fakeApi({ [zonePath]: zoneOk, [entryPath]: { ok: false, status: 403 } }); const res = await applyDownloadRateLimit(SECRET, 'akito.dog', api); expect(res.status).toBe('error'); - expect(res.detail).toContain('Firewall Services: Edit'); + expect(res.detail).toContain('WAF: Edit'); // GET zone + GET entrypoint only — no mutation on a scope failure. expect(calls.every((c) => c.method === 'GET')).toBe(true); }); diff --git a/scripts/waf-lib.ts b/scripts/waf-lib.ts index 02a0c76c..95e42c69 100644 --- a/scripts/waf-lib.ts +++ b/scripts/waf-lib.ts @@ -98,7 +98,7 @@ export interface RateLimitResult { } /** The token permission a fork operator must add, quoted verbatim in errors. */ -const SCOPE_HINT = 'Zone → Firewall Services: Edit (plus a Zone resource covering the domain)'; +const SCOPE_HINT = 'Zone → WAF: Edit (plus a Zone resource covering the domain)'; /** * Idempotently apply the download-beacon rate-limit rule to `domain`'s zone. @@ -109,7 +109,7 @@ const SCOPE_HINT = 'Zone → Firewall Services: Edit (plus a Zone resource cover * token can't see it, → a clear error naming the missing scope. No mutation. * 2. GET /zones//rulesets/phases/http_ratelimit/entrypoint → the zone's * rate-limit ruleset. 404 = no ruleset yet (fine — we create it). 401/403 or - * other non-ok = token lacks Firewall scope → clear error, no mutation. + * other non-ok = token lacks WAF scope → clear error, no mutation. * 3. Reconcile by `ref`/`description`: * - found & identical → no-op, status 'exists'. * - found & differs (param bump) → PATCH that one rule, status 'updated'. @@ -154,7 +154,7 @@ export async function applyDownloadRateLimit( rulesetId = r?.id; existing = r?.rules ?? []; } else if (entry.status !== 404) { - // 401/403 (no Firewall scope) or a transient error — do NOT mutate. + // 401/403 (no WAF scope) or a transient error — do NOT mutate. return { status: 'error', detail: `could not read the rate-limit ruleset for ${host} (HTTP ${entry.status}); token needs ${SCOPE_HINT}` From 652ca955d6c89edb2c0f29a5ab125f31da19acda Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 16 Jul 2026 23:57:34 -0700 Subject: [PATCH 3/5] fix(waf): resolve the zone via registrable-domain fallback for subdomain hosts applyDownloadRateLimit looked up the zone by exact host name, so a fork served from a subdomain of its zone got a false 'token has no WAF access' error and the download-beacon rate limit was never applied. Walk zone-name candidates (host, then leading labels stripped to the registrable domain), mirroring the setup preflight. --- scripts/waf-lib.test.ts | 21 +++++++++++++++++++++ scripts/waf-lib.ts | 40 ++++++++++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/scripts/waf-lib.test.ts b/scripts/waf-lib.test.ts index 4c5583a5..be10b0ca 100644 --- a/scripts/waf-lib.test.ts +++ b/scripts/waf-lib.test.ts @@ -172,6 +172,27 @@ describe('applyDownloadRateLimit — idempotent no-op', () => { }); }); +describe('applyDownloadRateLimit — subdomain host resolves via the registrable zone', () => { + it('strips leading labels until a zone matches (sub.example.com → example.com)', async () => { + const { api, calls } = fakeApi({ + // Exact-name lookup for the subdomain finds nothing (Cloudflare only + // registers the registrable zone), so it must fall back to example.com. + 'GET /zones?name=sub.example.com': { ok: true, status: 200, result: [] }, + 'GET /zones?name=example.com': zoneOk, + [entryPath]: { ok: true, status: 200, result: { id: RULESET, rules: [] } }, + [`POST /zones/${ZONE}/rulesets/${RULESET}/rules`]: { ok: true, status: 200 } + }); + const res = await applyDownloadRateLimit(SECRET, 'sub.example.com', api); + expect(res.status).toBe('created'); + // Tried the subdomain first, then the registrable zone. + const zoneQueries = calls.filter((c) => c.path.startsWith('/zones?name=')); + expect(zoneQueries.map((c) => c.path)).toEqual([ + '/zones?name=sub.example.com', + '/zones?name=example.com' + ]); + }); +}); + describe('applyDownloadRateLimit — clear errors, no mutation', () => { it('token has no access to the zone (empty result) → error naming WAF scope, no ruleset touched', async () => { const { api, calls } = fakeApi({ diff --git a/scripts/waf-lib.ts b/scripts/waf-lib.ts index 95e42c69..c1d6da56 100644 --- a/scripts/waf-lib.ts +++ b/scripts/waf-lib.ts @@ -16,6 +16,22 @@ */ import { cfApi, hostFromDomain, type CfApiResult } from './setup-lib.ts'; +/** + * Zone-name candidates for a host, most specific first: the host itself, then + * with leading labels stripped down to the two-label registrable domain (so + * `sub.example.com` also tries `example.com`, the zone that actually serves it). + * Kept local so this PR is mergeable on its own; once #179's identically-shaped + * `zoneNameCandidates` in setup-lib lands, collapse these to the shared one. + */ +function zoneNameCandidates(host: string): string[] { + const labels = host.split('.').filter(Boolean); + const out: string[] = []; + for (let i = 0; i + 2 <= labels.length; i++) { + out.push(labels.slice(i).join('.')); + } + return out.length ? out : host ? [host] : []; +} + /** * Stable identifier so re-runs find-and-skip our rule (idempotent) and a future * parameter change updates it in place rather than appending a duplicate. `ref` @@ -129,15 +145,23 @@ export async function applyDownloadRateLimit( const host = hostFromDomain(domain); if (!host) return { status: 'error', detail: 'no domain given' }; - // 1. Resolve the zone id. - const zoneRes = await api(cfToken, `/zones?name=${encodeURIComponent(host)}`); - if (!zoneRes.ok) { - return { - status: 'error', - detail: `could not query zones for ${host} (HTTP ${zoneRes.status}); token needs ${SCOPE_HINT}` - }; + // 1. Resolve the zone id. A subdomain (sub.example.com) is served by the + // registrable zone (example.com), so an exact `?name=` lookup finds + // nothing — try the host then strip leading labels until a zone on the + // account matches. This mirrors the setup CLI's zone preflight so a + // subdomain-hosted fork isn't wrongly told its token lacks WAF access. + let zoneId: string | undefined; + for (const candidate of zoneNameCandidates(host)) { + const zoneRes = await api(cfToken, `/zones?name=${encodeURIComponent(candidate)}`); + if (!zoneRes.ok) { + return { + status: 'error', + detail: `could not query zones for ${candidate} (HTTP ${zoneRes.status}); token needs ${SCOPE_HINT}` + }; + } + zoneId = ((zoneRes.result as { id?: string }[] | undefined) ?? [])[0]?.id; + if (zoneId) break; } - const zoneId = ((zoneRes.result as { id?: string }[] | undefined) ?? [])[0]?.id; if (!zoneId) { return { status: 'error', From 15abaa4ef7bea0e1db203df0cd0840244137543a Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Sat, 18 Jul 2026 17:45:52 -0700 Subject: [PATCH 4/5] fix(waf): include cf.colo.id in rate-limit characteristics Cloudflare counts rate-limit rules per data center outside Enterprise, and the Rulesets API rejects a rule whose characteristics omit cf.colo.id (HTTP 400, code 20155). The download-beacon rule sent only ip.src, so the apply failed on every zone. Count per-IP-per-colo instead. --- scripts/waf-lib.test.ts | 2 +- scripts/waf-lib.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/waf-lib.test.ts b/scripts/waf-lib.test.ts index be10b0ca..edb024d2 100644 --- a/scripts/waf-lib.test.ts +++ b/scripts/waf-lib.test.ts @@ -51,7 +51,7 @@ describe('buildRule', () => { expect(rule.ref).toBe(RULE_REF); expect(rule.description).toBe(RULE_DESCRIPTION); expect(rule.ratelimit).toEqual({ - characteristics: ['ip.src'], + characteristics: ['ip.src', 'cf.colo.id'], period: 10, requests_per_period: 20, mitigation_timeout: 60 diff --git a/scripts/waf-lib.ts b/scripts/waf-lib.ts index c1d6da56..a663fedf 100644 --- a/scripts/waf-lib.ts +++ b/scripts/waf-lib.ts @@ -49,9 +49,14 @@ export const RULE_EXPRESSION = * Rate-limit knobs: at most 20 POSTs per 10s from one IP, then that IP is blocked * for 60s. Generous enough that a real visitor mashing download never trips it, * tight enough that a scripted loop is throttled to a trickle. + * + * `cf.colo.id` is REQUIRED alongside `ip.src`: outside Enterprise, Cloudflare + * counts rate-limit rules per data center, and the Rulesets API rejects the rule + * (HTTP 400, code 20155) when the colo characteristic is absent. Counting is + * therefore per-IP-per-colo — the standard non-Enterprise behavior. Do not drop it. */ export const RULE_RATELIMIT = { - characteristics: ['ip.src'], + characteristics: ['ip.src', 'cf.colo.id'], period: 10, requests_per_period: 20, mitigation_timeout: 60 From 6927b6b769aed54bbc96367cbfa09640f41fdf60 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Sat, 18 Jul 2026 17:50:43 -0700 Subject: [PATCH 5/5] fix(waf): pin mitigation_timeout to the period (10s) for plan portability The Free plan is "not entitled to use a mitigation timeout different from 10", so a 60s block was rejected (HTTP 400). 10 is valid on every plan, keeping the rule portable across forks; a blocked IP is denied for 10s then must re-exceed the threshold. --- scripts/waf-lib.test.ts | 2 +- scripts/waf-lib.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/waf-lib.test.ts b/scripts/waf-lib.test.ts index edb024d2..8eacca99 100644 --- a/scripts/waf-lib.test.ts +++ b/scripts/waf-lib.test.ts @@ -54,7 +54,7 @@ describe('buildRule', () => { characteristics: ['ip.src', 'cf.colo.id'], period: 10, requests_per_period: 20, - mitigation_timeout: 60 + mitigation_timeout: 10 }); }); diff --git a/scripts/waf-lib.ts b/scripts/waf-lib.ts index a663fedf..a6f55e65 100644 --- a/scripts/waf-lib.ts +++ b/scripts/waf-lib.ts @@ -47,19 +47,24 @@ export const RULE_EXPRESSION = /** * Rate-limit knobs: at most 20 POSTs per 10s from one IP, then that IP is blocked - * for 60s. Generous enough that a real visitor mashing download never trips it, + * for 10s. Generous enough that a real visitor mashing download never trips it, * tight enough that a scripted loop is throttled to a trickle. * * `cf.colo.id` is REQUIRED alongside `ip.src`: outside Enterprise, Cloudflare * counts rate-limit rules per data center, and the Rulesets API rejects the rule * (HTTP 400, code 20155) when the colo characteristic is absent. Counting is * therefore per-IP-per-colo — the standard non-Enterprise behavior. Do not drop it. + * + * `mitigation_timeout` MUST equal the period (10): the Free plan is "not entitled + * to use a mitigation timeout different from 10", and 10 is valid on every higher + * plan too, so the rule stays plan-portable across forks. A blocked IP is denied + * for 10s, then must re-exceed the threshold to be blocked again. */ export const RULE_RATELIMIT = { characteristics: ['ip.src', 'cf.colo.id'], period: 10, requests_per_period: 20, - mitigation_timeout: 60 + mitigation_timeout: 10 } as const; /** The rate-limit rule body sent to the Rulesets API (http_ratelimit phase). */