GHSA-8gmh-2hhj-2hv9

Suggest an improvement
Source
https://github.com/advisories/GHSA-8gmh-2hhj-2hv9
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-8gmh-2hhj-2hv9/GHSA-8gmh-2hhj-2hv9.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-8gmh-2hhj-2hv9
Aliases
  • CVE-2026-126354
Published
2026-11-12T22:27:58Z
Modified
2026-11-12T22:45:04Z
Severity
  • 4.8 (Medium) CVSS_V3 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N CVSS Calculator
Summary
Addressable URI normalization resolves some dotted path segments to the wrong path
Details

Summary

Addressable::URI.normalize_path resolves some paths containing .. segments to a different path than RFC 3986 does. When a path climbs above the root before any other .. is resolved, the segments that later .. segments should remove are instead kept: /../../public/x/../../secret normalizes to /public/x/secret instead of /secret. URI#normalize, URI#join, URI#== and URI#=== all use this normalization. An application that checks a normalized Addressable path against an allowlist, but sends the original URI to something that resolves it according to RFC 3986, can be made to allow a request for a path outside the allowlist.

Details

normalize_path in 2.9.0 (lib/addressable/uri.rb) removes dot segments by repeating a set of rewrites until nothing changes. On each pass it finds the first /segment/../ pair with RULE_2B_2C, then replaces every textual copy of that pair in the path with /:

pair = normalized_path.match(RULE_2B_2C)
...
regexp = "/#{Regexp.escape(parent.to_s)}/\\.\\./|"
regexp += "(/#{Regexp.escape(current.to_s)}/\\.\\.$)"
...
mod ||= normalized_path.gsub!(Regexp.new(regexp), SLASH)

When the first pair found is /../../, where the "segment" is itself .., every /../../ in the path is replaced with /. That includes copies later in the path, where each .. should have removed the segment before it. For /../../public/x/../../secret:

  • RFC 3986 §5.2.4 drops the two leading .. segments, which can't go above the root, then removes x and public, giving /secret.
  • 2.9.0 replaces both copies of /../../ with / in one step, giving /public/x/secret.

Paths where an ordinary /segment/../ pair comes first, such as /a/../../b/c/../../d, are resolved correctly.

This was introduced in 2.2.5. Versions 2.0.0 through 2.2.4 and earlier versions resolve these paths correctly. It is fixed in 2.9.1, which replaces the rewrite loop with a single pass that correctly follows RFC 3986 §5.2.4.

Proof of concept

require "addressable/uri"
require "uri"

url = "http://example.com/../../public/x/../../secret"

# Addressable's normalized path, compared with RFC 3986 resolution by Ruby's URI library
puts Addressable::URI.parse(url).normalize.path
puts URI.join("http://example.com/", "../../public/x/../../secret").path

# join and == use the same normalization
puts Addressable::URI.join("http://example.com/", "../../public/x/../../secret").path
puts Addressable::URI.parse(url) == Addressable::URI.parse("http://example.com/public/x/secret")

# An allowlist check on the normalized path passes, but the request reaches /secret
def allowed?(url) = Addressable::URI.parse(url).normalize.path.start_with?("/public/")
puts allowed?(url)

Output with Addressable 2.9.0:

/public/x/secret
/secret
/public/x/secret
true
true

Output with Addressable 2.9.1:

/secret
/secret
/secret
false
false

Impact

This is an interpretation conflict (CWE-436) between Addressable and other RFC 3986 resolvers, which could lead to path traversal past an allowlist (CWE-22). An application is affected when it does both of these:

  • makes an access decision on a path normalized or joined by Addressable, such as "only paths under /public/ may be proxied" or "only these redirect targets are allowed";
  • sends the original, unnormalized URI to a component that resolves dot segments itself, such as an HTTP client, a proxy, a web server or a browser.

An attacker may be able to bypass a check by exploiting behavioral differences between URI parsers. Impact from this is application-dependent. Applications that use Addressable's normalized form for both the check and the request are unaffected, as are applications that reject paths containing .. segments.

As a work-around for unpatched versions, before making access decisions on a path, either reject any containing .. segments, including percent-encoded forms such as %2e%2e, or use the same normalized value for both the check and the request.

Database specific
{
    "cwe_ids": [
        "CWE-22",
        "CWE-436"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-11-12T22:27:58Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
}
References

Affected packages

RubyGems / addressable

Package

Name
addressable
Purl
pkg:gem/addressable

Affected ranges

Type
ECOSYSTEM
Events
Introduced
2.2.5
Fixed
2.9.1

Affected versions

2.*
2.2.5
2.2.6
2.2.7
2.2.8
2.3.2
2.3.3
2.3.4
2.3.5
2.3.6
2.3.7
2.3.8
2.4.0
2.5.0
2.5.1
2.5.2
2.6.0
2.7.0
2.8.0
2.8.1
2.8.2
2.8.3
2.8.4
2.8.5
2.8.6
2.8.7
2.8.8
2.8.9
2.8.10
2.9.0

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-8gmh-2hhj-2hv9/GHSA-8gmh-2hhj-2hv9.json"