Skip to content

Download-beacon rate limiting (WAF applier + wizard) (security F5) - #192

Merged
sparkyfen merged 5 commits into
mainfrom
fix/download-waf-ratelimit
Jul 27, 2026
Merged

Download-beacon rate limiting (WAF applier + wizard) (security F5)#192
sparkyfen merged 5 commits into
mainfrom
fix/download-waf-ratelimit

Conversation

@sparkyfen

@sparkyfen sparkyfen commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Adds the mechanism to protect the download-metrics beacon with a Cloudflare rate-limit rule (audit finding F5, Option 1 — WAF at the edge). This PR only adds code; it does not touch any Cloudflare account. The rule is applied only when the applier is explicitly run with a token, or during a future fork's setup.

  • scripts/waf-lib.tsapplyDownloadRateLimit(token, domain): idempotently creates a rate-limit rule (POST /api/metrics/download, 20 req / 10s per IP, block 60s) via the Rulesets http_ratelimit phase. Matched on a stable ref, so re-runs find-and-skip. Cannot delete or overwrite an operator's other WAF rules (PATCHes only our rule by id; POST appends; the whole-set PUT fires only when the zone has zero rate-limit rules; rate-limiting is a separate phase from custom firewall rules). The token is never logged and never leaves the Authorization header.
  • Wizard integration (scripts/setup.ts): future forks with a custom domain provision the rule automatically; a token lacking the scope produces a non-fatal warning (setup never crashes), and *.pages.dev-only forks skip it.
  • Standalone runner (npm run apply-download-ratelimit -- <domain>): for existing forks; reads the token from CLOUDFLARE_API_TOKEN (env only, never argv).
  • Onboarding token requirement + README updated: Zone → WAF: Edit (with a Zone resource for the domain).

Reviewed by a security gate (PASS): confirmed no collateral WAF-rule damage and no token leakage. 13 unit tests (mocked CF, no network) + full suite (995) pass.

Note: applying the rule to any live account is a separate, consent-gated step (operator adds the token scope; run against your own zone first).

@sparkyfen sparkyfen added the admin Admin panel / CMS label Jul 13, 2026
Comment thread scripts/waf-lib.ts Outdated
if (!host) return { status: 'error', detail: 'no domain given' };

// 1. Resolve the zone id.
const zoneRes = await api(cfToken, `/zones?name=${encodeURIComponent(host)}`);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security · CONFIRMED — subdomain-hosted forks can never attach the WAF rule (F5 protection silently not applied)

applyDownloadRateLimit resolves the zone with an exact GET /zones?name=<host> match. Any fork whose site host is a subdomain of its Cloudflare zone gets an empty result and the misleading error token has no access to zone <host>: add Zone → WAF: Edit, even when the token is correctly scoped.

Result: the operator chases a nonexistent permissions problem and the download beacon (POST /api/metrics/download) stays unprotected against the write-flood this PR exists to close.

Fix: reuse zoneNameCandidates(host) here instead of the exact-name query.

…ain 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.
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.
…lity

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.
@sparkyfen
sparkyfen force-pushed the fix/download-waf-ratelimit branch from b091ab9 to 6927b6b Compare July 19, 2026 02:47
@sparkyfen
sparkyfen merged commit af6547c into main Jul 27, 2026
6 checks passed
@sparkyfen
sparkyfen deleted the fix/download-waf-ratelimit branch July 27, 2026 01:15
sparkyfen added a commit that referenced this pull request Jul 27, 2026
#192 touched the same three places in setup.ts. Both features are additive
and independent, so every conflict keeps both sides: the two lib imports, the
two provisioning calls, the two status variables, and the two Next-steps
blocks.

Nesting matters and is preserved — the WAF apply stays inside the resolved-zone
block, Turnstile stays outside it (account-scoped, needs no zone) but inside
the token check.

TOKEN_RECIPE now lists both new scopes, with Turnstile in the Account group
rather than appended after the Zone ones. Dropped the note telling the reader
not to add Zone · WAF · Edit here — that was a merge-coordination marker for
exactly this merge, and it is now stale.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

admin Admin panel / CMS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant