diff --git a/README.md b/README.md index eaa22522..975843a8 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 · 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/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..ef00a40a --- /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 → 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'; + +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 · WAF · 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..d2594415 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 · 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() { 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 · 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; + 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 · WAF · Edit — tell them to add it. + if (downloadRateLimit === 'error') { + 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') { + 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..8eacca99 --- /dev/null +++ b/scripts/waf-lib.test.ts @@ -0,0 +1,295 @@ +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', 'cf.colo.id'], + period: 10, + requests_per_period: 20, + mitigation_timeout: 10 + }); + }); + + 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 — 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({ + [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('WAF: 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 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('WAF: 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..a6f55e65 --- /dev/null +++ b/scripts/waf-lib.ts @@ -0,0 +1,240 @@ +/** + * 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'; + +/** + * 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` + * 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 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: 10 +} 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 → WAF: 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 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'. + * - 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. 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; + } + 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 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}` + }; + } + + // 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)` + }; +}