Cloudflare WAF Custom Rules Bypassed by URL-Encoded Paths (%2F)
Cloudflare custom rules match the still-encoded path, so /settings%2F.env slips past "/.env" checks. Match on url_decode() instead.
A Cloudflare WAF custom rule that blocks /.env still lets vulnerability scanners through when they percent-encode the slash, and the application behind Cloudflare happily serves the decoded path.
A rule such as this one blocks /.env but not the encoded variants:
(lower(http.request.uri.path) contains "/.env")
The origin’s access log shows the requests that got through:
<client-ip> - - [<date>] "GET /settings%2F.env HTTP/1.1" 404 ...
<client-ip> - - [<date>] "GET /api%2F.env HTTP/1.1" 404 ...
<client-ip> - - [<date>] "GET /api/uploads/%2e%2e%2f%2e%2e%2f.env HTTP/1.1" 404 ...
The application logs them already decoded:
Not Found: /settings/.env
Not Found: /api/uploads/../../.env
Cause
http.request.uri.path is matched in its percent-encoded form. Cloudflare’s URL normalisation decodes unreserved characters (so %2e becomes .), but it leaves %2F encoded because it is a reserved character. The path /settings%2F.env therefore never contains the string /.env. Frameworks such as Django and Next.js decode the path before routing, so the request reaches the application as /settings/.env.
Even when the file does not exist, each probe still reaches the application. A fast scan can turn into hundreds of application requests and database queries in a few seconds.
Fix
Match against the decoded path with url_decode(). The "r" option decodes recursively, so double-encoded input (%252F) is caught too:
lower(url_decode(http.request.uri.path, "r"))
url_decode() works in custom rules on the Free plan. The Free plan allows five custom rules and no regular expressions, so group related checks into one rule with plain contains, starts_with and ends_with.
Example Terraform
locals {
path = "lower(url_decode(http.request.uri.path, \"r\"))"
# File types the site never serves.
blocked_extensions = ["env", "py", "php", "pem", "key", "bak", "sql",
"tfstate", "tfvars", "yml", "yaml", "toml", "ini", "log", "sh"]
# Secret file names. Each contains a dot, so no normal page slug matches.
secret_file_names = ["credentials.", "service-account.", "docker-compose.",
"firebase-admin.", "phpinfo"]
}
resource "cloudflare_ruleset" "waf_custom" {
zone_id = var.zone_id
name = "default"
kind = "zone"
phase = "http_request_firewall_custom"
rules = [
{
ref = "block_probes"
description = "Block dotfile and path traversal probes"
action = "block"
enabled = true
expression = join(" or ", [
# Every dot path except /.well-known/ (ACME, security.txt).
"(${local.path} contains \"/.\" and not starts_with(${local.path}, \"/.well-known/\"))",
"(${local.path} contains \"/..\")",
"(${local.path} contains \"/proc/self/\")",
])
},
{
ref = "block_secret_files"
description = "Block requests for secret, config and source files"
action = "block"
enabled = true
expression = join(" or ", concat(
[for ext in local.blocked_extensions : "(ends_with(${local.path}, \".${ext}\"))"],
[for name in local.secret_file_names : "(${local.path} contains \"${name}\")"],
))
},
]
}
Adjust the lists to match what the sites actually serve. If a legacy URL such as /index.php must keep working, exempt it in the expression, for example (ends_with(<path>, ".php") and <path> ne "/index.php").
Check for false positives before applying
Replay the rules against a real access log. This Python script reads an nginx combined-format log on stdin and lists every request the rules would block that the origin currently answers with a 2xx or 3xx:
import collections, re, sys, urllib.parse
EXT = "env py php pem key bak sql tfstate tfvars yml yaml toml ini log sh".split()
NAMES = ["credentials.", "service-account.", "docker-compose.", "firebase-admin.", "phpinfo"]
def decode(s):
prev = None
while prev != s:
prev, s = s, urllib.parse.unquote(s)
return s
def blocked(path):
p = decode(path).lower()
if "/." in p and not p.startswith("/.well-known/"):
return True
if "/.." in p or "/proc/self/" in p:
return True
if any(p.endswith("." + e) for e in EXT):
return True
return any(n in p for n in NAMES)
rx = re.compile(r'"[A-Z]+ (\S+) HTTP/[^"]*" (\d{3})')
hits = collections.Counter()
for line in sys.stdin:
m = rx.search(line)
if m and blocked(m.group(1).split("?", 1)[0]) and m.group(2)[0] in "23":
hits[(m.group(2), m.group(1))] += 1
for (status, path), n in hits.most_common(30):
print(n, status, path)
sudo cat /var/log/nginx/access.log /var/log/nginx/access.log.1 | python3 waf_check.py
Every line it prints is a request that would start returning 403. Check that each one is a probe and not a real page.
Verify
After applying, encoded probes should get Cloudflare’s 403, while real pages and /.well-known/ still work:
for p in "/settings%2F.env" "/api/uploads/%2e%2e%2f%2e%2e%2f.env" "/backup.env" "/" "/.well-known/security.txt"; do
curl -s -o /dev/null -w "%{http_code} $p\n" "https://<your-domain>$p"
done
The first three should return 403 and / should return 200. /.well-known/security.txt should return the origin’s own response (200, or 404 if the file does not exist).
Notes
- The same gap affects any expression that matches path substrings, not just
.envchecks. - Matching the decoded path does not replace application-side protection. Never let a framework serve dotfiles or files from outside its static directories.