| name | cloudflare-waf-author |
|---|---|
| description | Workflow for crafting Cloudflare WAF rules across all three authoring interfaces - Terraform (cloudflare_ruleset), the Cloudflare Dashboard, and the Rulesets API. Covers both Custom Rules (actions like block, managed_challenge, log, skip-as-allow) and Managed-Rule Exceptions (action skip). Forces a docs-first read, peer-rule survey, explicit rule-type branch, and guards-by-type checklist before any expression is drafted. Use when adding or editing any Cloudflare WAF rule, when the user asks for a WAF block / challenge / allow / skip / OWASP child-rule bypass, or when reviewing such a change. |
Companion rule: 405-cloudflare-waf-rules.mdc (file-scoped to Cloudflare ruleset Terraform / JSON; topic-discoverable for Dashboard authoring). The rule is the gate; this skill is the workflow through the gate.
Voice: opinionated. Every WAF rule is a policy commitment that ships to production at the edge. Custom rules can DoS your own users; managed-rule skips can silently bypass OWASP signatures. The job is to make the predicate exactly the right shape and exactly traceable to a request.
Invoke when:
- The user asks to add or edit a Cloudflare WAF rule of any kind - custom block, allow, challenge, log, or a managed-rule skip / exception / OWASP child-rule bypass.
- A diff touches a Cloudflare ruleset Terraform file (matches the file globs in
405-cloudflare-waf-rules.mdc) or a JSON payload destined for the Rulesets API. - The user is authoring a rule via the Cloudflare Dashboard (UI clicks under Security → WAF) and mentions it in the conversation.
- A user mentions a change ticket asking to "block country X", "allow IP range Y", "stop the WAF from blocking endpoint Z", or any equivalent.
- A code or change review finds a candidate rule that violates
405-cloudflare-waf-rules.mdc.
Do NOT invoke for:
- Cloudflare Single Redirect / Origin Rule / Transform Rule edits - different ruleset phases.
- Account-level WAF policy or Bulk Redirects - this skill is zone-level WAF only.
- Bot Management or DDoS configuration - separate Cloudflare products.
Before Step 0, confirm which of the three interfaces the user is authoring through. This determines provenance carriers, position semantics, and the templates you'll use in Step 4.
| Signal | Interface |
|---|---|
The user opened a .tf file, mentioned terraform apply, or pushed a branch to an IaC repo |
Terraform |
| The user said "I'm in the Cloudflare dashboard", "WAF section", "Security → WAF" | Dashboard |
The user mentioned curl, client/v4/zones/<id>/rulesets, a JSON payload, or a script |
API |
If unclear, ask. Per 405-cloudflare-waf-rules.mdc § "Source-of-truth discipline (NON-NEGOTIABLE)", a zone should have one documented authoring path; if the user is authoring via a path other than the documented one, push back before proceeding.
Before anything else, classify the request. Everything downstream depends on this.
| Symptom | Rule type | Ruleset phase | Typical actions |
|---|---|---|---|
| "Block traffic from country X" / "Allow only trusted IPs to /admin" / "Challenge known-bad ASN" | Custom Rule | http_request_firewall_custom |
block, managed_challenge, js_challenge, log, skip (as allow) |
| "WAF is blocking my legitimate POST" / "Skip OWASP rule X for endpoint Y" / "False positive on file upload" | Managed-Rule Exception (skip) | http_request_firewall_managed |
skip only |
Important
If the requester is asking for both ("allow these IPs AND skip the OWASP rules for them"), that's TWO separate rules in TWO separate phases. Don't conflate them. In Terraform, they live in separate files (or separate cloudflare_ruleset resources); in the Dashboard, they're under different navigation paths; in the API, they're separate ruleset IDs.
If the rule type is unclear from the request, stop and ask the requester. Don't guess - the wrong phase is a wrong-behavior rule that may not fire at all.
Open the docs for the chosen rule type and skim before writing any expression. Do not rely on training-data memory; the API and field names evolve. If a user-cloudflare-docs MCP server is configured in the workspace, prefer it for fresh fetches.
| Doc | Why |
|---|---|
| Ruleset Engine fields reference | Verify exact field names. Multi-value fields (headers) need any(... [*] ...). |
| Ruleset Engine operators / functions | eq, in, contains, starts_with, lower(...), any(...). Operator gotchas live here. |
| Cloudflare country codes reference | Always paste country codes from this page; never type from memory. |
| Doc | Why |
|---|---|
| WAF Custom Rules | Action options and their UX implications (block vs challenge vs managed_challenge vs js_challenge). |
cf.* request fields |
cf.client.bot, cf.threat_score, cf.waf.score (Enterprise) - the high-leverage signals. |
400-cloudflare.mdc § "WAF Attack Score (Plan-Aware)" |
When and how to use the score fields without self-DoS. |
| Doc | Why |
|---|---|
| Add an exception via API | Confirms the four exception types, the action_parameters shape, and the must-be-positioned-before-execute-rule rule. |
| Create an exception (Ruleset Engine) | When to use ruleset: current vs rulesets: [...] vs rules: { id: [...] } (you almost always want the third). |
| WAF changelog | Has the OWASP child-rule list you're targeting moved or been deprecated? |
| OWASP Managed Ruleset reference | What the rule IDs you're skipping actually do. |
| Interface | Additional doc |
|---|---|
| Terraform | WAF Managed Rules with Terraform and terraform-provider-cloudflare changelog. |
| Dashboard | Custom Rules in the Dashboard and the Audit Logs documentation. |
| API | Rulesets API reference plus the expression test endpoint. |
Important
Cite relevant documentation in the PR, change ticket, Dashboard runbook, or API sibling .md. Do not paste generic documentation URLs into every Terraform rule comment.
Identify the closest existing rule to your case in the same zone and mirror its shape. The carrier for the survey depends on the interface:
| Interface | How to survey |
|---|---|
| Terraform | Open the target .tf file (matching Step 0's phase). Read every rule. Pick the closest one. |
| Dashboard | Navigate to Security → WAF → Custom rules (or Managed rules → Exceptions). Read every existing rule's expression and description. |
| API | GET /client/v4/zones/<zone_id>/rulesets/<ruleset_id> to fetch the current ruleset JSON. Grep the rules array for similar patterns. |
If no peer matches, stop and explain why before drafting. The handbook's canonical shapes for both rule types live in 405-cloudflare-waf-rules.mdc § "Pre-edit gate" - read those first.
Tip
If the closest peer is a year old and the requester's flow doesn't match it cleanly, that's a signal the request may need a different approach (a new managed-rule sensitivity tune, a different action, or a code-side fix). Push back and discuss before drafting.
Ask for everything below before drafting. If any answer is missing, stop.
- Change ticket / approval number, named owner, business justification
- Exact host(s), path(s), method(s) - confirmed against actual traffic, not assumed
- Source IP shape: trusted (named list) only / trusted + geo escape hatch / fully open / specific named source set
- Soak plan: how long in
logmode before promoting to enforce, and how the soak will be validated (Security Events filter, dashboard, etc.)
- Intended action:
block,managed_challenge,log(soak),skip(allow) - Is this complementing an existing block / allow rule? If so, which one - it must be positioned correctly relative to that rule.
- Browser-app or server-to-server flow? (Origin header pinning only works for browser flows.)
- Which OWASP child-rule IDs are actually firing on the legitimate flow? (Cloudflare Security Events filter:
action=block,host=...,path=..., last 7 days. Skip only those, not the whole ruleset.)
Tip
If a custom-rule requester says "block all foreign traffic" - push back. Without a trusted-IPs escape hatch, traveling internal users and partners are blocked. Always include the escape hatch.
If a managed-rule-skip requester says "skip the whole OWASP managed ruleset for my host" - push back. Almost always 2-3 child rules are firing; skip those, not the whole ruleset.
Pick the template for your interface (from the pre-step) AND your rule type (from Step 0). Templates live in the references/ directory to keep this SKILL.md focused on the workflow.
| Rule type | Terraform | Dashboard | API |
|---|---|---|---|
| Custom Rule | references/templates-terraform.md § Custom Rule | references/templates-dashboard.md § Custom Rule | references/templates-api.md § Custom Rule |
| Managed-Rule Exception | references/templates-terraform.md § Managed-Rule Exception | references/templates-dashboard.md § Managed-Rule Exception | references/templates-api.md § Managed-Rule Exception |
Common patterns across all six combinations (interface × rule type):
- Path predicate uses one of the four sanctioned shapes from
405-cloudflare-waf-rules.mdc§ "Path predicate decision matrix" -eq(default),starts_with(..., "...")with trailing slash, alternation, or - never - barestarts_withwithout trailing slash. - Multi-value headers use
any(http.request.headers["X"][*] ...), never bareeq. - Content-type guards use
any(lower(http.request.headers["content-type"][*])[*] contains "multipart/form-data"). - Multipart skips include the
filename=body marker. - Trusted-IP guards reference a named Cloudflare IP List (the convention in this skill is
$trusted_egress_ips; your zone's actual list name will differ - use it). - Allow-before-block ordering is non-negotiable in custom rules: the allow-skip rule must be positioned ABOVE the block rule it complements.
Provenance is mandatory, but it must not duplicate the rule expression or the change ticket:
| Interface | Carrier |
|---|---|
| Terraform | One direct HCL # line above the rule; add up to two lines only for non-obvious security or lifecycle context. Detailed evidence stays in the PR/ticket. |
| Dashboard | (1) Rule description (purpose-based, optionally ticket-suffixed) plus (2) an out-of-band runbook entry (Confluence / ServiceNow / repo doc). Both are required - Audit Logs cover who / what / when but never why. |
| API | Repo-resident sibling .md file alongside the JSON payload. The description field carries the purpose. |
For Terraform, retain date/ticket and purpose/scope. Add a Ray ID, child-rule lineage, unusual source scope, known gap, or expiry only when relevant. Never generate Why each guard, Approval:, or “same shape as” boilerplate. Dashboard/API records may be structured but should still omit fields that add no rule-specific value.
Then verify all four sources of truth agree:
- Title of the PR / change ticket / API call summary describes the predicate intent ("narrow", "broaden", "scope to trusted IPs", "block country X").
- Body uses the same vocabulary.
- Provenance record identifies the change and any non-obvious security decision without restating the expression.
- Saved rule expression matches all three.
A drift between any two of these is the most common cause of "the change shipped but did the wrong thing" incidents.
Run through the per-interface reviewer checklist from 405-cloudflare-waf-rules.mdc § "Reviewer checklist". The high-leverage subset (always run):
- Cited relevant Cloudflare documentation in the PR, ticket, runbook, or API sibling file
- Checked the closest peer rule; cite it only when lineage or divergence matters
-
descriptionfield is purpose-based (Terraform: ticket-free, ticket lives in concise#provenance; Dashboard / API: ticket-suffixed if required by zone convention) - Path predicate matches one of the four sanctioned shapes
- Multi-value headers use
any(... [*] ...)form (not bareeq) - No hardcoded IPs - a named IP List is referenced
- Title / body / provenance artifact / saved rule expression all agree
- Soak plan documented (log-mode duration and validation method)
- Any non-obvious action or accepted risk is documented in the appropriate change record
- Allow-as-skip rules positioned ABOVE corresponding block rules
- Geo blocks include trusted-IPs escape hatch (unless OFAC compliance block)
- No
js_challengeon/api/*paths - New block-class rules went through a
log-mode soak before being promoted - Country codes copy-pasted from Cloudflare reference, not typed
- OWASP child-rule IDs sourced from actual Security Events firings (not guessed from symptom)
- Skip rule positioned BEFORE the
executerule it bypasses - Multipart skips include the
filename=body marker - Origin guard (when present) uses
any(... [*] eq "...")form
- Terraform:
terraform fmtclean,terraform planreviewed by author + at least one peer, file mode100644, heredoc indent matches neighboring rules. - Dashboard: clone existing rule before editing, save as a single logical change per Audit Log entry, staging-zone dry-run completed, runbook entry created.
- API:
GET+ ETag captured,PUTwithIf-Match,position.before/after(notindex), JSON validated via expression test endpoint, response logged to the run-log.
See references/common-failure-modes.md for failure-mode patterns observed across many WAF authoring teams (not specific to any one team).
405-cloudflare-waf-rules.mdc- the rule (file-scoped guardrail; covers both rule types and all three interfaces)400-cloudflare.mdc- broader Cloudflare ruleset patterns including OWASP Managed Ruleset configuration (PL1 + anomaly score threshold 60)cloud-platformsskill - cross-cloud platform patternscodebase-security-auditskill - WAF rule reviews are security reviews