{"id":"CVE-2026-126354","kind":"vulnerability","aliases":["CVE-2026-126354","GHSA-8gmh-2hhj-2hv9"],"related":[],"title":"Addressable URI normalization resolves some dotted path segments to the wrong path","description":"### Summary\n\n`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.\n\n### Details\n\n`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 `/`:\n\n```ruby\npair = normalized_path.match(RULE_2B_2C)\n...\nregexp = \"/#{Regexp.escape(parent.to_s)}/\\\\.\\\\./|\"\nregexp += \"(/#{Regexp.escape(current.to_s)}/\\\\.\\\\.$)\"\n...\nmod ||= normalized_path.gsub!(Regexp.new(regexp), SLASH)\n```\n\nWhen 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`:\n\n- RFC 3986 §5.2.4 drops the two leading `..` segments, which can't go above the root, then removes `x` and `public`, giving `/secret`.\n- 2.9.0 replaces both copies of `/../../` with `/` in one step, giving `/public/x/secret`.\n\nPaths where an ordinary `/segment/../` pair comes first, such as `/a/../../b/c/../../d`, are resolved correctly.\n\nThis 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.\n\n### Proof of concept\n\n```ruby\nrequire \"addressable/uri\"\nrequire \"uri\"\n\nurl = \"http://example.com/../../public/x/../../secret\"\n\n# Addressable's normalized path, compared with RFC 3986 resolution by Ruby's URI library\nputs Addressable::URI.parse(url).normalize.path\nputs URI.join(\"http://example.com/\", \"../../public/x/../../secret\").path\n\n# join and == use the same normalization\nputs Addressable::URI.join(\"http://example.com/\", \"../../public/x/../../secret\").path\nputs Addressable::URI.parse(url) == Addressable::URI.parse(\"http://example.com/public/x/secret\")\n\n# An allowlist check on the normalized path passes, but the request reaches /secret\ndef allowed?(url) = Addressable::URI.parse(url).normalize.path.start_with?(\"/public/\")\nputs allowed?(url)\n```\n\nOutput with Addressable 2.9.0:\n\n```\n/public/x/secret\n/secret\n/public/x/secret\ntrue\ntrue\n```\n\nOutput with Addressable 2.9.1:\n\n```\n/secret\n/secret\n/secret\nfalse\nfalse\n```\n\n\n### Impact\n\nThis 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:\n\n- 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\";\n- 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.\n\nAn 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.\n\nAs 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.","summary":"Addressable's URI normalization resolves some paths with repeated `..` segments differently from RFC 3986, so `/../../public/x/../../secret` normalizes to `/public/x/secret` instead of `/secret`. An application that checks the Addressable-normalized path against an allowlist but sends the original URI to an RFC 3986 resolver can let a request reach a path outside the allowlist.","severity":"medium","cvss":4.8,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"addressable","fixed":["2.9.1"],"affected":">= 2.2.5, < 2.9.1","ecosystem":"rubygems"}],"ecosystems":["rubygems"],"tags":["addressable","cwe-22","cwe-436","package","ruby","self-hosted","web-app"],"not_affected_if":["no-path-allowlist","normalized-value-used-for-request","rejects-dot-segments"],"how_to_check":"- Check the installed Addressable version with `bundle exec gem list addressable` or by inspecting `Gemfile.lock` for `addressable`.\n- Look for code that calls `Addressable::URI` normalize, join, `==` or `===` on user-supplied URLs or paths and then uses the result for an access decision such as an allowlist or redirect check.\n- Confirm whether the original, unnormalized URI is later passed to an HTTP client, proxy, web server or browser.","fix":null,"status":"open","published_at":"2026-11-12T22:19:20+00:00","first_seen_at":"2026-11-12T22:31:52+00:00","triaged_at":"2026-11-12T22:32:15+00:00","updated_at":"2026-11-12T22:40:38+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-11-12T22:32:15Z","model":"claude-haiku-5-5","output":{"fix":"- `addressable` gem: move to `2.9.1`, which the sources state fixes this issue (affected range is `>= 2.2.5, < 2.9.1`).\n- Until patched, the sources suggest rejecting paths containing `..` segments (including `%2e%2e`) before access checks, or using the same normalized value for both the check and the request.","reach":"internet","title":"Addressable: dot-segment normalization can bypass path allowlists in Ruby","impact":"medium","summary":"Addressable's URI normalization resolves some paths with repeated `..` segments differently from RFC 3986, so `/../../public/x/../../secret` normalizes to `/public/x/secret` instead of `/secret`. An application that checks the Addressable-normalized path against an allowlist but sends the original URI to an RFC 3986 resolver can let a request reach a path outside the allowlist.","audience":"developers","category":"package","nicknames":[],"who_cares":["ruby","addressable","self-hosted","web-app"],"confidence":0.85,"fix_status":"available","needs_auth":"unknown","worst_case":"request reaches paths outside the allowlist, such as internal files","analyst_note":"","how_to_check":"- Check the installed Addressable version with `bundle exec gem list addressable` or by inspecting `Gemfile.lock` for `addressable`.\n- Look for code that calls `Addressable::URI` normalize, join, `==` or `===` on user-supplied URLs or paths and then uses the result for an access decision such as an allowlist or redirect check.\n- Confirm whether the original, unnormalized URI is later passed to an HTTP client, proxy, web server or browser.","plain_action":"","product_name":"","needs_analyst":false,"not_affected_if":["normalized-value-used-for-request","rejects-dot-segments","no-path-allowlist"],"who_is_affected":"Applications that use the Ruby Addressable gem to normalize or join URLs, then make an access decision on the normalized path, and also send the original unnormalized URI to another component that resolves dot segments per RFC 3986. Applications that use Addressable's normalized value for both the check and the request, or that reject `..` segments, are not affected.","user_interaction":"no","product_ecosystem":"","needs_special_setup":"yes","severity_if_unknown":"unknown"},"cost_usd":0.000999,"input_tokens":6079,"output_tokens":782,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-126354':15A,23A '-2026':14A,22A '-22':27B,496C '-436':29B,478C '/../..':255C,262C,314C '/../../public/x/../../secret':53B,131C,286C,382C '/../public/x/../../secret':405C,417C '/a/../../b/c/../../d':331C '/public':449C,524C '/public/x/secret':56B,134C,320C,427C,457C,459C '/secret':59B,137C,308C,440C,458C,466C,467C,468C '/segment/..':212C,325C '2.0.0':341C '2.2.4':343C '2.2.5':339C '2.9.0':191C,309C,456C '2.9.1':355C,465C '2b':216C,234C '2c':217C,235C '2e':635C,636C '2hhj':19A '2hv9':20A '3986':51B,80B,107C,173C,288C,369C,390C,484C '5.2.4':289C,370C '8gmh':18A 'abl':565C 'access':509C,618C 'accord':170C 'address':1A,25B,38B,66B,93C,155C,383C,398C,414C,420C,423C,444C,455C,464C,480C,518C,587C 'addressable':24A 'addressable-norm':65B 'addressable/uri':376C 'affect':500C 'allow':178C,442C,451C,534C 'allowlist':71B,91B,159C,186C,429C,494C 'app':37B 'applic':61B,150C,498C,582C,584C,602C 'application-depend':581C 'around':612C 'attack':562C 'behavior':572C 'bo':644C 'browser':560C 'bypass':567C 'chang':204C 'check':63B,152C,430C,569C,594C 'client':552C 'climb':112C 'come':327C 'compar':387C 'compon':542C 'concept':373C 'conflict':476C 'contain':99C,606C,626C 'copi':222C,271C,312C 'correct':334C,350C,366C 'could':487C 'current.to':242C 'cve':13A,21A 'cwe':26B,28B,477C,495C 'decis':510C,619C 'def':441C 'depend':583C 'detail':187C 'differ':48B,103C,573C 'dot':6A,194C,545C 'drop':290C 'earlier':345C 'either':623C 'encod':631C 'everi':220C,261C 'example.com':381C,404C,416C,426C 'example.com/../../public/x/../../secret':380C 'example.com/public/x/secret':425C 'exploit':571C 'fals':469C,470C 'find':209C 'first':211C,251C,328C 'fix':353C 'follow':367C 'form':590C,632C 'found':253C 'ghsa':17A 'ghsa-8gmh-2hhj-2hv9':16A 'give':307C,319C 'go':298C 'host':34B 'http':551C 'impact':471C,577C 'includ':270C,628C 'instead':57B,129C,135C 'interpret':475C 'introduc':337C 'join':141C,407C,516C 'kept':130C 'later':124C,272C 'lead':293C,488C 'let':83B 'lib/addressable/uri.rb':192C 'librari':396C 'loop':360C 'made':176C 'make':507C,617C 'may':525C,563C 'mod':244C 'normal':3A,41B,54B,67B,132C,139C,148C,154C,188C,385C,412C,433C,514C,589C,641C 'normalize.path':401C 'normalize.path.start':447C 'normalized_path.gsub':245C 'normalized_path.match':232C 'noth':203C 'one':317C 'ordinari':324C 'origin':75B,163C,537C 'output':453C,462C 'outsid':89B,184C 'packag':30B 'pair':213C,225C,231C,252C,326C 'parent.to':238C 'parser':576C 'pass':207C,364C,435C 'past':492C 'path':7A,12A,44B,68B,88B,95C,98C,104C,111C,156C,183C,189C,228C,265C,275C,321C,349C,386C,406C,418C,434C,490C,513C,522C,605C,622C 'percent':630C 'percent-encod':629C 'proof':371C 'proxi':527C,554C 'public':306C 'put':397C,402C,413C,419C,450C 'reach':86B,439C 'redirect':531C 'regexp':236C,240C,247C 'regexp.escape':237C,241C 'regexp.new':246C 'reject':604C,624C 'remov':127C,193C,280C,303C 'repeat':46B,197C 'replac':219C,267C,310C,357C 'request':85B,180C,438C,597C 'requir':375C,377C 'resolut':391C 'resolv':4A,42B,81B,96C,120C,168C,333C,347C,485C,544C 'rewrit':201C,359C 'rfc':50B,79B,106C,172C,287C,368C,389C,483C 'root':115C,301C 'rubi':31B,230C,374C,393C 'rule':215C,233C 'segment':8A,47B,100C,122C,125C,195C,258C,282C,294C,546C,607C,627C 'self':33B 'self-host':32B 'send':73B,161C,535C 'server':557C 'set':199C 'singl':363C 'slash':248C 'someth':166C 'step':318C 'summari':92C 'target':532C 'textual':221C 'travers':491C 'true':460C,461C 'two':292C 'unaffect':599C 'unnorm':538C 'unpatch':614C 'uri':2A,40B,76B,138C,140C,142C,144C,164C,378C,395C,539C,575C 'uri.join':403C,415C 'uri.normalize':94C 'uri.parse':399C,421C,424C,445C 'url':379C,400C,422C,443C,446C,452C 'use':146C,409C,586C,638C 'valu':642C 'version':340C,346C,615C 'web':36B,556C 'web-app':35B 'work':611C 'work-around':610C 'wrong':11A 'x':304C","nicknames":[],"mitigations":[],"who_affected":"Applications that use the Ruby Addressable gem to normalize or join URLs, then make an access decision on the normalized path, and also send the original unnormalized URI to another component that resolves dot segments per RFC 3986. Applications that use Addressable's normalized value for both the check and the request, or that reject `..` segments, are not affected.","tier":"C","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"developers","plain_action":null,"pkeys":["addressable"],"ptokens":["addressable"],"labels":["addressable","cwe22","cwe436","medium","package","ruby","rubygems","selfhosted","webapp"],"campaign_type":null,"search_a":"'-126354':15A,23A '-2026':14A,22A '2hhj':19A '2hv9':20A '8gmh':18A 'address':1A 'addressable':24A 'cve':13A,21A 'dot':6A 'ghsa':17A 'ghsa-8gmh-2hhj-2hv9':16A 'normal':3A 'path':7A,12A 'resolv':4A 'segment':8A 'uri':2A 'wrong':11A","search_b":"'-22':27B '-436':29B '/../../public/x/../../secret':53B '/public/x/secret':56B '/secret':59B '3986':51B,80B 'address':25B,38B,66B 'addressable-norm':65B 'allowlist':71B,91B 'app':37B 'applic':61B 'check':63B 'cwe':26B,28B 'differ':48B 'host':34B 'instead':57B 'let':83B 'normal':41B,54B,67B 'origin':75B 'outsid':89B 'packag':30B 'path':44B,68B,88B 'reach':86B 'repeat':46B 'request':85B 'resolv':42B,81B 'rfc':50B,79B 'rubi':31B 'segment':47B 'self':33B 'self-host':32B 'send':73B 'uri':40B,76B 'web':36B 'web-app':35B","search_c":"'-22':496C '-436':478C '/../..':255C,262C,314C '/../../public/x/../../secret':131C,286C,382C '/../public/x/../../secret':405C,417C '/a/../../b/c/../../d':331C '/public':449C,524C '/public/x/secret':134C,320C,427C,457C,459C '/secret':137C,308C,440C,458C,466C,467C,468C '/segment/..':212C,325C '2.0.0':341C '2.2.4':343C '2.2.5':339C '2.9.0':191C,309C,456C '2.9.1':355C,465C '2b':216C,234C '2c':217C,235C '2e':635C,636C '3986':107C,173C,288C,369C,390C,484C '5.2.4':289C,370C 'abl':565C 'access':509C,618C 'accord':170C 'address':93C,155C,383C,398C,414C,420C,423C,444C,455C,464C,480C,518C,587C 'addressable/uri':376C 'affect':500C 'allow':178C,442C,451C,534C 'allowlist':159C,186C,429C,494C 'applic':150C,498C,582C,584C,602C 'application-depend':581C 'around':612C 'attack':562C 'behavior':572C 'bo':644C 'browser':560C 'bypass':567C 'chang':204C 'check':152C,430C,569C,594C 'client':552C 'climb':112C 'come':327C 'compar':387C 'compon':542C 'concept':373C 'conflict':476C 'contain':99C,606C,626C 'copi':222C,271C,312C 'correct':334C,350C,366C 'could':487C 'current.to':242C 'cwe':477C,495C 'decis':510C,619C 'def':441C 'depend':583C 'detail':187C 'differ':103C,573C 'dot':194C,545C 'drop':290C 'earlier':345C 'either':623C 'encod':631C 'everi':220C,261C 'example.com':381C,404C,416C,426C 'example.com/../../public/x/../../secret':380C 'example.com/public/x/secret':425C 'exploit':571C 'fals':469C,470C 'find':209C 'first':211C,251C,328C 'fix':353C 'follow':367C 'form':590C,632C 'found':253C 'give':307C,319C 'go':298C 'http':551C 'impact':471C,577C 'includ':270C,628C 'instead':129C,135C 'interpret':475C 'introduc':337C 'join':141C,407C,516C 'kept':130C 'later':124C,272C 'lead':293C,488C 'lib/addressable/uri.rb':192C 'librari':396C 'loop':360C 'made':176C 'make':507C,617C 'may':525C,563C 'mod':244C 'normal':132C,139C,148C,154C,188C,385C,412C,433C,514C,589C,641C 'normalize.path':401C 'normalize.path.start':447C 'normalized_path.gsub':245C 'normalized_path.match':232C 'noth':203C 'one':317C 'ordinari':324C 'origin':163C,537C 'output':453C,462C 'outsid':184C 'pair':213C,225C,231C,252C,326C 'parent.to':238C 'parser':576C 'pass':207C,364C,435C 'past':492C 'path':95C,98C,104C,111C,156C,183C,189C,228C,265C,275C,321C,349C,386C,406C,418C,434C,490C,513C,522C,605C,622C 'percent':630C 'percent-encod':629C 'proof':371C 'proxi':527C,554C 'public':306C 'put':397C,402C,413C,419C,450C 'reach':439C 'redirect':531C 'regexp':236C,240C,247C 'regexp.escape':237C,241C 'regexp.new':246C 'reject':604C,624C 'remov':127C,193C,280C,303C 'repeat':197C 'replac':219C,267C,310C,357C 'request':180C,438C,597C 'requir':375C,377C 'resolut':391C 'resolv':96C,120C,168C,333C,347C,485C,544C 'rewrit':201C,359C 'rfc':106C,172C,287C,368C,389C,483C 'root':115C,301C 'rubi':230C,374C,393C 'rule':215C,233C 'segment':100C,122C,125C,195C,258C,282C,294C,546C,607C,627C 'send':161C,535C 'server':557C 'set':199C 'singl':363C 'slash':248C 'someth':166C 'step':318C 'summari':92C 'target':532C 'textual':221C 'travers':491C 'true':460C,461C 'two':292C 'unaffect':599C 'unnorm':538C 'unpatch':614C 'uri':138C,140C,142C,144C,164C,378C,395C,539C,575C 'uri.join':403C,415C 'uri.normalize':94C 'uri.parse':399C,421C,424C,445C 'url':379C,400C,422C,443C,446C,452C 'use':146C,409C,586C,638C 'valu':642C 'version':340C,346C,615C 'web':556C 'work':611C 'work-around':610C 'x':304C","cause_entry":null,"cause_path":null,"disclosures_checked_at":null,"first_public_at":"2026-10-30T08:13:09+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-8gmh-2hhj-2hv9","url":"https://github.com/advisories/GHSA-8gmh-2hhj-2hv9","severity":"medium","published_at":"2026-11-12T22:27:58+00:00","modified_at":"2026-11-12T22:27:59+00:00","first_seen_at":"2026-11-12T22:31:52+00:00","fetched_at":"2026-11-12T22:39:28+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-126354","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-126354","severity":null,"published_at":"2026-11-12T22:19:20+00:00","modified_at":"2026-11-12T22:19:20+00:00","first_seen_at":"2026-11-12T22:40:38+00:00","fetched_at":"2026-11-12T22:40:38+00:00","note":null}],"documents":[{"id":139742,"url":"https://api.github.com/repos/sporkmonger/addressable/security-advisories/GHSA-8gmh-2hhj-2hv9","title":"Maintainer's advisory: api.github.com","kind":"vendor","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: api.github.com"},{"id":139743,"url":"https://github.com/sporkmonger/addressable/security/advisories/GHSA-8gmh-2hhj-2hv9","title":"Maintainer's advisory: github.com","kind":"vendor","source":"ghsa","publisher":"vendor","text":null,"published_at":"2026-10-30T08:13:09+00:00","relation":"advisory","confidence":null,"note":"Maintainer's advisory: github.com"},{"id":139744,"url":"https://github.com/sporkmonger/addressable/commit/25c7c412a2f7e06c3cac92872e831f6d053703c5","title":"Patch: github.com","kind":"other","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"fix","confidence":null,"note":"Patch: github.com"},{"id":139737,"url":"https://github.com/sporkmonger/addressable/releases/tag/addressable-2.9.1","title":"Release: github.com","kind":"release","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"fix","confidence":null,"note":"Release: github.com"}],"disclosures":[],"exploit_sources":[],"notice":"Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai"}