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/🆔 10 requests per minute (quote updates)
- DELETE /api/quotes/🆔 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 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent 5d2cdcae8e
commit d9099c5585

@ -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]`):

@ -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
}
}
}
Loading…
Cancel
Save