CVE-2026-33494
Description
ORY Oathkeeper is an Identity & Access Proxy (IAP) and Access Control Decision API that authorizes HTTP requests based on sets of Access Rules. Versions prior to 26.2.0 are vulnerable to an authorization bypass via HTTP path traversal. An attacker can craft a URL containing path traversal sequences (e.g. /public/../admin/secrets) that resolves to a protected path after normalization, but is matched against a permissive rule because the raw, un-normalized path is used during rule evaluation. Version 26.2.0 contains a patch.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/ory/oathkeeperGo | < 0.40.10-0.20260320084758-8e0002140491 | 0.40.10-0.20260320084758-8e0002140491 |
Affected products
1Patches
18e0002140491fix: clean path while matching to prevent path traversal
8 files changed · +86 −63
proxy/proxy.go+2 −1 modified@@ -9,6 +9,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "path" "strings" "github.com/ory/oathkeeper/driver/configuration" @@ -194,7 +195,7 @@ func ConfigureBackendURL(r *http.Request, rl *rule.Rule) error { forwardURL := r.URL forwardURL.Scheme = backendScheme forwardURL.Host = backendHost - forwardURL.Path = "/" + strings.TrimLeft("/"+strings.Trim(backendPath, "/")+"/"+strings.TrimLeft(proxyPath, "/"), "/") + forwardURL.Path = path.Join(backendPath, proxyPath) if rl.Upstream.StripPath != "" { forwardURL.Path = strings.Replace(forwardURL.Path, "/"+strings.Trim(rl.Upstream.StripPath, "/"), "", 1)
proxy/proxy_test.go+4 −4 modified@@ -496,10 +496,10 @@ func TestConfigureBackendURL(t *testing.T) { eHost: "localhost:3000", }, { - r: &http.Request{Host: "localhost:3000", URL: &url.URL{Path: "/api/users/1234", Scheme: "http"}}, - rl: &rule.Rule{Upstream: rule.Upstream{URL: "http://localhost:4000/foo/", PreserveHost: true, StripPath: "api"}}, - eURL: "http://localhost:4000/foo/users/1234", - eHost: "localhost:3000", + r: &http.Request{Host: "localhost:3000", URL: &url.URL{Path: "/api/../users/1234", Scheme: "http"}}, + rl: &rule.Rule{Upstream: rule.Upstream{URL: "http://localhost:4000"}}, + eURL: "http://localhost:4000/users/1234", + eHost: "localhost:4000", }, } { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
proxy/request_handler.go+5 −1 modified@@ -6,6 +6,7 @@ package proxy import ( "encoding/json" "net/http" + "path" "github.com/ory/herodot" "github.com/ory/x/errorsx" @@ -327,11 +328,14 @@ func (d *requestHandler) HandleRequest(r *http.Request, rl *rule.Rule) (session // InitializeAuthnSession creates an authentication session and initializes it with a Match context if possible func (d *requestHandler) InitializeAuthnSession(r *http.Request, rl *rule.Rule) *authn.AuthenticationSession { - session := &authn.AuthenticationSession{ Subject: "", } + if r.URL.Path != "" { + r.URL.Path = path.Clean(r.URL.Path) + } + values, err := rl.ExtractRegexGroups(d.c.AccessRuleMatchingStrategy(), r.URL) if err != nil { d.r.Logger().WithError(err).
proxy/request_handler_test.go+48 −40 modified@@ -444,18 +444,18 @@ func TestRequestHandler(t *testing.T) { } func TestInitializeSession(t *testing.T) { - for k, tc := range []struct { - d string - ruleMatch rule.Match - matchingStrategy configuration.MatchingStrategy - r *http.Request - expectContext authn.MatchContext + for _, tc := range []struct { + desc string + match rule.Match + strategy configuration.MatchingStrategy + url string + expectContext authn.MatchContext }{ { - d: "Rule without capture", - r: newTestRequest("http://localhost"), - matchingStrategy: configuration.Regexp, - ruleMatch: rule.Match{ + desc: "Rule without capture", + url: "http://localhost", + strategy: configuration.Regexp, + match: rule.Match{ URL: "http://localhost", }, expectContext: authn.MatchContext{ @@ -466,10 +466,10 @@ func TestInitializeSession(t *testing.T) { }, }, { - d: "Rule with one capture", - r: newTestRequest("http://localhost/user"), - matchingStrategy: configuration.Regexp, - ruleMatch: rule.Match{ + desc: "Rule with one capture", + url: "http://localhost/user", + strategy: configuration.Regexp, + match: rule.Match{ URL: "http://localhost/<.*>", }, expectContext: authn.MatchContext{ @@ -480,10 +480,10 @@ func TestInitializeSession(t *testing.T) { }, }, { - d: "Request with query params", - r: newTestRequest("http://localhost/user?param=test"), - matchingStrategy: configuration.Regexp, - ruleMatch: rule.Match{ + desc: "Request with query params", + url: "http://localhost/user?param=test", + strategy: configuration.Regexp, + match: rule.Match{ URL: "http://localhost/<.*>", }, expectContext: authn.MatchContext{ @@ -494,10 +494,10 @@ func TestInitializeSession(t *testing.T) { }, }, { - d: "Rule with 2 captures", - r: newTestRequest("http://localhost/user?param=test"), - matchingStrategy: configuration.Regexp, - ruleMatch: rule.Match{ + desc: "Rule with 2 captures", + url: "http://localhost/user?param=test", + strategy: configuration.Regexp, + match: rule.Match{ URL: "<http|https>://localhost/<.*>", }, expectContext: authn.MatchContext{ @@ -508,10 +508,24 @@ func TestInitializeSession(t *testing.T) { }, }, { - d: "Rule with Glob matching strategy", - r: newTestRequest("http://localhost/user?param=test"), - matchingStrategy: configuration.Glob, - ruleMatch: rule.Match{ + desc: "Rule with 1 capture and path traversal", + url: "http://localhost/user/../admin/secrets?param=test", + strategy: configuration.Regexp, + match: rule.Match{ + URL: "http://localhost/admin/<.*>", + }, + expectContext: authn.MatchContext{ + RegexpCaptureGroups: []string{"secrets"}, + URL: x.ParseURLOrPanic("http://localhost/admin/secrets?param=test"), + Method: "GET", + Header: TestHeader, + }, + }, + { + desc: "Rule with Glob matching strategy", + url: "http://localhost/user?param=test", + strategy: configuration.Glob, + match: rule.Match{ URL: "<http|https>://localhost/<*>", }, expectContext: authn.MatchContext{ @@ -522,23 +536,17 @@ func TestInitializeSession(t *testing.T) { }, }, } { - t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { - - conf := internal.NewConfigurationWithDefaults() + t.Run(fmt.Sprintf("description=%s", tc.desc), func(t *testing.T) { + conf := internal.NewConfigurationWithDefaults(configx.WithValue(configuration.AccessRuleMatchingStrategy, tc.strategy)) reg := internal.NewRegistry(conf) - conf.SetForTest(t, configuration.AccessRuleMatchingStrategy, string(tc.matchingStrategy)) - - rule := rule.Rule{ - Match: &tc.ruleMatch, - Authenticators: []rule.Handler{}, - Authorizer: rule.Handler{}, - Mutators: []rule.Handler{}, - } - session := reg.ProxyRequestHandler().InitializeAuthnSession(tc.r, &rule) + session := reg.ProxyRequestHandler().InitializeAuthnSession( + newTestRequest(tc.url), + &rule.Rule{Match: &tc.match}, + ) - assert.NotNil(t, session) - assert.NotNil(t, session.MatchContext.Header) + require.NotNil(t, session) + require.NotNil(t, session.MatchContext.Header) assert.EqualValues(t, tc.expectContext, session.MatchContext) }) }
rule/engine_glob.go+1 −1 modified@@ -35,7 +35,7 @@ func (ge *globMatchingEngine) ReplaceAllString(_, _, _ string) (string, error) { } // FindStringSubmatch is noop for now and always returns an empty array -func (ge *globMatchingEngine) FindStringSubmatch(pattern, matchAgainst string) ([]string, error) { +func (ge *globMatchingEngine) FindStringSubmatch(_, _ string) ([]string, error) { return []string{}, nil }
rule/repository_memory.go+1 −1 modified@@ -110,7 +110,7 @@ func (m *RepositoryMemory) Set(ctx context.Context, rules []Rule) error { return nil } -func (m *RepositoryMemory) Match(ctx context.Context, method string, u *url.URL, protocol Protocol) (*Rule, error) { +func (m *RepositoryMemory) Match(_ context.Context, method string, u *url.URL, protocol Protocol) (*Rule, error) { if u == nil { return nil, errors.WithStack(errors.New("nil URL provided")) }
rule/rule.go+4 −0 modified@@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/url" + "path" "strings" "github.com/pkg/errors" @@ -218,6 +219,9 @@ func (r *Rule) IsMatching(strategy configuration.MatchingStrategy, method string return false, nil } + if u.Path != "" { + u.Path = path.Clean(u.Path) + } matchAgainst := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) return r.matchingEngine.IsMatching(r.Match.GetURL(), matchAgainst) }
rule/rule_test.go+21 −15 modified@@ -47,33 +47,29 @@ func TestRule(t *testing.T) { method string url string expectedMatch bool - expectedErr error }{ { method: "DELETE", url: "https://localhost/users/1234", expectedMatch: true, - expectedErr: nil, }, { method: "DELETE", url: "https://localhost/users/1234?key=value&key1=value1", expectedMatch: true, - expectedErr: nil, }, { method: "DELETE", url: "https://localhost/users/abcd", expectedMatch: false, - expectedErr: nil, }, } for ind, tcase := range tests { t.Run(strconv.FormatInt(int64(ind), 10), func(t *testing.T) { testFunc := func(rule Rule, strategy configuration.MatchingStrategy) { matched, err := rule.IsMatching(strategy, tcase.method, mustParse(t, tcase.url), ProtocolHTTP) + require.NoError(t, err) assert.Equal(t, tcase.expectedMatch, matched) - assert.Equal(t, tcase.expectedErr, err) } t.Run("rule0", func(t *testing.T) { testFunc(rules[0], configuration.Regexp) @@ -100,32 +96,28 @@ func TestRule1(t *testing.T) { method string url string expectedMatch bool - expectedErr error }{ { method: "DELETE", url: "https://localhost/users/manager", expectedMatch: true, - expectedErr: nil, }, { method: "DELETE", url: "https://localhost/users/1234?key=value&key1=value1", expectedMatch: true, - expectedErr: nil, }, { method: "DELETE", url: "https://localhost/users/admin", expectedMatch: false, - expectedErr: nil, }, } for ind, tcase := range tests { t.Run(strconv.FormatInt(int64(ind), 10), func(t *testing.T) { matched, err := r.IsMatching(configuration.Regexp, tcase.method, mustParse(t, tcase.url), ProtocolHTTP) + require.NoError(t, err) assert.Equal(t, tcase.expectedMatch, matched) - assert.Equal(t, tcase.expectedErr, err) }) } } @@ -142,36 +134,50 @@ func TestRuleWithCustomMethod(t *testing.T) { method string url string expectedMatch bool - expectedErr error }{ { method: "CUSTOM", url: "https://localhost/users/manager", expectedMatch: true, - expectedErr: nil, }, { method: "CUSTOM", url: "https://localhost/users/1234?key=value&key1=value1", expectedMatch: true, - expectedErr: nil, }, { method: "DELETE", url: "https://localhost/users/admin", expectedMatch: false, - expectedErr: nil, }, } for ind, tcase := range tests { t.Run(strconv.FormatInt(int64(ind), 10), func(t *testing.T) { matched, err := r.IsMatching(configuration.Regexp, tcase.method, mustParse(t, tcase.url), ProtocolHTTP) + require.NoError(t, err) assert.Equal(t, tcase.expectedMatch, matched) - assert.Equal(t, tcase.expectedErr, err) }) } } +func TestRulePathTraversal(t *testing.T) { + r := &Rule{ + Match: &Match{ + Methods: []string{"POST"}, + URL: "https://localhost/admin/<.*>", + }, + } + + u := &url.URL{ + Scheme: "https", + Host: "localhost", + Path: "/public/../admin/secrets", + } + matched, err := r.IsMatching(configuration.Regexp, "POST", u, ProtocolHTTP) + require.NoError(t, err) + assert.True(t, matched) +} + func TestRule_UnmarshalJSON(t *testing.T) { var tests = []struct { name string
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/ory/oathkeeper/commit/8e0002140491c592db41fa141dc6ad68f417e2b2nvdPatchWEB
- github.com/advisories/GHSA-p224-6x5r-fjpmghsaADVISORY
- github.com/ory/oathkeeper/security/advisories/GHSA-p224-6x5r-fjpmnvdMitigationVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-33494ghsaADVISORY
News mentions
0No linked articles in our index yet.