Summary
gix-url's hand-rolled URL parser does not treat ? or # as terminating the authority.
For http://a?@b/repo it reports the host as b, where Git, libcurl, browsers and the
url crate all report a.
This is a correctness regression on its own. Its security consequence is that
gix-transport's HTTP redirect guard — redirect::can_reuse_identity(), added in PR #2686
to fix GHSA-9857 — compares the wrong host and fails open. A server that can return a
crafted Location gets the caller's Authorization: Basic credentials on the next request,
sent to an authority the credentials do not belong to.
Details
Root cause (#186) — gix-url/src/simple_url.rs:72
let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
let authority = &after_scheme[..path_start];
...
if let Some((user_info, host_port)) = authority.rsplit_once('@') { ... }
The authority is taken as everything up to the first /, so ? and # are absorbed into it,
and rsplit_once('@') then hands everything after the last @ to parse_host_port().
RFC 3986 §3.2 ends the authority at the first /, ? or #.
This is a regression from commit b34d1d270 ("feat: Replace url dependency with minimal custom
implementation", 2025-11-22). Before it, parse::url() took host from url::Url::host_str(),
which is RFC-conformant.
Url::to_bstring() percent-encodes ?/# into the userinfo, so a URL that gitoxide parsed
itself stays internally consistent. The divergence only bites where a raw URL string reaches
both gitoxide's parser and the HTTP client — which is exactly the redirect Location header.
Impact (#187) — gix-transport/src/client/blocking_io/http/redirect.rs:51
can_reuse_identity(redirect_url, original_url) calls gix_url::parse on the server-supplied
Location and compares .host. With a Location of the form
<attacker-authority>?@<original-authority><original-tail>:
redirect::base_url() accepts it (scheme_is_safe passes, the tail matches), so
Transport::url becomes the attacker's base;
can_reuse_identity() reads the host as the original host, returns true, and
sync_redirected_base_url_from() (http/mod.rs:262) therefore keeps the identity;
- the next
request() builds the POST URL from the attacker base and
add_basic_auth_if_present() (http/mod.rs:306) attaches Authorization: Basic <creds>.
libcurl does the right thing on its own — it strips the custom Authorization on the
cross-origin GET redirect. gitoxide then re-attaches it on the POST.
Reachable with default configuration: http.followRedirects defaults to
FollowRedirects::Initial (http/mod.rs, #[default] Initial).
Reproduction
Two loopback HTTP servers, no external hosts. Confirmed on main @ 5708de434,
macOS, rustc 1.95.0, system git 2.50.1.
(a) Host divergence, via the gix CLI
http://localhost?@127.0.0.1:8802/repo — a server listening on 127.0.0.1:8802 logs the request
from gix clone, while git clone of the same string reports
Failed to connect to localhost port 80 and the 8802 server sees nothing. The url crate
reports host=Some("localhost"). Identical result with # in place of ?.
(b) Credential leak
A driver built against gix-transport (features blocking-client, http-client-curl, http-client-curl-rust-tls, http-client-insecure-credentials) sets an identity and handshakes
against http://127.0.0.1:8801/repo. The 8801 server answers the info/refs GET with a 302
whose Location uses the ?@ shape above and points at 127.0.0.1:8802. The 8802 server then
logs a POST carrying Authorization: Basic with the credentials that were provisioned for
8801, and the transport reports identity after handshake: Some("victim-user").
Note on the repro: http-client-insecure-credentials is only needed because
add_basic_auth_if_present() refuses cleartext HTTP. On https:// — the real-world case —
there is no such gate and no feature flag is involved; scheme_is_safe() permits
https → https, so the same Location works.
Full server scripts and the driver source are held locally and will be provided on request
rather than published.
Impact
An attacker who can control a redirect response from an origin the user authenticates to
(a hostile or compromised forge, or an open redirect on a legitimate one) obtains that origin's
HTTP Basic credentials — commonly a PAT or OAuth token from the user's credential helper.
Independently of the credential leak, any embedder that validates or displays a repository URL's
host with a conformant parser and then hands the same string to gix will disagree with where
gitoxide actually connects. That is a host-allowlist bypass / request-forgery primitive for
downstream consumers of gix-url.
Suggested direction (maintainer's call)
- In
simple_url::parse, end the authority at the first of /, ?, #, and keep the
remainder in path as today. This restores the pre-b34d1d270 host and is the narrow fix.
- Independently,
can_reuse_identity() should fail closed on any URL whose authority contains
a character that is not legal there, rather than trusting a permissive parse of a
server-controlled header.
- Extend
redirected_post_does_not_forward_basic_auth_to_the_new_host with the ?@ and #@
Location shapes — the current test only exercises a plain cross-host Location.
Happy to open the PR once you have decided how you want this handled and disclosed.
Disclosure: this report was prepared with AI assistance (Claude Code) operating through
the shuvamk account, per CONTRIBUTING.md's "Prevent agent impersonation" section. Every
result quoted above was executed locally against the commit named; the host-divergence and
redirect code paths were re-verified unchanged on main @ da5fa7359 before sending.
Summary
gix-url's hand-rolled URL parser does not treat?or#as terminating the authority.For
http://a?@b/repoit reports the host asb, where Git, libcurl, browsers and theurlcrate all reporta.This is a correctness regression on its own. Its security consequence is that
gix-transport's HTTP redirect guard —redirect::can_reuse_identity(), added in PR #2686to fix GHSA-9857 — compares the wrong host and fails open. A server that can return a
crafted
Locationgets the caller'sAuthorization: Basiccredentials on the next request,sent to an authority the credentials do not belong to.
Details
Root cause (#186) —
gix-url/src/simple_url.rs:72The authority is taken as everything up to the first
/, so?and#are absorbed into it,and
rsplit_once('@')then hands everything after the last@toparse_host_port().RFC 3986 §3.2 ends the authority at the first
/,?or#.This is a regression from commit
b34d1d270("feat: Replace url dependency with minimal customimplementation", 2025-11-22). Before it,
parse::url()tookhostfromurl::Url::host_str(),which is RFC-conformant.
Url::to_bstring()percent-encodes?/#into the userinfo, so a URL that gitoxide parseditself stays internally consistent. The divergence only bites where a raw URL string reaches
both gitoxide's parser and the HTTP client — which is exactly the redirect
Locationheader.Impact (#187) —
gix-transport/src/client/blocking_io/http/redirect.rs:51can_reuse_identity(redirect_url, original_url)callsgix_url::parseon the server-suppliedLocationand compares.host. With aLocationof the form<attacker-authority>?@<original-authority><original-tail>:redirect::base_url()accepts it (scheme_is_safepasses, the tail matches), soTransport::urlbecomes the attacker's base;can_reuse_identity()reads the host as the original host, returnstrue, andsync_redirected_base_url_from()(http/mod.rs:262) therefore keeps the identity;request()builds the POST URL from the attacker base andadd_basic_auth_if_present()(http/mod.rs:306) attachesAuthorization: Basic <creds>.libcurl does the right thing on its own — it strips the custom
Authorizationon thecross-origin GET redirect. gitoxide then re-attaches it on the POST.
Reachable with default configuration:
http.followRedirectsdefaults toFollowRedirects::Initial(http/mod.rs,#[default] Initial).Reproduction
Two loopback HTTP servers, no external hosts. Confirmed on
main@5708de434,macOS, rustc 1.95.0, system git 2.50.1.
(a) Host divergence, via the
gixCLIhttp://localhost?@127.0.0.1:8802/repo— a server listening on 127.0.0.1:8802 logs the requestfrom
gix clone, whilegit cloneof the same string reportsFailed to connect to localhost port 80and the 8802 server sees nothing. Theurlcratereports
host=Some("localhost"). Identical result with#in place of?.(b) Credential leak
A driver built against
gix-transport(featuresblocking-client, http-client-curl, http-client-curl-rust-tls, http-client-insecure-credentials) sets an identity and handshakesagainst
http://127.0.0.1:8801/repo. The 8801 server answers theinfo/refsGET with a 302whose
Locationuses the?@shape above and points at 127.0.0.1:8802. The 8802 server thenlogs a POST carrying
Authorization: Basicwith the credentials that were provisioned for8801, and the transport reports
identity after handshake: Some("victim-user").Note on the repro:
http-client-insecure-credentialsis only needed becauseadd_basic_auth_if_present()refuses cleartext HTTP. Onhttps://— the real-world case —there is no such gate and no feature flag is involved;
scheme_is_safe()permitshttps→https, so the sameLocationworks.Full server scripts and the driver source are held locally and will be provided on request
rather than published.
Impact
An attacker who can control a redirect response from an origin the user authenticates to
(a hostile or compromised forge, or an open redirect on a legitimate one) obtains that origin's
HTTP Basic credentials — commonly a PAT or OAuth token from the user's credential helper.
Independently of the credential leak, any embedder that validates or displays a repository URL's
host with a conformant parser and then hands the same string to
gixwill disagree with wheregitoxide actually connects. That is a host-allowlist bypass / request-forgery primitive for
downstream consumers of
gix-url.Suggested direction (maintainer's call)
simple_url::parse, end the authority at the first of/,?,#, and keep theremainder in
pathas today. This restores the pre-b34d1d270host and is the narrow fix.can_reuse_identity()should fail closed on any URL whose authority containsa character that is not legal there, rather than trusting a permissive parse of a
server-controlled header.
redirected_post_does_not_forward_basic_auth_to_the_new_hostwith the?@and#@Locationshapes — the current test only exercises a plain cross-hostLocation.Happy to open the PR once you have decided how you want this handled and disclosed.
Disclosure: this report was prepared with AI assistance (Claude Code) operating through
the
shuvamkaccount, perCONTRIBUTING.md's "Prevent agent impersonation" section. Everyresult quoted above was executed locally against the commit named; the host-divergence and
redirect code paths were re-verified unchanged on
main@da5fa7359before sending.