Skip to content

Commit c7517ab

Browse files
tonghuarootsferik
authored andcommitted
Reject protocol-relative inputs in base_uri / persistent resolution
When HTTP::Client is configured with `base_uri:` or `HTTP.persistent("...")`, the builder previously passed user-supplied path strings straight into `URI#join` (via `String(base.join(uri))`) or naive concatenation (`"#{persistent}#{uri}"`). Per RFC 3986 §5.2, an input starting with `//` is a network-path reference and replaces the authority of the base. The existing `HTTP_OR_HTTPS_RE` guard only rejected `http://` and `https://` prefixes, so a bare `//evil.example/x` slipped through and turned a base_uri-scoped request into one to an attacker-chosen host. The same call also keeps any connection-scoped headers (`HTTP.auth(...)` bearer, custom `X-API-Key`, etc.), so the primitive is a one-shot SSRF plus credential leak — no follow-redirect chain is involved, so the usual strip-on-redirect defences never run. Fix: when the resolver sees a `//`-prefixed input on the base_uri or persistent branch, prepend `./` before delegating so the input resolves as an ordinary relative path under the configured base. Regression coverage in test/http/request/builder_test.rb covers `//evil.com/x`, `//evil.com:port/x`, `//user:pass@evil.com/x`, `///evil.com`, plus persistent + benign-absolute + benign-relative shapes, mirroring the test cases used for the equivalent fix in sibling Ruby HTTP-client gems. Reported privately as GHSA-r98x-p6m8-xcrv.
1 parent 0d2303d commit c7517ab

3 files changed

Lines changed: 80 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Security
11+
12+
- `HTTP::Request::Builder#make_request_uri` now rejects protocol-relative inputs
13+
(`//host/path`) when resolving against a configured `base_uri` or `persistent`
14+
origin. Previously such inputs flowed into `URI#merge` and replaced the base
15+
authority, allowing an attacker who controlled the path argument to redirect
16+
the request to an arbitrary host and leak any connection-scoped headers
17+
(`HTTP.auth(...)`, etc.). See `GHSA-r98x-p6m8-xcrv` for details.
18+
819
## [6.0.3] - 2026-04-20
920

1021
### Fixed

lib/http/request/builder.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,18 @@ def make_request_uri(uri)
8484
uri = uri.to_s
8585

8686
if @options.base_uri? && uri !~ HTTP_OR_HTTPS_RE
87+
# A leading "//" makes the input a protocol-relative (network-path)
88+
# reference per RFC 3986 §4.2 / §5.2. Passing it to base.join /
89+
# URI#merge would replace the base authority with the input's host,
90+
# turning a base_uri-scoped request into one to an arbitrary host.
91+
# Prepend "./" so it resolves as a normal relative path under base.
92+
uri = "./#{uri}" if uri.start_with?("//")
8793
uri = resolve_against_base(uri)
8894
elsif @options.persistent? && uri !~ HTTP_OR_HTTPS_RE
95+
# Same hazard against the persistent-host concatenation: a leading
96+
# "//" produces "scheme://persistent//evil/path" which HTTP::URI.parse
97+
# normalises into scheme://evil/path. Force a relative-path prefix.
98+
uri = "./#{uri}" if uri.start_with?("//")
8999
uri = "#{@options.persistent}#{uri}"
90100
end
91101

test/http/request/builder_test.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,4 +620,63 @@ def test_make_form_data_with_plain_hash_creates_form_data_and_sets_content_type
620620

621621
assert_includes chunks.join, "key=value"
622622
end
623+
624+
# Regression coverage for protocol-relative (network-path-reference) inputs.
625+
# Without the guard in make_request_uri, an input like "//evil.com/path"
626+
# bypasses HTTP_OR_HTTPS_RE and reaches base.join, which per RFC 3986 §5.2
627+
# replaces the base authority with the input host — turning a base_uri-scoped
628+
# request into one to an arbitrary host while keeping connection-scoped
629+
# headers (Authorization, etc.).
630+
def test_build_with_base_uri_and_protocol_relative_does_not_override_host
631+
builder = build_builder(base_uri: "http://example.com/api/")
632+
req = builder.build(:get, "//evil.com/leak")
633+
634+
assert_equal "example.com", req.uri.host
635+
refute_equal "evil.com", req.uri.host
636+
end
637+
638+
def test_build_with_base_uri_and_protocol_relative_with_port_does_not_override_host
639+
builder = build_builder(base_uri: "http://example.com/api/")
640+
req = builder.build(:get, "//evil.com:8080/leak")
641+
642+
assert_equal "example.com", req.uri.host
643+
end
644+
645+
def test_build_with_base_uri_and_protocol_relative_with_userinfo_does_not_override_host
646+
builder = build_builder(base_uri: "http://example.com/api/")
647+
req = builder.build(:get, "//user:pass@evil.com/leak")
648+
649+
assert_equal "example.com", req.uri.host
650+
end
651+
652+
def test_build_with_base_uri_and_triple_slash_does_not_override_host
653+
builder = build_builder(base_uri: "http://example.com/api/")
654+
req = builder.build(:get, "///evil.com")
655+
656+
assert_equal "example.com", req.uri.host
657+
end
658+
659+
def test_build_with_persistent_and_protocol_relative_does_not_override_host
660+
builder = build_builder(persistent: "http://example.com")
661+
req = builder.build(:get, "//evil.com/leak")
662+
663+
assert_equal "example.com", req.uri.host
664+
refute_equal "evil.com", req.uri.host
665+
end
666+
667+
def test_build_with_base_uri_still_allows_absolute_paths_after_fix
668+
builder = build_builder(base_uri: "http://example.com/api/")
669+
req = builder.build(:get, "/safe/path")
670+
671+
assert_equal "example.com", req.uri.host
672+
assert_equal "/safe/path", req.uri.path
673+
end
674+
675+
def test_build_with_base_uri_still_allows_plain_relative_after_fix
676+
builder = build_builder(base_uri: "http://example.com/api/")
677+
req = builder.build(:get, "users/me")
678+
679+
assert_equal "example.com", req.uri.host
680+
assert_equal "/api/users/me", req.uri.path
681+
end
623682
end

0 commit comments

Comments
 (0)