TL;DR
Manage Cloudflare WAF Custom Rules from .NET 10 via the v4 API and guarantee the same 4 code-pushed rules + 1 manual exception across pod restarts with an idempotent seeder. An
ICloudflareWafRulesServiceinterface backed by typedHttpClient+ Polly 429 retry + structured logging avoids the legacydynamicparsing trap. Full code, test strategy and production landmines below — straight from Bilal's live system.
Author note: All the code and examples in this article come from the live bilalkose.com.tr system. 4 code-pushed rules + 1 manual self-IP bypass are active (as of 2026-05-12); on pod restart, WafRulesSeeder runs an idempotent seed driven by an MD5-based diff. Pillar 1 H2-3 sketched this surface; this article digs into the implementation, error handling and test strategy.
Table of Contents
- Custom Rules API: 3-layer anatomy
ICloudflareWafRulesService: the CRUD interface- Idempotent seed: same script running 100 times
- WAF Rule Expression DSL: 6 traps
- Error handling: 429 + 401 + 5xx
- Test strategy: unit + integration + smoke
- Frequently Asked Questions
- Related Posts
Custom Rules API: 3-Layer Anatomy
Cloudflare Custom Rules (formerly "Firewall Rules") are zone-scoped: each domain holds its own rule set. Free plan gets 5 rules, Pro plan gets 25. Our setup hosts 3 separate zones (bilalkose.com + bilalkose.com.tr + maya.bilalkose.com.tr); each zone carries its own 4 code-pushed rules + 1 manual exception.
A single Custom Rule has three components:
- Filter expression — Cloudflare's custom DSL: e.g.
(http.request.uri.path contains "/.env") or (lower(http.user_agent) contains "sqlmap"). Matches against IP, ASN, country, headers, path, method, query string. - Action — what happens on match:
block,challenge,js_challenge,managed_challenge,log,skip. Block is the strongest; log silently records a trace. - Priority — integer; lower value matches first. Same priority defaults to definition order.
The v4 API endpoint is POST/GET/PUT/DELETE /zones/{zone_id}/firewall/rules. For auth, the 2023+ recommendation is API Token (scope: zone:waf:edit + zone:zone:read). The legacy Global API Key is DEPRECATED — it carries account-wide privileges and creates blast-radius risk.
Pillar 1 noted that "managing WAF rules manually is risky." This article is about how we eliminated that risk: code-pushed seed + idempotency + audit log.
![Cloudflare Dashboard → Security → WAF → Custom rules: 5 rules live (4 [Auto] + 1 [Manual] self-IP bypass)](/uploads/blog/2026-05/b1-intro-cf-custom-rules.png)
ICloudflareWafRulesService: The CRUD Interface
Design centers on one interface — all Cloudflare WAF operations flow through it.
public interface ICloudflareWafRulesService
{
Task<IReadOnlyList<WafRule>> ListAsync(string zoneId, CancellationToken ct);
Task<WafRule?> GetAsync(string zoneId, string ruleId, CancellationToken ct);
Task<WafRule> CreateAsync(string zoneId, WafRuleSpec spec, CancellationToken ct);
Task<WafRule> UpdateAsync(string zoneId, string ruleId, WafRuleSpec spec, CancellationToken ct);
Task DeleteAsync(string zoneId, string ruleId, CancellationToken ct);
}
public sealed record WafRuleSpec(
string Description,
string Expression,
WafRuleAction Action,
int Priority,
bool Paused = false);
public enum WafRuleAction { Block, Challenge, JsChallenge, ManagedChallenge, Log, Skip }
Immutable record spec + IReadOnlyList<> return: callers can't mutate state. The Description field gets reused as an idempotency key in the next section.
HTTP layer uses the typed HttpClient pattern:
services.AddHttpClient<ICloudflareApiClient, CloudflareApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.cloudflare.com/client/v4/");
var token = configuration["CloudflareApi:ApiToken"]
?? throw new InvalidOperationException("CloudflareApi:ApiToken not configured");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddPolicyHandler(GetRetryPolicy());
JSON serialization uses System.Text.Json + JsonNamingPolicy.SnakeCaseLower (.NET 8+). Cloudflare API speaks snake_case (zone_id, created_on); this avoids hand-rolling [JsonPropertyName] on every property.
Every Cloudflare API response shares the same envelope:
public sealed record CloudflareApiResponse<T>(
bool Success,
IReadOnlyList<CloudflareError>? Errors,
T? Result);
public sealed record CloudflareError(int Code, string Message);
When Success: false, Errors is populated; the service layer throws CloudflareApiException. A Result-pattern (Result.IsSuccess) is fine too, but exception-based flow stays simple for this small internal API.

Idempotent Seed: Same Script Running 100 Times
Every time a K8s pod restarts, WafRulesSeeder fires as IHostedService. Running the same seed 100 times must be a no-op — no rule should get duplicated nor deleted.
The pattern has five steps:
Step 1 — Description-based identity. Every code-pushed rule's description starts with the [Auto] prefix: [Auto] Block sensitive path scanners. The prefix doesn't collide with manual rules (Bilal's self-IP bypass carries [Manual] Bilal self-IP bypass).
Step 2 — List + match. List all rules in the zone, match by description against the existing set:
var existing = await _service.ListAsync(zoneId, ct);
var existingByDesc = existing
.Where(r => r.Description.StartsWith("[Auto] "))
.ToDictionary(r => r.Description, r => r);
Step 3 — Diff hash. Compute an MD5 hash of the new spec's expression + action + priority triple and compare it to the existing rule's hash.
private static string ComputeRuleHash(WafRuleSpec spec)
{
var raw = $"{spec.Expression}|{spec.Action}|{spec.Priority}";
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(bytes);
}
Step 4 — Decide & execute. Equal hash → SKIP, missing → CREATE, different hash → UPDATE.
Step 5 — Cleanup. Delete any [Auto]-prefixed rule not present in this seed. Manual rules ([Manual] or no prefix) are left untouched.
Log every action with structured logging:
_logger.LogInformation(
"WafSeeder {Action}: ZoneId={ZoneId} RuleId={RuleId} Description={Description}",
action, zoneId, ruleId, description);
On pod restart these logs flow to Loki/Promtail, and Grafana shows action stats. If the Cloudflare API is unreachable during seed, the app does not crash — best-effort, warning log only.
WAF Rule Expression DSL: 6 Traps
Cloudflare's expression language derives from the wirefilter project. Most syntax looks familiar but small surprises bite. The six traps that hit us in production:
Trap 1 — contains is case-sensitive. http.user_agent contains "Curl" will not match "curl" — Cloudflare doesn't normalize. Fix: lower(http.user_agent) contains "curl".
Trap 2 — IPv6 must be quoted, IPv4 must not. ip.src in {1.2.3.4} ✓ but ip.src in {::1} ✗. Correct form: ip.src in {"::1"} (double quotes required for IPv6). In mixed lists, splitting into two rules is cleanest.
Trap 3 — No wildcard glob. /admin/* does not work as a URL pattern. Cloudflare uses regex: http.request.uri.path matches "^/admin/". For performance the starts_with operator is preferred: starts_with(http.request.uri.path, "/admin/").
Trap 4 — ASN list capped at 100. A single expression ip.geoip.asnum in {15169 8075 ...} takes a maximum of 100 ASNs. Our 40+ ASN DatacenterAsnRegistry fits one rule, but at scale you need CF Lists (covered in cluster B2).
Trap 5 — not precedence. not http.user_agent contains "Googlebot" applies to all UAs — wrong. Fix: parenthesize (not (http.user_agent contains "Googlebot")). In any non-trivial condition, always use explicit parens.
Trap 6 — String literal escaping. Use \" for double quotes or alternate to single quotes. Newline as \n or hex \x0a. Backslash itself is not auto-escaped; you need \\. In regex patterns this means writing \\\\. instead of \\. — unreadable, so be careful.
Error Handling: 429 + 401 + 5xx
Rate limit (429). Cloudflare API rate limit per token is 1200 requests / 5 minutes. During a pod restart we make 4 zones × 5 list calls = 20 requests, no concern. With Polly, exponential backoff: 1s, 2s, 4s, 8s (max 30s). If a Retry-After header is present, honor it; otherwise exponential.
Auth failure (401/403).
401 Unauthorized— token expired or rotated. Fail-fast, log "Vault token rotation needed".403 Forbidden— token scope insufficient (e.g. missingzone:waf:edit). This is a deploy-time error; retrying is pointless.
Server error (5xx). Treat as transient, retry up to 3 times, then log. For persistent 5xx use a Polly circuit breaker: 5-minute bypass window, app throws CloudflareApiException but doesn't block startup.
Idempotency-Key header. Cloudflare API supports it, optional but recommended. One GUID per logical operation, same key on retries — duplicate detection on the CF side. In our flow CreateAsync carries it; list/get/delete are idempotent by nature.
Test Strategy: Unit + Integration + Smoke
Unit (xUnit + Moq). Test WafRulesSeeder diff logic against a fake ICloudflareWafRulesService:
- Scenario A: zone has no rules → 4 Creates
- Scenario B: zone already has the same 4 rules (hash match) → 0 actions
- Scenario C: one of the 4 rules has a changed expression → 3 Skips + 1 Update
- Scenario D: zone has a stale 5th
[Auto]rule → 4 Skips + 1 Delete
Integration (WireMock.Net). Mock the CF API and exercise the real HttpClient pipeline (Polly retries included). 429 + 401 + 5xx scenarios + Idempotency-Key replay test.
Smoke (production). Parse the seed log after pod startup: Create:N, Update:M, Skip:K, Delete:L — feed those numbers into a Prometheus counter cf_waf_rules_seeded_total{action="create"}. Grafana alert: seed fail rate > 1% → tier-1 critical security alert via Telegram.
Total test cost: ~120 lines of xUnit + ~80 lines of WireMock setup. CI completes in 8 seconds.
Frequently Asked Questions
How many custom rules do I get on free plan?
Five. Pro plan gives 25. With 4 automated rules + 1 manual exception, free plan suffices. When more rules are needed, CF Lists (10K IPs/list × 10 lists/account = 100K capacity) scale within a single rule.
API Token vs Global API Key — which should I use?
Token, scoped to zone:waf:edit + zone:zone:read. Global Key is DEPRECATED 2023+, account-wide; if leaked the whole account is at risk. Tokens are revocable + scope-bounded + audit-logged.
How fast does a WAF Custom Rule change go live?
Generally 5–10 seconds. Worldwide propagation is under 30 seconds. Manual dashboard edits behave the same.
What if two pods run idempotent seed simultaneously?
The race lives on the CF API; the second pod gets 409 Conflict. For a hard cluster lock add IDistributedLock (Redis-backed), but at our scale it's overkill — 4 zones × 5 rules is a small operation with a millisecond race window.
Related Posts
- Pillar 1 — Cloudflare Edge Security and .NET (cross-link back)
- B2 — Cloudflare IP List API for 10K IP push (B1's scale follow-up)
- B3 (upcoming) — ASN-based bot detection + DatacenterAsnRegistry deep dive
- C7 (upcoming) — Origin block loop: TrafficClassifier → IpListService
Comments (0)
No comments yet. Be the first to comment.