From db71399b2f2c0bb0160412ddc2ad8d2bef45928b Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 8 Mar 2026 12:52:52 -0700 Subject: [PATCH] feat(quotesdb): add Cloudflare WAF rate limiting rules via OpenTofu Adds infra/rate-limits.tf with a cloudflare_ruleset (phase: http_ratelimit) implementing per-IP rate limits on all mutating API endpoints: - PUT /api/quotes: 5 requests per 10 minutes (quote creation) - POST /api/quotes/:id/report: 3 requests per hour (abuse reports) - POST /api/quotes/:id: 10 requests per minute (quote updates) - DELETE /api/quotes/:id: 10 requests per minute (quote deletes) The report rule is ordered before the general update rule to ensure the more-specific /report path matches before the broader /api/quotes/:id pattern. Documents the approach, plan requirements, and layered protection rationale in docs/ARCHITECTURE.md. Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/docs/ARCHITECTURE.md | 27 ++++++++++++++ quotesdb/infra/rate-limits.tf | 69 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 quotesdb/infra/rate-limits.tf diff --git a/quotesdb/docs/ARCHITECTURE.md b/quotesdb/docs/ARCHITECTURE.md index 5ddf05d..72c8e4b 100644 --- a/quotesdb/docs/ARCHITECTURE.md +++ b/quotesdb/docs/ARCHITECTURE.md @@ -40,6 +40,33 @@ Integration tests bypass the UI and talk directly to the API over HTTP, using a Use `#[cfg(not(target_arch = "wasm32"))]` and `#[cfg(target_arch = "wasm32")]` guards where needed. +## Rate Limiting + +Rate limiting is enforced at the Cloudflare WAF layer using a `cloudflare_ruleset` resource with `phase = "http_ratelimit"` (see `infra/rate-limits.tf`). All limits are keyed on the client IP address (`ip.src`). Blocked requests receive a Cloudflare-generated `429 Too Many Requests` response before the Worker is invoked. + +### Configured Limits + +| Endpoint | Method | Limit | Window | Mitigation timeout | +|----------|--------|-------|--------|--------------------| +| `PUT /api/quotes` | PUT | 5 requests | 10 minutes | 10 minutes | +| `POST /api/quotes/:id/report` | POST | 3 requests | 1 hour | 1 hour | +| `POST /api/quotes/:id` | POST | 10 requests | 1 minute | 1 minute | +| `DELETE /api/quotes/:id` | DELETE | 10 requests | 1 minute | 1 minute | + +GET endpoints are not rate-limited at the WAF layer — Cloudflare's CDN caches most read responses, making per-IP GET limits unnecessary. + +### Rule Ordering + +Cloudflare evaluates `http_ratelimit` rules top-to-bottom with first-match semantics. The `POST /api/quotes/:id/report` rule is placed **before** the general `POST /api/quotes/:id` rule to prevent the broader pattern from matching report requests. + +### Plan Requirements + +The Cloudflare free tier allows **1 custom rate limiting rule per zone**. This configuration uses a single `cloudflare_ruleset` resource with multiple `rules` blocks, which counts as one ruleset but consumes multiple rule slots. Verify the account's plan limits before applying — a Pro or higher plan is recommended for the full four-rule set. + +### Layered Protection + +WAF rate limiting complements (not replaces) Turnstile CAPTCHA on the submission form. CAPTCHA handles bot detection for quote creation; rate limiting enforces hard caps across all mutating endpoints including updates, deletes, and abuse reports. + ## Key Dependency Versions Resolved versions for the UI Wasm target (scoped to `[target.'cfg(target_arch = "wasm32")'.dependencies]`): diff --git a/quotesdb/infra/rate-limits.tf b/quotesdb/infra/rate-limits.tf new file mode 100644 index 0000000..196b6d5 --- /dev/null +++ b/quotesdb/infra/rate-limits.tf @@ -0,0 +1,69 @@ +# Cloudflare WAF rate limiting rules for the quotesdb API. +# Uses the http_ratelimit phase of cloudflare_ruleset to enforce per-IP request caps +# on all mutating endpoints. Rules are evaluated top-to-bottom; first match wins. +# The report rule must appear before the general update rule to prevent the broader +# /api/quotes/* pattern from matching /api/quotes/*/report first. +resource "cloudflare_ruleset" "api_rate_limits" { + # Scoped to the elijah.run zone that hosts quotes.elijah.run. + zone_id = var.cloudflare_zone_id + name = "quotesdb API rate limits" + description = "Per-IP rate limiting for mutating quotesdb API endpoints" + kind = "zone" + phase = "http_ratelimit" + + rules { + # Limit quote creation via PUT /api/quotes: 5 requests per IP per 10 minutes. + # PUT is the create verb per the quotesdb API design. + description = "Limit PUT /api/quotes to 5 per IP per 10 minutes" + expression = "(http.request.method eq \"PUT\" and http.request.uri.path eq \"/api/quotes\")" + action = "block" + ratelimit { + characteristics = ["ip.src"] + period = 600 + requests_per_period = 5 + mitigation_timeout = 600 + } + } + + rules { + # Limit report submissions: 3 POST /api/quotes/:id/report per IP per hour. + # This rule must precede the general update rule below so the more-specific + # /report path is matched first before the broader /api/quotes/* pattern. + description = "Limit POST /api/quotes/*/report to 3 per IP per hour" + expression = "(http.request.method eq \"POST\" and http.request.uri.path matches \"/api/quotes/[^/]+/report$\")" + action = "block" + ratelimit { + characteristics = ["ip.src"] + period = 3600 + requests_per_period = 3 + mitigation_timeout = 3600 + } + } + + rules { + # Limit quote updates: 10 POST /api/quotes/:id per IP per minute. + # Excludes the /report sub-path (handled by the rule above). + description = "Limit POST /api/quotes/:id to 10 per IP per minute" + expression = "(http.request.method eq \"POST\" and http.request.uri.path matches \"/api/quotes/[^/]+$\")" + action = "block" + ratelimit { + characteristics = ["ip.src"] + period = 60 + requests_per_period = 10 + mitigation_timeout = 60 + } + } + + rules { + # Limit quote deletes: 10 DELETE /api/quotes/:id per IP per minute. + description = "Limit DELETE /api/quotes/:id to 10 per IP per minute" + expression = "(http.request.method eq \"DELETE\" and http.request.uri.path matches \"/api/quotes/[^/]+$\")" + action = "block" + ratelimit { + characteristics = ["ip.src"] + period = 60 + requests_per_period = 10 + mitigation_timeout = 60 + } + } +}