Documentation

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
Adam Birds
Published 13 November 20263 min read0 views

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 .env checks.
  • Matching the decoded path does not replace application-side protection. Never let a framework serve dotfiles or files from outside its static directories.
Last updated: 13 November 2026v1