http: fix emptyAuth=auto for Negotiate/SPNEGO (#6170)

When a server advertises Negotiate (SPNEGO) authentication alongside
Basic, the "auto" mode of http.emptyAuth should allow libcurl to
attempt Kerberos authentication using the system ticket cache before
falling back to credential_fill(). Currently this never happens due
to an interaction between two older features.

The Negotiate-stripping logic from 4dbe66464b (remote-curl: fall back
to Basic auth if Negotiate fails, 2015-01-08) removes
CURLAUTH_GSSNEGOTIATE on the first 401, before the auto-detection
from 40a18fc77c (http: add an "auto" mode for http.emptyauth,
2017-02-25) gets a chance to see it as an "exotic" method. The result
is that auto mode silently degrades to the same behavior as
emptyAuth=false for any server whose only non-Basic/Digest method is
Negotiate, forcing Kerberos users to manually set http.emptyAuth=true
to get seamless ticket-based authentication.

This series fixes the interaction by delaying the Negotiate stripping
in auto mode by one round-trip, giving empty auth a chance to use the
system Kerberos ticket. If there is no valid ticket, Negotiate is
stripped on the second 401 and we fall through to credential_fill()
as before. The true and false modes are unchanged.

  Patch 1: Extract a http_reauth_prepare() helper from the three
           retry paths that call credential_fill() on HTTP_REAUTH.
           Pure refactor, no behavior change.

  Patch 2: Delay the GSSNEGOTIATE stripping in auto mode and teach
           http_reauth_prepare() to skip credential_fill() when
           empty auth should be attempted first.

  Patch 3: Add tests verifying that auto mode produces an extra
           round-trip (empty auth attempt) compared to false mode,
           using the existing nph-custom-auth.sh CGI infrastructure.

There is a trade-off in auto mode: when a server advertises Negotiate
but the client has no valid Kerberos ticket, there is one extra
round-trip compared to the current behavior. This matches the
trade-off already documented in 40a18fc77c. Users who want to avoid
it can set http.emptyAuth=false.
This commit is contained in:
Johannes Schindelin
2026-04-14 13:47:27 +02:00
4 changed files with 112 additions and 4 deletions

32
http.c
View File

@@ -139,6 +139,7 @@ static unsigned long empty_auth_useless =
CURLAUTH_BASIC
| CURLAUTH_DIGEST_IE
| CURLAUTH_DIGEST;
static int empty_auth_try_negotiate;
static struct curl_slist *pragma_header;
static struct string_list extra_http_headers = STRING_LIST_INIT_DUP;
@@ -704,6 +705,22 @@ static void init_curl_http_auth(CURL *result)
}
}
void http_reauth_prepare(int all_capabilities)
{
/*
* If we deferred stripping Negotiate to give empty auth a
* chance (auto mode), skip credential_fill on this retry so
* that init_curl_http_auth() sends empty credentials and
* libcurl can attempt Negotiate with the system ticket cache.
*/
if (empty_auth_try_negotiate &&
!http_auth.password && !http_auth.credential &&
(http_auth_methods & CURLAUTH_GSSNEGOTIATE))
return;
credential_fill(the_repository, &http_auth, all_capabilities);
}
/* *var must be free-able */
static void var_override(char **var, char *value)
{
@@ -1954,7 +1971,18 @@ static int handle_curl_result(struct slot_results *results)
}
return HTTP_NOAUTH;
} else {
http_auth_methods &= ~CURLAUTH_GSSNEGOTIATE;
if (curl_empty_auth == -1 &&
!empty_auth_try_negotiate &&
(results->auth_avail & CURLAUTH_GSSNEGOTIATE)) {
/*
* In auto mode, give Negotiate a chance via
* empty auth before stripping it. If it fails,
* we will strip it on the next 401.
*/
empty_auth_try_negotiate = 1;
} else {
http_auth_methods &= ~CURLAUTH_GSSNEGOTIATE;
}
if (results->auth_avail) {
http_auth_methods &= results->auth_avail;
http_auth_methods_restricted = 1;
@@ -2462,7 +2490,7 @@ static int http_request_recoverable(const char *url,
sleep(retry_delay);
}
} else if (ret == HTTP_REAUTH) {
credential_fill(the_repository, &http_auth, 1);
http_reauth_prepare(1);
}
/*

6
http.h
View File

@@ -76,6 +76,12 @@ extern int http_is_verbose;
extern ssize_t http_post_buffer;
extern struct credential http_auth;
/**
* Prepare for an HTTP re-authentication retry. This fills credentials
* via credential_fill() so the next request can include them.
*/
void http_reauth_prepare(int all_capabilities);
extern char curl_errorstr[CURL_ERROR_SIZE];
enum http_follow_config {

View File

@@ -946,7 +946,7 @@ static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_rece
do {
err = probe_rpc(rpc, &results);
if (err == HTTP_REAUTH)
credential_fill(the_repository, &http_auth, 0);
http_reauth_prepare(0);
} while (err == HTTP_REAUTH);
if (err != HTTP_OK)
return -1;
@@ -1068,7 +1068,7 @@ retry:
rpc->any_written = 0;
err = run_slot(slot, NULL);
if (err == HTTP_REAUTH && !large_request) {
credential_fill(the_repository, &http_auth, 0);
http_reauth_prepare(0);
curl_slist_free_all(headers);
goto retry;
}

View File

@@ -748,4 +748,78 @@ test_expect_success NTLM 'access using NTLM auth' '
git ls-remote "$HTTPD_URL/ntlm_auth/repo.git"
'
test_lazy_prereq SPNEGO 'curl --version | grep -qi "SPNEGO\|GSS-API\|Kerberos\|negotiate"'
test_expect_success SPNEGO 'http.emptyAuth=auto attempts Negotiate before credential_fill' '
test_when_finished "per_test_cleanup" &&
set_credential_reply get <<-EOF &&
username=alice
password=secret-passwd
EOF
# Basic base64(alice:secret-passwd)
cat >"$HTTPD_ROOT_PATH/custom-auth.valid" <<-EOF &&
id=1 creds=Basic YWxpY2U6c2VjcmV0LXBhc3N3ZA==
EOF
cat >"$HTTPD_ROOT_PATH/custom-auth.challenge" <<-EOF &&
id=1 status=200
id=default response=WWW-Authenticate: Negotiate
id=default response=WWW-Authenticate: Basic realm="example.com"
EOF
test_config_global credential.helper test-helper &&
GIT_TRACE_CURL="$TRASH_DIRECTORY/trace-auto" \
git -c http.emptyAuth=auto \
ls-remote "$HTTPD_URL/custom_auth/repo.git" &&
# In auto mode with a Negotiate+Basic server, there should be
# three 401 responses: (1) initial no-auth request, (2) empty-auth
# retry where Negotiate fails (no Kerberos ticket), (3) libcurl
# internal Negotiate retry. The fourth attempt uses Basic
# credentials from credential_fill and succeeds.
grep "HTTP/[0-9.]* 401" "$TRASH_DIRECTORY/trace-auto" >actual_401s &&
test_line_count = 3 actual_401s &&
expect_credential_query get <<-EOF
capability[]=authtype
capability[]=state
protocol=http
host=$HTTPD_DEST
wwwauth[]=Negotiate
wwwauth[]=Basic realm="example.com"
EOF
'
test_expect_success SPNEGO 'http.emptyAuth=false skips Negotiate' '
test_when_finished "per_test_cleanup" &&
set_credential_reply get <<-EOF &&
username=alice
password=secret-passwd
EOF
# Basic base64(alice:secret-passwd)
cat >"$HTTPD_ROOT_PATH/custom-auth.valid" <<-EOF &&
id=1 creds=Basic YWxpY2U6c2VjcmV0LXBhc3N3ZA==
EOF
cat >"$HTTPD_ROOT_PATH/custom-auth.challenge" <<-EOF &&
id=1 status=200
id=default response=WWW-Authenticate: Negotiate
id=default response=WWW-Authenticate: Basic realm="example.com"
EOF
test_config_global credential.helper test-helper &&
GIT_TRACE_CURL="$TRASH_DIRECTORY/trace-false" \
git -c http.emptyAuth=false \
ls-remote "$HTTPD_URL/custom_auth/repo.git" &&
# With emptyAuth=false, Negotiate is stripped immediately and
# credential_fill is called right away. Only one 401 response.
grep "HTTP/[0-9.]* 401" "$TRASH_DIRECTORY/trace-false" >actual_401s &&
test_line_count = 1 actual_401s
'
test_done