# Cloudflare WAF rate limiting rules for the pages Worker, per # docs/plans/2026-07-12-pages-design.md ยง2.5 (denial-of-wallet mitigation). # Uses the http_ratelimit phase of cloudflare_ruleset, mirroring # ../quotesdb/infra/rate-limits.tf. # # NOTE on thresholds: the design doc's targets (uploads ~10/min, auth # ~20/min per IP) are minute-based, but the Cloudflare Free plan only allows # a 10-second window ("period" must be 10) for http_ratelimit rules, so the # figures below are converted to 10s buckets and rounded to the nearest # sensible integer rather than being an exact /min conversion. # # NOTE on the zone entrypoint limit: Cloudflare allows only ONE zone-level # ruleset per phase, on every plan โ€” the http_ratelimit entrypoint for # elijah.run is a single resource. quotesdb already declares such a ruleset # (../quotesdb/infra/rate-limits.tf) on this shared zone, so applying this # file's ruleset alongside quotesdb's will clobber it (last apply wins), # regardless of plan. A paid plan only raises the number of rules allowed # INSIDE the one ruleset (Free additionally caps it at 1 rule with 10s # periods). Before applying both projects' limits, merge the two rules # blocks below into the zone's single shared cloudflare_ruleset (wherever # it ends up living); this file is written standalone so it works verbatim # when quotesdb's ruleset is not applied. See infra/README.md. resource "cloudflare_ruleset" "pages_rate_limits" { # Scoped to the elijah.run zone that hosts pages.elijah.run. zone_id = var.cloudflare_zone_id name = "pages rate limits" description = "Per-IP rate limiting for mutating pages endpoints (uploads, auth)" kind = "zone" phase = "http_ratelimit" rules { # Uploads: POST /api/pages?name=. Free plan restrictions apply # (see quotesdb's note): expression fields are limited to Path and # Verified Bot only (no Method, no regex โ€” "contains" instead of a # path prefix match), characteristics are IP + colo only, and period # must be 10. This also catches GET /api/pages (list) since Method # isn't available to filter on Free โ€” acceptable since list is cheap # and idempotent; the design's 10/min target for the abusive path # (upload) still bounds worst-case write volume. # ~10/min => ~1.7/10s, rounded up to 2/10s. description = "Limit /api/pages requests to 2 per IP per 10 seconds" expression = "http.request.uri.path contains \"/api/pages\"" action = "block" ratelimit { # cf.colo.id is required alongside ip.src (rate limiting is processed # per-colocation). characteristics = ["ip.src", "cf.colo.id"] period = 10 requests_per_period = 2 mitigation_timeout = 10 } } rules { # Auth: GET /auth/login, GET /auth/callback, POST /auth/logout. # ~20/min => ~3.3/10s, rounded up to 4/10s (slightly more permissive # than uploads since login/logout round-trips are more frequent under # normal use than page uploads). description = "Limit /auth/ requests to 4 per IP per 10 seconds" expression = "http.request.uri.path contains \"/auth/\"" action = "block" ratelimit { characteristics = ["ip.src", "cf.colo.id"] period = 10 requests_per_period = 4 mitigation_timeout = 10 } } }