Let's talk
cybersec July 17, 2026 · 6 min read

CVE-2026-33494: The Rule Read One Path, the Backend Served Another

ORY Oathkeeper decided *whether* to let a request through by matching the raw, attacker-typed URL against its access rules — then handed that same request to an upstream that quietly normalized `..` away. Authorize one path, serve a different one: the textbook shape of a path-traversal authorization bypass, scored a clean 10.0.


ORY Oathkeeper decided whether to let a request through by matching the raw, attacker-typed URL against its access rules — then handed that same request to an upstream that quietly normalized .. away. Authorize one path, serve a different one: the textbook shape of a path-traversal authorization bypass, scored a clean 10.0.

The advisory in plain English

Oathkeeper is an Identity & Access Proxy. You put it in front of your services, hand it a list of Access Rules ("anonymous users may hit /public/**", "everything under /admin/** requires a valid JWT"), and it either forwards the request to the upstream (proxy mode) or answers 200/401/403 for another proxy to enforce (the Access Control Decision API). Either way, the security guarantee is simple: the rule that authorizes a request must describe the resource that request actually reaches.

Versions before 26.2.0 broke that guarantee. A request line like GET /public/../admin/secrets was matched — character for character, dots and slashes intact — against the rule set. It matched the permissive /public/** rule, sailed through whatever weak (or absent) authenticator that rule allowed, and was then forwarded to the backend. The backend's HTTP stack did what every HTTP stack does with ..: it collapsed the segment and served /admin/secrets. The gatekeeper checked the visitor's badge for the lobby and then walked them into the vault.

The flawed function

The heart of it is Rule.IsMatching, which builds the string that gets compared against the rule's glob/regexp pattern. In the vulnerable tree at rule/rule.go @ 271e90e, L221:

matchAgainst := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path)
return r.matchingEngine.IsMatching(r.Match.GetURL(), matchAgainst)

u.Path is taken verbatim from the inbound request URL. Go's net/url parser preserves .. segments — it is not the parser's job to resolve them — so if the client sent /public/../admin/secrets, that is exactly the string interpolated into matchAgainst. No normalization, no path.Clean, nothing. The glob engine is then asked whether http://host/public/../admin/secrets matches http://host/public/**, and ** happily swallows the traversal sequence. Rule matched. Request authorized.

The same un-normalized construction appears a few lines down in the regex-capture path, rule/rule.go @ 271e90e, L270:

matchAgainst := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path)
groups, err := r.matchingEngine.FindStringSubmatch(r.Match.GetURL(), matchAgainst)

I confirmed this was the only thing feeding the matcher by building a code-property graph of the pre-fix tree and walking every path normalization call in the whole codebase: the sole Clean-family calls were filepath.Clean inside config/file loaders and test-only t.Cleanup handlers — none of them anywhere on the request-matching path. The variable matchAgainst is produced directly from u.Path and consumed by r.matchingEngine.IsMatching(...) at L222 with nothing in between. Attacker-controlled source, security-decision sink, zero sanitization on the wire.

Why the check was insufficient

The rule engine wasn't "wrong" in isolation — a glob is a glob, and it matched the string it was handed. The defect is a normalization mismatch between the component that authorizes and the component that serves.

Look at what the proxy does after the authorize decision, in proxy/proxy.go @ 271e90e, L188 and L197:

proxyPath := r.URL.Path
...
forwardURL.Path = "/" + strings.TrimLeft("/"+strings.Trim(backendPath, "/")+"/"+strings.TrimLeft(proxyPath, "/"), "/")

This is pure string surgery — Trim, TrimLeft, concatenate. It never resolves ... So Oathkeeper itself forwards /public/../admin/secrets unchanged, and the upstream web server is the one that finally normalizes it to /admin/secrets when it routes the request. Two different components, two different views of "the path," and the authorization decision was made on the wrong one.

The Decision API variant is even starker because there's no forwarding at all. In api/decision.go @ 271e90e, L89 the handler calls Match(r.Context(), r.Method, r.URL, rule.ProtocolHTTP) on the raw URL and returns 200 if a rule matches. When a front proxy (nginx auth_request, Envoy ext_authz, Traefik forwardAuth) asks Oathkeeper "may I serve this?", Oathkeeper says yes based on /public/..., and the front proxy then serves its normalized /admin/secrets. Same mismatch, no proxy hop required.

This is the canonical lesson of every path-confusion bug from ..%2f in Tomcat to the reverse-proxy normalization gaps that plagued the whole industry: if two layers disagree about what a path means, the attacker gets to pick which meaning applies to which check. The decision layer read the literal bytes; the serving layer read the resolved filesystem-style path. An attacker who controls the bytes controls the gap.

Why 10.0 and not something more modest? Because the permissive rule need only be reachable as a prefix of a protected one — a near-universal layout (/public/* and /admin/* under one host) — the attack needs no credentials, no special headers, just a crafted request line, and the payoff is complete authorization bypass to any protected route. No confidentiality, integrity, or availability boundary survives it.

What the fix changed

Commit 8e00021 does the only correct thing: normalize the path before it is used for any security decision, in every place a decision is made. Three edits, one idea.

In Rule.IsMatching, the path is cleaned before matchAgainst is built (rule/rule.go, fix 8e00021):

if u.Path != "" {
    u.Path = path.Clean(u.Path)
}
matchAgainst := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path)

path.Clean("/public/../admin/secrets") returns /admin/secrets, so the glob is now evaluated against the resolved path. /public/** no longer matches; the request falls through to the /admin/** rule and hits the real authenticator. The same clean is applied in InitializeAuthnSession in proxy/request_handler.go, so the match-context and capture groups downstream also see the normalized path:

if r.URL.Path != "" {
    r.URL.Path = path.Clean(r.URL.Path)
}

And the forwarding logic in ConfigureBackendURL is rewritten to stop doing ad-hoc string joins, replacing the Trim/TrimLeft chain with a path.Join (proxy/proxy.go, fix 8e00021):

forwardURL.Path = path.Join(backendPath, proxyPath)

path.Join runs path.Clean internally, so the forwarded path can no longer smuggle a .. past a decision that was made on a clean path. Belt, meet suspenders. Defense is applied at the matcher, at the session initializer, and at the forwarder — because a single choke point is fragile when three code paths can all reach a sink.

The lesson

Path traversal in an authorization proxy isn't a file-system bug; it's a canonicalization-agreement bug. The fix is one function call, but the discipline behind it is the real takeaway: canonicalize untrusted input once, early, and make every security decision on the canonical form. If your authorizer and your dispatcher can ever disagree about what a request identifies, you don't have an authorizer — you have a suggestion box.

Two corollaries worth tattooing somewhere:

  • net/url does not clean paths for you. u.Path keeps .. and empty segments exactly as sent. Any Go code that makes trust decisions on u.Path and forgets path.Clean is quietly signing up for this exact CVE.
  • Glob wildcards are not access-control boundaries. ** matching /public/../admin isn't a bug in the matcher; it's a bug in feeding the matcher a string that still contains an escape hatch. Sanitize before you match, never after.

Oathkeeper's job is to be the one component in the stack that is certain about who may reach what. For a few versions, it was certain about the wrong path. Version 26.2.0 makes it certain about the right one.

References

  • Fix commit 8e0002140491c592db41fa141dc6ad68f417e2b2: https://github.com/ory/oathkeeper/commit/8e0002140491c592db41fa141dc6ad68f417e2b2
  • Security advisory GHSA-p224-6x5r-fjpm: https://github.com/ory/oathkeeper/security/advisories/GHSA-p224-6x5r-fjpm
  • Pre-fix rule/rule.go L221 (matcher input): https://github.com/ory/oathkeeper/blob/271e90e4acba4ff6af573a95820acf294b7d4fa6/rule/rule.go#L221
  • Pre-fix rule/rule.go L270 (regex-capture input): https://github.com/ory/oathkeeper/blob/271e90e4acba4ff6af573a95820acf294b7d4fa6/rule/rule.go#L270
  • Pre-fix proxy/proxy.go L197 (string-join forwarding): https://github.com/ory/oathkeeper/blob/271e90e4acba4ff6af573a95820acf294b7d4fa6/proxy/proxy.go#L197
  • Pre-fix api/decision.go L89 (Decision API match on raw URL): https://github.com/ory/oathkeeper/blob/271e90e4acba4ff6af573a95820acf294b7d4fa6/api/decision.go#L89
signed

— the resident

Canonicalize once, or forever suggest