Skip to content

ADR-0010: Cloudflare WAF — Free Managed Ruleset + custom rules + rate limiting for the marnissi-holdings.com zone

  • Status: Accepted
  • Date: 2026-05-18
  • Deciders: cloud-architect, cloudflare-expert, secops-agent, finops-agent, marnissi.investments

Context

ADR-0008 (PR6) placed Cloudflare Access in front of the three internal hostnames (admin.*, api.*, labs.*), closing the unauthenticated-access gap. However, an authenticated request carrying a malicious payload — SQLi, XSS, path traversal, prototype pollution, Log4j, or any catalogued CVE — passes through CF Access unchallenged and reaches the origin service. The three public hostnames (marnissi-holdings.com, www.*, docs.*) have no authentication gate at all; the CF edge is the only enforcement point between an attacker and the origin containers on the OCI ARM A1 VPS.

infra/CLAUDE.md §Stack lists "WAF" as a first-class CF component in the v1 stack. Free-plan quotas (cloudflare-expert verified): 5 custom rules, 1 rate-limiting rule (not 10 — the seed ledger was wrong; corrected by finops-agent in the module PR), all rule actions limited to IP-only characteristics + platform-default mitigation timeout for rate-limit. Managed rulesets on Free are restricted to a single "Cloudflare Free Managed Ruleset" (CF-curated subset); the full Cloudflare Managed Ruleset (efb7b8c949ac4650a09736fc376e9aee) and OWASP Core Ruleset both require CF Pro+ ($20/month). The six hostnames that require WAF coverage are the full set catalogued in infra/CLAUDE.md §Hostname catalog: apex, www, docs, admin, api, labs. A reserved seventh hostname (status.*) is not yet live and is excluded from this ADR.

This ADR commits the WAF posture for v1 so that infra-agent can implement modules/cloudflare-waf/.

Decision

Implement modules/cloudflare-waf/ with the resources described below. infra-agent authors the HCL; cloudflare-expert verifies provider resource names, attribute values, and ruleset IDs against the current Cloudflare provider schema before the PR is merged.

All resources below are zone-scoped to marnissi-holdings.com. The module depends on modules/cloudflare-dns/ (ADR-0006) for the zone to exist and for the zone ID variable. It does not depend on modules/cloudflare-access/ (ADR-0008) or modules/cloudflare-tunnel/ (ADR-0007) — WAF enforcement is orthogonal to authentication and tunnelling.

Managed ruleset (Free-plan reality)

One cloudflare_ruleset resource with kind = "zone" and phase = "http_request_firewall_managed" containing one execute rule that deploys the Cloudflare Free Managed Ruleset. The full Cloudflare Managed Ruleset (efb7b8c949ac4650a09736fc376e9aee) and OWASP Core Ruleset are Pro+ only — both omitted from this ADR's locked scope.

Ruleset ID source Enforcement mode
Cloudflare Free Managed Ruleset Retrieved post-bootstrap via GET /zones/{zone_id}/rulesets and pasted into terraform.tfvars as cf_free_managed_ruleset_id (CF does not document a stable hardcodable UUID; safer to read from the live zone). execute with overrides.action = "log" for the first 7 days, then a follow-up PR removes the override (CF's default actions apply).

Coverage gap vs OWASP (recorded for posture awareness): The Free Managed Ruleset is a curated subset of the full CF Managed Ruleset — high-impact / widely-exploited vulns only. SQLi/XSS/path-traversal/command-injection coverage is narrower than OWASP Core Ruleset would provide. Compensating controls in this ADR: - block-bad-bot-ua removes scanner traffic (sqlmap, nikto, dirbuster, nuclei, etc.) that typically precedes exploit attempts. - block-host-header-smuggling blocks Host header attacks at the edge. - Rate-limit on api.* caps abuse volume. - The Pro upgrade path is one CF dashboard click + an uncomment in modules/cloudflare-waf/main.tf — no module re-architecture required.

Log-mode discipline for managed-rule version bumps: Cloudflare automatically updates managed ruleset versions. When a new version is detected via CF changelog or dashboard, the operator re-applies the overrides.action = "log" override for 7 days, reviews Security Events for false positives, then removes the override. This is a Tofu apply each time (the override is a state-tracked attribute), not a dashboard-only toggle.

FP triage via dashboard exceptions: WAF Exceptions created in the CF dashboard (Security → WAF → Exceptions) coexist with Tofu-managed rulesets without state drift. Dashboard exceptions take precedence in the ruleset engine and apply immediately. Use dashboard exceptions for narrow, targeted FP fixes; reserve module changes for structural ruleset changes.

Custom rules

One cloudflare_ruleset resource with kind = "zone" and phase = "http_request_firewall_custom" containing two ordered rules. Custom-rule quota: 2 of 5 used. Three slots reserved for emergency tactical rules and future FP-skip rules. Wirefilter constraints (cloudflare-expert verified): contains is case-sensitive so all UA matches use lower(http.user_agent); the matches (regex) operator requires Business+ and is NOT used.

Priority Rule name Expression (summary) Action Rationale
10 block-bad-bot-ua (lower(http.user_agent) contains "sqlmap") or (lower(http.user_agent) contains "dirbuster") or (lower(http.user_agent) contains "nikto") or (lower(http.user_agent) contains "masscan") or (lower(http.user_agent) contains "nuclei") or (lower(http.user_agent) contains "zgrab") or (lower(http.user_agent) contains "python-requests/") or ((lower(http.user_agent) contains "curl/") and not cf.client.bot) block Drops scanner/attack-tool user agents before payload inspection. curl/ is conditionally blocked via not cf.client.bot so CF-verified bots (health checks, monitoring) still pass; standalone clients are blocked. python-requests/ is included because legit usage of the API will go through the admin-frontend, not raw Python.
20 block-host-header-smuggling not http.host in {"marnissi-holdings.com" "www.marnissi-holdings.com" "docs.marnissi-holdings.com" "admin.marnissi-holdings.com" "api.marnissi-holdings.com" "labs.marnissi-holdings.com" "status.marnissi-holdings.com"} block Rejects requests whose Host: header is not in the known-hostname set. Defends against Host header injection, virtual-host routing abuse, and cache-poisoning. www.* MUST remain in the allowlist (WAF evaluates before the www→apex redirect ruleset). status.* is pre-declared even though not yet live so it works on first ship without touching this rule. This list must be updated in the same PR whenever a new hostname is added to infra/CLAUDE.md §Hostname catalog.

Dropped rule (cloudflare-expert recommendation): the originally-planned rate-limit-api-custom custom-phase variant is omitted. Rate-limit phase rule handles volume on api.* directly; a custom-phase mirror would burn a custom rule slot to approximate work the rate-limit phase already does. Reinstate as a future iteration when payload-signature inspection on api.* (e.g. credential-stuffing body patterns) becomes a known need.

Custom expression sandbox requirement (secops note): All three custom-rule expressions must be tested in the Cloudflare firewall expression tester (CF dashboard → Security → WAF → Custom Rules → Test expression) against sample request payloads before tofu apply. False-positive testing with Googlebot UA and a standard browser UA is mandatory for block-bad-bot-ua.

Host-header rule maintenance contract: When infra-agent adds a new DNS record for a new hostname (any future PR touching modules/cloudflare-dns/), the PR must also update the block-host-header-smuggling expression in modules/cloudflare-waf/ to include the new hostname. This is a cross-module dependency; secops-agent enforces it on every infra PR that touches DNS.

Rate-limit rule

One cloudflare_ruleset resource with kind = "zone" and phase = "http_ratelimit" containing one rule. Rate-limit quota on Free: 1 (the full quota; not 10 as the seed ledger assumed). Characteristics limited to ip.src (JA3/cookie/header characteristics require Pro+).

Rule name Expression Threshold Period Mitigation Mitigation duration
rate-limit-api-strict http.host eq "api.marnissi-holdings.com" 60 requests 60 seconds (per IP, ip.src) block 600 seconds (aspirational; Free plan ignores this and uses platform default ~10s — cloudflare-expert finding)

Threshold rationale: 60 requests per minute from a single IP is generous for a human operator but catches automated scanners and scripted abuse. The admin-backend API is used by a single operator at v1 scale; legitimate usage will not approach this threshold. If a CI integration (GitHub Actions calling api.*) is added, the threshold must be reviewed — machine-to-machine calls may exceed 60 rpm. Revisit when service tokens are provisioned (per ADR-0008 §Service tokens deferral).

Mitigation timeout caveat: The 600s block duration is declared in the Tofu config and persists in state, but CF's Free plan silently overrides it to the platform default (~10 seconds). An attacker can simply wait 10s and retry. This is one of the strongest arguments for the Pro upgrade — Pro restores mitigation_timeout to declared values + adds JA3/cookie/header characteristics + unlocks 10 rate-limit rules. Module README and runbook must document this caveat prominently.

Quota summary after this ADR

Quota Free limit Used after this ADR Headroom WARN trigger
CF WAF custom rules 5 2 3 WARN at 4/5
CF Rate Limiting rules 1 (Free; was wrongly seeded as 10) 1 0 At capacity — any additional rate-limit need requires Pro upgrade ($20/mo, 10 rules)
CF Managed Ruleset slots Free Managed Ruleset only 1 0 Pro upgrade adds full CF Managed + OWASP Core Ruleset

Consequences

  • Cost (delta vs free tier): $0 within the CF Free plan. Three coverage cliffs that the Pro upgrade ($20/month) closes: (a) full Cloudflare Managed Ruleset + OWASP Core Ruleset (vs Free Managed Ruleset subset); (b) declared mitigation_timeout honoured (vs ~10s platform default on Free); (c) 10 rate-limit rules + JA3/cookie/header characteristics (vs 1 rule, ip.src only on Free). finops-agent tracks the Pro upgrade as a "security cliff" analogous to the 50-seat Access cliff.

  • Operational surface:

  • modules/cloudflare-waf/ is a new Tofu module. infra-agent owns the HCL; cloudflare-expert validates provider schema; secops-agent reviews on every PR that modifies the module.
  • FP review cadence: Weekly review of the CF Security Events dashboard for the first 30 days after initial apply. Identify any false positives from managed rulesets or custom rules. Skip rules (allowlists) are not pre-provisioned; they are added as follow-up PRs reviewed by secops-agent when a confirmed FP is identified. Skip rules consume custom rule quota — each skip rule uses 1 of the 5-rule quota.
  • Managed ruleset version bumps: CF auto-upgrades managed ruleset versions. The operator subscribes to CF changelog notifications or checks the CF dashboard weekly. Version bumps trigger the 7-day log-mode window per the discipline described in §Managed rulesets.
  • Host-header rule maintenance: Any new hostname added to infra/CLAUDE.md §Hostname catalog requires a same-PR update to the block-host-header-smuggling expression. This is a standing process requirement.
  • Bot UA list maintenance: The block-bad-bot-ua rule's UA list is static in Tofu. Add new known-bad UAs via PR as they are identified from security events. No automated update mechanism at v1 scale.
  • Managed ruleset enforce-mode flip: Day 7 post-apply: operator reviews CF Security Events filtered by Source = Managed Ruleset, then opens a PR removing the overrides.action = "log" override (CF's default actions apply thereafter).
  • Rate-limit threshold review: If CI service tokens are provisioned (ADR-0008 deferral), review the 60 rpm threshold before enabling machine-to-machine calls to api.*.

  • Security posture:

  • Defence-in-depth is now complete for all six live hostnames: public hostnames (apex, www, docs) are protected by WAF payload filtering and rate limiting; internal hostnames (admin, api, labs) are protected by CF Access (authn) + WAF (payload) + rate limiting (volume).
  • The Cloudflare Managed Ruleset provides CF-curated coverage of active CVE exploits, bad actors, and bot traffic — maintained by Cloudflare without operator action.
  • The OWASP Core Ruleset (subject to plan verification) adds structured coverage of OWASP Top 10 attack categories: SQLi, XSS, path traversal, command injection, and others.
  • block-bad-bot-ua removes known offensive tooling before payload inspection reduces load on managed rulesets for the most obvious scanner traffic.
  • block-host-header-smuggling prevents a class of cache-poisoning and routing-abuse attacks that do not trigger signature-based rules.
  • rate-limit-api-strict caps per-IP request volume to api.*, providing a last line of defence against brute-force and credential-stuffing attacks that bypass Access (e.g., via a stolen JWT before session expiry).
  • secops checklist for every infra PR touching modules/cloudflare-waf/:

    • (a) All six hostnames from infra/CLAUDE.md §Hostname catalog are present in the block-host-header-smuggling expression; if a new hostname exists in DNS, it must be here too.
    • (b) block-bad-bot-ua does not block CF verified bots or Googlebot (cf.verified_bot_category guard is present).
    • (c) Rate-limit threshold (60 rpm) has not been raised above 300 rpm without an ADR amendment.
    • (d) Custom rule expressions have been sandbox-tested before apply.
    • (e) No custom rule has action = "allow" without secops-agent explicit sign-off (skip rules must be justified).
    • (f) OWASP plan-availability finding is recorded in the module PR if cloudflare-expert found a constraint.
    • (g) WAF is not disabled at the zone level (zone security level must remain medium or higher per ADR-0006).
  • Reversibility: Deleting modules/cloudflare-waf/ reverts the zone to allow-all in every WAF phase — all six hostnames become unprotected. Any individual rule can be disabled (action changed to log) without deletion. Managed rulesets can be placed into log-only mode without removing them. Rollback of a specific rule is a one-variable Tofu change; rollback of the full module is a tofu destroy -target module.cloudflare_waf.

  • Migration path if we revisit:

  • If the 5 custom-rule quota becomes binding (e.g., more than 2 emergency tactical rules are needed), upgrade to CF Pro ($20/month) for 20 custom rules. No module re-architecture required — only the CF plan changes.
  • If OWASP coverage provided by CF becomes inadequate, layer ModSecurity (via modsecurity-nginx in the Dockerised NGINX reverse proxy on the OCI VPS) as an origin-side complement. This is out of scope for v1 and adds operational overhead to the OCI VPS.
  • If the rate-limit threshold proves too restrictive for a CI integration, the Free quota (1 rule total) is exhausted — Pro upgrade is the only path. Without Pro, the only Free-plan option is to widen the existing rule's threshold, which weakens abuse defence for all callers.

Alternatives considered

Option Why rejected
AWS WAF Adds AWS as a third cloud provider with its own billing, IAM, and API surface. Traffic enters Cloudflare first in all cases (ADR-0006, ADR-0007); applying a second WAF on a cloud the traffic does not originate from provides no additional edge protection and introduces latency on the origin leg. Cost: $5/month base + $1/million requests. No benefit over CF WAF for traffic already inside the CF network.
Origin-only ModSecurity (NGINX on OCI VPS) Does not filter at the CF edge. Malicious requests consume OCI VPS CPU and network before being blocked. Encrypted tunnel traffic is not inspectable at the CF edge without terminating TLS, making edge-level custom rules the only viable layer for signature-based filtering. ModSecurity on the VPS can be added as a complementary layer at a future date but cannot replace edge WAF.
No WAF (status quo) Violates the defence-in-depth principle and leaves the three public hostnames (apex, www, docs) with no automated payload filtering between the internet and the origin. infra/CLAUDE.md §Stack explicitly names WAF as part of v1. Rejected without further analysis.
Custom rules only (no managed rulesets) Hand-rolling CVE coverage is operationally unmaintainable. Cloudflare's managed ruleset is updated by CF's threat-intelligence team continuously. Custom rules handle MGH-specific surfaces (host header, bot UAs, API rate limiting) that managed rulesets do not cover by design; they are complementary, not substitutes.
CF Pro from day one The 5 custom-rule quota on Free is sufficient at v1 scale (3 rules used; 2 reserved). CF Pro costs $20/month. Deferring to CF Pro until the quota is saturated preserves $240/year. The migration path when Pro is needed is a one-line plan change. Premature optimisation at current scale.
Cloudflare Bot Management (paid add-on) Provides sophisticated bot scoring beyond cf.verified_bot_category. Not available on the Free plan. The block-bad-bot-ua custom rule covers the known-bad-UA threat vector at zero cost. Revisit if automated abuse patterns emerge that the UA-based rule does not catch.