Path Normalization at the Tunnel Edge: Why a Route Matches on localhost but Not Through a Tunnel

Your /admin route works on localhost:8080. Through the tunnel, //admin returns something different, /public/../admin behaves differently again, and a rule you assumed was guarding the path never seems to fire. Nothing is wrong with your app. Several components are each parsing the same path with different rules, and a tunnel adds one more parser to the chain.
That chain is about to change. Starting 2027-01-25, new cloudflared releases will turn on URL normalization by default, which Cloudflare says may change which ingress rule matches a request. This guide explains what changes, what doesn't, how path-confusion auth bypasses work, and how to test your own routing before the default flips.
Quick summary
cloudflared2026.10.0 and later can normalize the URL path before matching ingress rules.- It is off by default today and on by default in new releases from 2027-01-25.
- Cloudflare scopes the change to tunnels with ingress rules that require Cloudflare Access validation; other configurations are unaffected.
- The documented transformations are dot-segment removal, backslash-to-slash conversion and merging repeated slashes. The path forwarded to your origin is not modified.
- Percent-encoding (such as
%2F) and trailing slashes are not in the documented list, so test them instead of assuming.
What Cloudflare announced
On October 22, 2026, Cloudflare published a changelog entry titled "Cloudflare Tunnel supports URL normalization." The key facts:
- Availability:
cloudflaredversion2026.10.0and later. GitHub lists 2026.10.0 as released on October 5, 2026; the newest release I could find as of November 5, 2026 is 2026.10.6. - Default today: off. The flag is inverted, so the documented default of
--disable-path-normalizationistrue. - Default from 2027-01-25: on, for new releases.
- Scope: Cloudflare says it improves security for tunnels with rules that require Access validation, and that configurations without such rules are unaffected.
- Origin path: unchanged. Normalization applies to the path used for matching, not the path forwarded.
To enable it now:
cloudflared tunnel --disable-path-normalization=false run <TUNNEL_NAME_OR_UUID>
or set TUNNEL_DISABLE_PATH_NORMALIZATION=false on each cloudflared replica and restart it.
One operational detail deserves attention. Standalone cloudflared binaries running as a service auto-update every 24 hours by default (autoupdate-freq). Package-manager installs, Windows and interactive runs don't auto-update. If your tunnel auto-updates, or you deploy a floating latest image tag, the new default will arrive without a deliberate upgrade. Either set the flag explicitly now or pin your version until you've tested.
What gets normalized, and what doesn't
Cloudflare's configuration docs describe the behavior with three examples. Incoming paths are shown as cloudflared receives them:
| Incoming path | Used for ingress matching | Sent to origin |
|---|---|---|
/hello/../path |
/path |
/hello/../path |
/hello//path |
/hello/path |
/hello//path |
/hello\path |
/hello/path |
/hello\path |
In words: dot segments (. and ..) are removed following RFC 3986 section 5.2.4, backslashes become forward slashes, and repeated slashes are merged.
Notice what the documentation does not list: percent-decoding, trailing-slash handling, semicolon path parameters, and case folding. This matters because RFC 3986 draws a hard line here. A percent-encoded unreserved character such as %61 (the letter "a") is equivalent to its literal form, so normalizers may decode it. A percent-encoded reserved character is not equivalent: %2F is not the same as /, and a standards-compliant normalizer must not collapse them.
My takeaway: treat anything outside Cloudflare's three documented transformations as "not normalized" until your own test shows otherwise. I verified this against Cloudflare's documentation, not by instrumenting cloudflared, so the test matrix below is the way to confirm behavior on your version.
Why localhost and a tunnel disagree
Hitting localhost:8080/admin involves one path parser: your framework's. A tunneled request passes through at least three:
- The Cloudflare edge, which has its own URL normalization feature. Per Cloudflare's docs, incoming-URL normalization is on by default, and certain baseline steps (merging adjacent slashes, for example) are always applied and cannot be disabled.
cloudflared, which matches the path against your ingress rules.- Your origin, whose framework, reverse proxy or static-file handler applies its own decoding and dot-segment logic.
There is a fourth, easily overlooked participant: your test client. Browsers resolve .. segments before sending a request, and the WHATWG URL parser treats backslashes as slashes for http and https URLs. So clicking around in a browser almost never exercises these inputs. Even curl collapses dot segments unless you pass --path-as-is. When you "tested" a path and it worked, the client may have rewritten it into a safe one first.
Any disagreement between parsers is a potential bypass. If layer two thinks the request is for /public/... and layer three thinks it's for /admin, a rule written for one is evaluated against the other.
The path-confusion auth bypass, step by step
Consider a tunnel that protects /admin with Access validation at cloudflared, defense in depth behind your Access policy:
tunnel: <TUNNEL_UUID>
credentials-file: /etc/cloudflared/<TUNNEL_UUID>.json
ingress:
- hostname: app.example.com
path: ^/admin(/|$)
service: http://localhost:8080
originRequest:
access:
required: true
teamName: <your-team-name>
audTag:
- <access-application-aud-tag>
- hostname: app.example.com
service: http://localhost:8080
- service: http_status:404
Ingress rules are evaluated first to last, and path is a regular expression parsed with Go's regexp/syntax package. Now send GET /public/../admin/users.
- Normalization off: the matcher sees
/public/../admin/users. The first rule's regex requires the path to start with/admin, so it doesn't match. The request falls through to the second rule, which has no Access requirement, and is forwarded with its original path. - Your origin collapses the dot segment, routes the request to
/admin/users, and serves it. The JWT check that was supposed to guard/adminnever ran. - Normalization on: the matcher sees
/admin/users, selects the first rule, and requires a valid Access JWT. The origin still receives the original path.
The pattern is generic: a security decision made on one interpretation of a path, with the action taken on another. This is exactly what Orange Tsai's 2018 Black Hat and DEF CON research, "Breaking Parser Logic," catalogued. His slides showed that /foo;name=orange/bar/ is read as /foo;name=orange/bar/ by Apache, Nginx and IIS, but as /foo/bar/ by Tomcat and Jetty, and used that gap to bypass proxy-level ACLs.
It keeps happening, including this year:
- Heimdall (CVE-2026-42274): before version 0.17.14, the identity-aware proxy matched rules on the raw path while downstream components normalized dot segments, so a request like
/user/../admincould be authorized as one path and processed as another. - Quarkus (CVE-2026-50559): path-based authorization policies could be bypassed with percent-encoded semicolons (
%3B), slashes (%2F) or backslashes (%5C), because the security layer and the routing or static-file layer decoded them differently. - @opennextjs/cloudflare (CVE-2026-3125): a backslash in
/cdn-cgi\image/slipped past Cloudflare's edge interception, then a Worker's URL class converted it to a forward slash, reaching a vulnerable handler. Fixed in 1.17.1. - fast-uri (CVE-2026-6321): the library decoded percent-encoded separators and dot segments before removing dot segments, so distinct URIs could collapse onto one normalized path. Fixed in 3.1.1.
Different stacks, same root cause: two parsers, one path.
The four inputs to test
Dot segments (/../ and /./)
This is the classic traversal-style confusion, and the one the new cloudflared setting directly addresses. Test plain .. and the encoded forms %2e%2e and %2E%2E. RFC 3986 says percent-encoded unreserved characters are equivalent to literals, and the WHATWG URL parser treats %2e as a dot in path segments. Many origins decode %2e before removing dots, as the Quarkus advisory shows. Cloudflare documents dot-segment removal but doesn't say whether encoded dots are decoded first, so this row needs a real test.
Double slashes (//)
Frameworks differ on whether //admin equals /admin. Some merge, some treat the empty segment as significant, and some reject it. Cloudflare's cloudflared normalization merges them for matching only. A pattern like ^/admin won't match //admin without normalization, and an origin that does merge it will happily serve /admin.
Trailing slashes (/admin vs /admin/)
The documented normalization doesn't mention trailing slashes, so assume they are untouched. That makes regex anchoring decisive: ^/admin$ matches /admin but not /admin/, while many frameworks (Express by default, for instance) route both to the same handler. Write protected-path patterns as ^/admin(/|$), or better, protect the whole host and carve out public exceptions.
Encoded slashes (%2F) and friends
%2F is where parsers split most. Some origins decode it into a real separator before routing, some keep it inside a single segment, and some reject it outright (many servers expose a setting for this). Because RFC 3986 treats it as non-equivalent to /, cloudflared's documented normalization has no obvious reason to decode it, and a standards-following matcher would treat /admin%2Fusers as a single opaque segment. Your origin might not. The same goes for %5C (backslash) and %3B (semicolon). The Quarkus case shows how a bypass can hide in exactly that gap.
A test matrix you can run today
Assume the config above, with the origin returning 200 on /admin/* only when an Access JWT is present. For each request, the pass condition is that it is denied the same way plain /admin/users is.
| Request path | Matched as (off) | Matched as (on) | Documented? |
|---|---|---|---|
/admin/users |
/admin/users |
/admin/users |
Baseline |
/public/../admin/users |
raw | /admin/users |
Yes |
//admin/users |
raw | /admin/users |
Yes |
/admin//users |
raw | /admin/users |
Yes |
\admin\users |
raw | /admin/users |
Yes |
/admin/ |
raw | raw | Trailing slash not listed |
/%61dmin/users |
raw | Not documented | No, test it |
/admin%2Fusers |
raw | Not documented | No, test it |
/public/%2e%2e/admin/users |
raw | Not documented | No, test it |
/admin;x=1/users |
raw | Not documented | No, test it |
Two cautions when reading results. First, Cloudflare's own edge normalization is a separate layer. Some rows may look identical with cloudflared normalization on and off because the edge already rewrote them; that's fine, but you want to know which layer is doing the work. Second, cloudflared tunnel ingress rule <URL> shows which rule a URL matches, but Cloudflare's docs don't say whether it honors the normalization flag, so confirm with live traffic as well.
Here's a small Python harness that sends paths exactly as written, which curl and browsers won't always do:
import http.client, ssl
HOST = "app.example.com"
PATHS = [
"/admin/users", "/public/../admin/users", "//admin/users",
"/admin//users", "\\admin\\users", "/admin/", "/%61dmin/users",
"/admin%2Fusers", "/public/%2e%2e/admin/users", "/admin;x=1/users",
]
ctx = ssl.create_default_context()
for path in PATHS:
conn = http.client.HTTPSConnection(HOST, context=ctx, timeout=10)
conn.request("GET", path)
print(f"{path!r:34} -> {conn.getresponse().status}")
conn.close()
Run it twice: once with the tunnel on current defaults, once with --disable-path-normalization=false. Any path that returns something other than the baseline denial is a finding. For one-off checks, curl --path-as-is stops curl from collapsing dot segments before sending.
Rollout checklist before 2027-01-25
- Find out if you're in scope. Search your configs for
access:blocks withrequired: true. No such rules, and Cloudflare says you're unaffected. - Check where the
accessblock sits. An open issue in Cloudflare's docs repository reports that oncloudflared2026.7.1 a top-leveloriginRequest.accessblock wasn't enforced, while the same block under an individual ingress rule was. This is a user report, not confirmed documentation, so verify on your version rather than trusting either reading. - Run the matrix in staging with normalization off, then on. Record every difference.
- Audit regex anchors. Replace unanchored or
$-terminated patterns for protected paths with ones that handle trailing slashes. - Turn it on early, explicitly. Setting the flag now means the January change is a no-op for you. After the cutover, check the release notes to confirm the flag is still honored.
- Control auto-updates (
--no-autoupdate, version pinning) until the matrix passes on the new version. - Fix the origin too. Normalization at the tunnel is defense in depth. Make your application reject ambiguous paths (encoded slashes, backslashes, dot segments) rather than depending on the layer in front of it.
- Log raw request targets so a probing attempt shows up as
%2e%2eor\in your logs, not as a cleaned path.
FAQ
Does this change the path my application receives? No. Cloudflare states the URL path sent to your origin stays the same. Only the path used to select an ingress rule is normalized.
Will my tunnel break on 2027-01-25? Only if you have rules requiring Access validation and your matching relies on un-normalized paths. Cloudflare says configurations without such rules are unaffected, but it recommends testing path-based routing before the date.
Is normalizing at the tunnel enough to stop path-confusion bypasses?
It closes the gap for the transformations it performs. It does not make your origin's parsing consistent with the tunnel's, and Cloudflare doesn't document decoding of reserved characters like %2F. Treat it as one layer.
Does the same risk exist with other tunnels and reverse proxies? Yes. Any component that makes a routing or authorization decision on a path it hasn't normalized the way the next component will is exposed. Run the matrix against whatever sits at your edge.
Webhook signature verification behind tunnels is a separate failure mode, covered in the earlier post on signatures.
Sources
- Cloudflare Tunnel supports URL normalization (changelog, Oct 22, 2026)
- Cloudflare Tunnel configuration file: how traffic is matched
- Cloudflare Tunnel run parameters: disable-path-normalization
- Cloudflare Tunnel origin parameters: Access settings
- Cloudflare URL normalization (Rules docs)
- cloudflared releases on GitHub
- cloudflare-docs issue 32006: access block placement
- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax
- Orange Tsai, "Breaking Parser Logic" (Black Hat USA 2018 slides)
- CVE-2026-42274 (Heimdall)
- CVE-2026-50559 (Quarkus) advisory
- CVE-2026-3125 (@opennextjs/cloudflare)
- CVE-2026-6321 (fast-uri)
Originally published at https://instatunnel.my/blog/path-normalization-at-the-tunnel-edge-why-a-route-matches-on-localhost-but-not-through-a-tunnel
Comments
Post a Comment