Managed Rules Straight to Block: Triaging False Positives on a JSON API
An API-first SaaS runs api.example.com (REST plus a single /v1/graphql endpoint) and a React SPA. To close findings from a pen test, the platform team enables the Cloudflare OWASP Core Ruleset and a new custom rule set, both set to Block, in one change window with no staging period. Within 30 minutes, 4.3% of legitimate requests return 403, concentrated in three places: POST /v1/files (multipart uploads of base64-encoded PDFs), POST /v1/graphql, and PATCH /v1/notes, where customers legitimately paste SQL and HTML snippets into note bodies. Support tickets spike and two enterprise customers escalate. Meanwhile the pen-test finding that triggered the project - unauthenticated path traversal probing on /v1/render?template=../../etc/passwd - is still reaching the origin, because the custom rule only matches the literal string /etc/passwd after URL decoding, and the testers use ....// and other encodings. The team's first instinct is to disable the ruleset until the next sprint.
1. Identify the process error in the rollout and describe the staged approach you would have used instead, including what you do now.
Read the worked answer
The error is treating a managed ruleset change as a binary deploy: no staging period, Block on day one, no per-endpoint baseline, and a new custom ruleset bundled into the same change.
Staged approach: (1) deploy the Core Ruleset in Log action and ship payload logs to Logpush for one to two weeks; (2) build a per-path false-positive rate and identify the specific rule IDs firing on /v1/files, /v1/graphql and /v1/notes; (3) enable Block globally, but set rule-level overrides to Log for those specific rule IDs on those specific paths - not the whole ruleset; (4) re-baseline and tighten monthly, with an owner and a ticket behind every exception.
Disabling the entire ruleset is the wrong correction: it removes protection everywhere to fix three rules. The targeted override does the same job with a fraction of the exposure.
Two things explain the false positives and should shape the plan. The Core Ruleset is calibrated for HTML forms and query strings; a JSON API - especially GraphQL, where query documents legitimately contain SQL-looking strings - has a different grammar, so a high false-positive rate is expected rather than surprising. And the platform team is using the WAF to compensate for the absence of positive-security controls. Adding schema validation against an OpenAPI document, plus a body-size cap on uploads so base64 blobs cannot be scanned into nonsense, reduces the surface the WAF has to guess about. Without data from a log phase, precision at the rule level is not achievable.
2. Write the scoped exception for `/v1/graphql` and explain why it does not become a bypass.
Read the worked answer
A scoped skip rule, in roughly this shape:
- Expression:
http.host eq "api.example.com" and http.request.uri.path eq "/v1/graphql" and http.request.method eq "POST" and any(http.request.headers["content-type"][*] contains "application/json") - Action: skip the specific managed ruleset or the named rule IDs - not 'skip all remaining rules', which would also bypass the custom rate limiting and any authentication rules.
Why it is not a bypass: the exception applies to one path, one method, one content type. An attacker cannot reach /v1/render or any other route through it, and cannot use GET /v1/graphql - reject GET outright at the origin, because it enables query-in-URL injection and caching of user-specific responses.
Then add what the skipped rules would have approximated. Rate limit POST /v1/graphql per session; enforce a query depth and complexity limit and a maximum body size at the origin; require a valid session token before the gateway dispatches; and log every request that hits the skip so a weekly sample can be reviewed. If the endpoint is authenticated before execution, an injection payload inside a GraphQL document is a far weaker primitive. If it were unauthenticated, do not create the skip at all - fix the client query shapes so the rules stop misfiring. The exception is only acceptable because a second layer enforces what the WAF gave up.
3. The traversal finding is still getting through. Why is the existing rule structurally wrong, and how would you rewrite the protection?
Read the worked answer
The rule matches /etc/passwd - a payload signature, not a behaviour. Signatures lose to encoding by construction: ....// collapses to ../ after one normalisation pass, and each layer (Cloudflare, load balancer, application server) may decode a different number of times. You cannot enumerate the encodings; you can only restrict the shape of legitimate input.
At the edge, stop matching payload text and constrain the parameter. This is an allowlist rule: http.request.uri.path eq "/v1/render" and the template query value must match ^[a-z0-9_-]{1,32}$; block everything else. Default-deny is robust to encoding because anything not in the permitted set is rejected regardless of how it was encoded.
At the origin, this is a path traversal vulnerability and must be fixed as code: canonicalise the resolved path and verify it stays inside the templates directory before opening it. Never concatenate user input into a filesystem path, no matter what the edge does.
Keep the old signature rule as cheap defence in depth, but never rely on it, and add the missing control - alerts on blocked-path counts, which would have shown the probing continuing after 'mitigation'.
Verify by replaying the tester's full payload list through both layers. The edge should 403 non-conforming template values; the origin should reject even with the edge rule disabled. Only the second result proves the vulnerability is closed.