---
title: "Cloudflare WAF Custom Rules Bypassed by URL-Encoded Paths (%2F)"
canonical: "https://techwiki.co.uk/networking/cloudflare-waf-url-encoded-path-bypass"
author: "Adam Birds"
article_type: "documentation"
published: "2026-11-13T20:05:58.122Z"
updated: "2026-11-13T20:05:58.122Z"
categories: ["Networking"]
tags: ["cloudflare","django","terraform"]
---**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:

```text
(lower(http.request.uri.path) contains "/.env")
```

The origin's access log shows the requests that got through:

```text
<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:

```text
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:

```text
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

```hcl
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:

```python
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)
```

```bash
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:

```bash
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.
