You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

70 lines
3.3 KiB
HCL

# 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=<slug>. 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
}
}
}