From 11ed8a20046bf3baff41cd1873a1384da0f892b6 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 26 Nov 2025 17:51:41 +0100 Subject: [PATCH 1/4] t5563: verify that NTLM authentication works Although NTLM authentication is considered weak (extending even to NTLMv2, which purportedly allows brute-forcing reasonably complex 8-character passwords in a matter of days, given ample compute resources), it _is_ one of the authentication methods supported by libcurl. Note: The added test case *cannot* reuse the existing `custom_auth` facility. The reason is that that facility is backed by an NPH script ("No Parse Headers"), which does not allow handling the 3-phase NTLM authentication correctly (in my hands, the NPH script would not even be called upon the Type 3 message, a "200 OK" would be returned, but no headers, let alone the `git http-backend` output as payload). Having a separate NTLM authentication script makes the exact workings clearer and more readable, anyway. Co-authored-by: Matthew John Cheetham Signed-off-by: Johannes Schindelin --- t/lib-httpd.sh | 1 + t/lib-httpd/apache.conf | 8 ++++ t/lib-httpd/ntlm-handshake.sh | 38 +++++++++++++++++++ t/t5563-simple-http-auth.sh | 71 +++-------------------------------- 4 files changed, 53 insertions(+), 65 deletions(-) create mode 100755 t/lib-httpd/ntlm-handshake.sh diff --git a/t/lib-httpd.sh b/t/lib-httpd.sh index fc646447d5..68823c6ed2 100644 --- a/t/lib-httpd.sh +++ b/t/lib-httpd.sh @@ -168,6 +168,7 @@ prepare_httpd() { install_script apply-one-time-script.sh install_script nph-custom-auth.sh install_script http-429.sh + install_script ntlm-handshake.sh ln -s "$LIB_HTTPD_MODULE_PATH" "$HTTPD_ROOT_PATH/modules" diff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf index 664f23fc6c..192271ba99 100644 --- a/t/lib-httpd/apache.conf +++ b/t/lib-httpd/apache.conf @@ -155,6 +155,13 @@ SetEnv PERL_PATH ${PERL_PATH} CGIPassAuth on + + SetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH} + SetEnv GIT_HTTP_EXPORT_ALL + + CGIPassAuth on + + ScriptAlias /smart/incomplete_length/git-upload-pack incomplete-length-upload-pack-v2-http.sh/ ScriptAlias /smart/incomplete_body/git-upload-pack incomplete-body-upload-pack-v2-http.sh/ ScriptAlias /smart/no_report/git-receive-pack error-no-report.sh/ @@ -166,6 +173,7 @@ ScriptAlias /error/ error.sh/ ScriptAliasMatch /one_time_script/(.*) apply-one-time-script.sh/$1 ScriptAliasMatch /http_429/(.*) http-429.sh/$1 ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1 +ScriptAliasMatch /ntlm_auth/(.*) ntlm-handshake.sh/$1 Options FollowSymlinks diff --git a/t/lib-httpd/ntlm-handshake.sh b/t/lib-httpd/ntlm-handshake.sh new file mode 100755 index 0000000000..3cf1266e40 --- /dev/null +++ b/t/lib-httpd/ntlm-handshake.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +case "$HTTP_AUTHORIZATION" in +'') + # No Authorization header -> send NTLM challenge + echo "Status: 401 Unauthorized" + echo "WWW-Authenticate: NTLM" + echo + ;; +"NTLM TlRMTVNTUAAB"*) + # Type 1 -> respond with Type 2 challenge (hardcoded) + echo "Status: 401 Unauthorized" + # Base64-encoded version of the Type 2 challenge: + # signature: 'NTLMSSP\0' + # message_type: 2 + # target_name: 'NTLM-GIT-SERVER' + # flags: 0xa2898205 = + # NEGOTIATE_UNICODE, REQUEST_TARGET, NEGOTIATE_NT_ONLY, + # TARGET_TYPE_SERVER, TARGET_TYPE_SHARE, REQUEST_NON_NT_SESSION_KEY, + # NEGOTIATE_VERSION, NEGOTIATE_128, NEGOTIATE_56 + # challenge: 0xfa3dec518896295b + # context: '0000000000000000' + # target_info_present: true + # target_info_len: 128 + # version: '10.0 (build 19041)' + echo "WWW-Authenticate: NTLM TlRMTVNTUAACAAAAHgAeADgAAAAFgomi+j3sUYiWKVsAAAAAAAAAAIAAgABWAAAACgBhSgAAAA9OAFQATABNAC0ARwBJAFQALQBTAEUAUgBWAEUAUgACABIAVwBPAFIASwBHAFIATwBVAFAAAQAeAE4AVABMAE0ALQBHAEkAVAAtAFMARQBSAFYARQBSAAQAEgBXAE8AUgBLAEcAUgBPAFUAUAADAB4ATgBUAEwATQAtAEcASQBUAC0AUwBFAFIAVgBFAFIABwAIAACfOcZKYNwBAAAAAA==" + echo + ;; +"NTLM TlRMTVNTUAAD"*) + # Type 3 -> accept without validation + exec "$GIT_EXEC_PATH"/git-http-backend + ;; +*) + echo "Status: 500 Unrecognized" + echo + echo "Unhandled auth: '$HTTP_AUTHORIZATION'" + ;; +esac diff --git a/t/t5563-simple-http-auth.sh b/t/t5563-simple-http-auth.sh index 349ae4ab39..2082e553ae 100755 --- a/t/t5563-simple-http-auth.sh +++ b/t/t5563-simple-http-auth.sh @@ -764,78 +764,19 @@ test_expect_success 'access using three-legged auth' ' EOF ' -test_lazy_prereq SPNEGO 'curl --version | grep -qi "SPNEGO\|GSS-API\|Kerberos\|negotiate"' +test_lazy_prereq NTLM 'curl --version | grep -q NTLM' -test_expect_success SPNEGO 'http.emptyAuth=auto attempts Negotiate before credential_fill' ' +test_expect_success NTLM 'access using NTLM auth' ' 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" + username=user + password=pwd 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 + GIT_TRACE_CURL=1 \ + git ls-remote "$HTTPD_URL/ntlm_auth/repo.git" ' test_done From bbbf3a21b0728b027a612e61cd768277154195d8 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 26 Nov 2025 18:47:19 +0100 Subject: [PATCH 2/4] http: disallow NTLM authentication by default NTLM authentication is relatively weak. This is the case even with the default setting of modern Windows versions, where NTLMv1 and LanManager are disabled and only NTLMv2 is enabled: NTLMv2 hashes of even reasonably complex 8-character passwords can be broken in a matter of days, given enough compute resources. Even worse: On Windows, NTLM authentication uses Security Support Provider Interface ("SSPI"), which provides the credentials without requiring the user to type them in. Which means that an attacker could talk an unsuspecting user into cloning from a server that is under the attacker's control and extracts the user's NTLMv2 hash without their knowledge. For that reason, let's disallow NTLM authentication by default. NTLM authentication is quite simple to set up, though, and therefore there are still some on-prem Azure DevOps setups out there whose users and/or automation rely on this type of authentication. To give them an escape hatch, introduce the `http..allowNTLMAuth` config setting that can be set to `true` to opt back into using NTLM for a specific remote repository. Signed-off-by: Johannes Schindelin --- Documentation/config/http.adoc | 5 +++++ http.c | 20 ++++++++++++++++---- t/t5563-simple-http-auth.sh | 6 ++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc index 792a71b413..ed1281b82c 100644 --- a/Documentation/config/http.adoc +++ b/Documentation/config/http.adoc @@ -242,6 +242,11 @@ http.sslKeyType:: See also libcurl `CURLOPT_SSLKEYTYPE`. Can be overridden by the `GIT_SSL_KEY_TYPE` environment variable. +http.allowNTLMAuth:: + Whether or not to allow NTLM authentication. While very convenient to set + up, and therefore still used in many on-prem scenarios, NTLM is a weak + authentication method and therefore deprecated. Defaults to "false". + http.schannelCheckRevoke:: Used to enforce or disable certificate revocation checks in cURL when http.sslBackend is set to "schannel". Defaults to `true` if diff --git a/http.c b/http.c index 8088eded36..85e10dd550 100644 --- a/http.c +++ b/http.c @@ -131,7 +131,8 @@ enum http_follow_config http_follow_config = HTTP_FOLLOW_INITIAL; static struct credential cert_auth = CREDENTIAL_INIT; static int ssl_cert_password_required; -static unsigned long http_auth_methods = CURLAUTH_ANY; +static unsigned long http_auth_any = CURLAUTH_ANY & ~CURLAUTH_NTLM; +static unsigned long http_auth_methods; static int http_auth_methods_restricted; /* Modes for which empty_auth cannot actually help us. */ static unsigned long empty_auth_useless = @@ -430,6 +431,15 @@ static int http_options(const char *var, const char *value, return 0; } + if (!strcmp("http.allowntlmauth", var)) { + if (git_config_bool(var, value)) { + http_auth_any |= CURLAUTH_NTLM; + } else { + http_auth_any &= ~CURLAUTH_NTLM; + } + return 0; + } + if (!strcmp("http.schannelcheckrevoke", var)) { http_schannel_check_revoke = git_config_bool(var, value); return 0; @@ -726,11 +736,11 @@ static void init_curl_proxy_auth(CURL *result) if (i == ARRAY_SIZE(proxy_authmethods)) { warning("unsupported proxy authentication method %s: using anyauth", http_proxy_authmethod); - curl_easy_setopt(result, CURLOPT_PROXYAUTH, CURLAUTH_ANY); + curl_easy_setopt(result, CURLOPT_PROXYAUTH, http_auth_any); } } else - curl_easy_setopt(result, CURLOPT_PROXYAUTH, CURLAUTH_ANY); + curl_easy_setopt(result, CURLOPT_PROXYAUTH, http_auth_any); } static int has_cert_password(void) @@ -1140,7 +1150,7 @@ static CURL *get_curl_handle(void) } curl_easy_setopt(result, CURLOPT_NETRC, CURL_NETRC_OPTIONAL); - curl_easy_setopt(result, CURLOPT_HTTPAUTH, CURLAUTH_ANY); + curl_easy_setopt(result, CURLOPT_HTTPAUTH, http_auth_any); #ifdef CURLGSSAPI_DELEGATION_FLAG if (curl_deleg) { @@ -1508,6 +1518,8 @@ void http_init(struct remote *remote, const char *url, int proactive_auth) set_long_from_env(&http_max_retries, "GIT_HTTP_MAX_RETRIES"); set_long_from_env(&http_max_retry_time, "GIT_HTTP_MAX_RETRY_TIME"); + http_auth_methods = http_auth_any; + curl_default = get_curl_handle(); } diff --git a/t/t5563-simple-http-auth.sh b/t/t5563-simple-http-auth.sh index 2082e553ae..965c2fb716 100755 --- a/t/t5563-simple-http-auth.sh +++ b/t/t5563-simple-http-auth.sh @@ -775,8 +775,10 @@ test_expect_success NTLM 'access using NTLM auth' ' EOF test_config_global credential.helper test-helper && - GIT_TRACE_CURL=1 \ - git ls-remote "$HTTPD_URL/ntlm_auth/repo.git" + test_must_fail env GIT_TRACE_CURL=1 git \ + ls-remote "$HTTPD_URL/ntlm_auth/repo.git" && + GIT_TRACE_CURL=1 git -c http.$HTTPD_URL.allowNTLMAuth=true \ + ls-remote "$HTTPD_URL/ntlm_auth/repo.git" ' test_done From b0112ac3e8e63630ee7774d795fa47ffde7d49f5 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 26 Nov 2025 19:18:35 +0100 Subject: [PATCH 3/4] http: warn if might have failed because of NTLM The new default of Git is to disable NTLM authentication by default. To help users find the escape hatch of that config setting, should they need it, suggest it when the authentication failed and the server had offered NTLM, i.e. if re-enabling it would fix the problem. Helped-by: Patrick Steinhardt Signed-off-by: Johannes Schindelin --- http.c | 11 +++++++++++ t/t5563-simple-http-auth.sh | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/http.c b/http.c index 85e10dd550..fcb143d9db 100644 --- a/http.c +++ b/http.c @@ -1960,6 +1960,17 @@ static int handle_curl_result(struct slot_results *results) credential_reject(the_repository, &http_auth); if (always_auth_proactively()) http_proactive_auth = PROACTIVE_AUTH_NONE; + if ((results->auth_avail & CURLAUTH_NTLM) && + !(http_auth_any & CURLAUTH_NTLM)) { + warning(_("Due to its cryptographic weaknesses, " + "NTLM authentication has been\n" + "disabled in Git by default. You can " + "re-enable it for trusted servers\n" + "by running:\n\n" + "git config set " + "http.%s://%s.allowNTLMAuth true"), + http_auth.protocol, http_auth.host); + } return HTTP_NOAUTH; } else { if (curl_empty_auth == -1 && diff --git a/t/t5563-simple-http-auth.sh b/t/t5563-simple-http-auth.sh index 965c2fb716..bd3b697cfd 100755 --- a/t/t5563-simple-http-auth.sh +++ b/t/t5563-simple-http-auth.sh @@ -776,7 +776,8 @@ test_expect_success NTLM 'access using NTLM auth' ' test_config_global credential.helper test-helper && test_must_fail env GIT_TRACE_CURL=1 git \ - ls-remote "$HTTPD_URL/ntlm_auth/repo.git" && + ls-remote "$HTTPD_URL/ntlm_auth/repo.git" 2>err && + test_grep "allowNTLMAuth" err && GIT_TRACE_CURL=1 git -c http.$HTTPD_URL.allowNTLMAuth=true \ ls-remote "$HTTPD_URL/ntlm_auth/repo.git" ' From 3fccc23ceacb0688642252b6fb1c81df4992e6da Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 9 Feb 2026 18:21:48 +0100 Subject: [PATCH 4/4] credential: advertise NTLM suppression and allow helpers to re-enable The previous commits disabled NTLM authentication by default due to its cryptographic weaknesses. Users can re-enable it via the config setting http..allowNTLMAuth, but this requires manual intervention. Credential helpers may have knowledge about which servers are trusted for NTLM authentication (e.g., known on-prem Azure DevOps instances). To allow them to signal this trust, introduce a simple negotiation: when NTLM is suppressed and the server offered it, Git advertises ntlm=suppressed to the credential helper. The helper can respond with ntlm=allow to re-enable NTLM for this request. This happens precisely at the point where we would otherwise warn the user about NTLM being suppressed, ensuring the capability is only advertised when relevant. Helped-by: Matthew John Cheetham Signed-off-by: Johannes Schindelin --- credential.c | 5 +++++ credential.h | 3 +++ http.c | 17 +++++++++++++++-- t/t5563-simple-http-auth.sh | 13 ++++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/credential.c b/credential.c index 2594c0c422..af96418936 100644 --- a/credential.c +++ b/credential.c @@ -360,6 +360,9 @@ int credential_read(struct credential *c, FILE *fp, credential_set_capability(&c->capa_authtype, op_type); else if (!strcmp(value, "state")) credential_set_capability(&c->capa_state, op_type); + } else if (!strcmp(key, "ntlm")) { + if (!strcmp(value, "allow")) + c->ntlm_allow = 1; } else if (!strcmp(key, "continue")) { c->multistage = !!git_config_bool("continue", value); } else if (!strcmp(key, "password_expiry_utc")) { @@ -420,6 +423,8 @@ void credential_write(const struct credential *c, FILE *fp, if (c->ephemeral) credential_write_item(c, fp, "ephemeral", "1", 0); } + if (c->ntlm_suppressed) + credential_write_item(c, fp, "ntlm", "suppressed", 0); credential_write_item(c, fp, "protocol", c->protocol, 1); credential_write_item(c, fp, "host", c->host, 1); credential_write_item(c, fp, "path", c->path, 0); diff --git a/credential.h b/credential.h index c78b72d110..95244d5375 100644 --- a/credential.h +++ b/credential.h @@ -177,6 +177,9 @@ struct credential { struct credential_capability capa_authtype; struct credential_capability capa_state; + unsigned ntlm_suppressed:1, + ntlm_allow:1; + char *username; char *password; char *credential; diff --git a/http.c b/http.c index fcb143d9db..f760953120 100644 --- a/http.c +++ b/http.c @@ -661,6 +661,11 @@ static void init_curl_http_auth(CURL *result) credential_fill(the_repository, &http_auth, 1); + if (http_auth.ntlm_allow && !(http_auth_methods & CURLAUTH_NTLM)) { + http_auth_methods |= CURLAUTH_NTLM; + curl_easy_setopt(result, CURLOPT_HTTPAUTH, http_auth_methods); + } + if (http_auth.password) { if (always_auth_proactively()) { /* @@ -1951,6 +1956,8 @@ static int handle_curl_result(struct slot_results *results) } else if (missing_target(results)) return HTTP_MISSING_TARGET; else if (results->http_code == 401) { + http_auth.ntlm_suppressed = (results->auth_avail & CURLAUTH_NTLM) && + !(http_auth_any & CURLAUTH_NTLM); if ((http_auth.username && http_auth.password) ||\ (http_auth.authtype && http_auth.credential)) { if (http_auth.multistage) { @@ -1960,8 +1967,7 @@ static int handle_curl_result(struct slot_results *results) credential_reject(the_repository, &http_auth); if (always_auth_proactively()) http_proactive_auth = PROACTIVE_AUTH_NONE; - if ((results->auth_avail & CURLAUTH_NTLM) && - !(http_auth_any & CURLAUTH_NTLM)) { + if (http_auth.ntlm_suppressed) { warning(_("Due to its cryptographic weaknesses, " "NTLM authentication has been\n" "disabled in Git by default. You can " @@ -2509,6 +2515,13 @@ static int http_request_recoverable(const char *url, http_reauth_prepare(1); } + /* + * Re-enable NTLM auth if the helper allows it and we would + * otherwise suppress authentication via NTLM. + */ + if (http_auth.ntlm_suppressed && http_auth.ntlm_allow) + http_auth_methods |= CURLAUTH_NTLM; + ret = http_request(url, result, target, options); } if (ret == HTTP_RATE_LIMITED) { diff --git a/t/t5563-simple-http-auth.sh b/t/t5563-simple-http-auth.sh index bd3b697cfd..434cf46cd7 100755 --- a/t/t5563-simple-http-auth.sh +++ b/t/t5563-simple-http-auth.sh @@ -778,8 +778,19 @@ test_expect_success NTLM 'access using NTLM auth' ' test_must_fail env GIT_TRACE_CURL=1 git \ ls-remote "$HTTPD_URL/ntlm_auth/repo.git" 2>err && test_grep "allowNTLMAuth" err && + + # Can be enabled via config GIT_TRACE_CURL=1 git -c http.$HTTPD_URL.allowNTLMAuth=true \ - ls-remote "$HTTPD_URL/ntlm_auth/repo.git" + ls-remote "$HTTPD_URL/ntlm_auth/repo.git" && + + # Or via credential helper responding with ntlm=allow + set_credential_reply get <<-EOF && + username=user + password=pwd + ntlm=allow + EOF + + git ls-remote "$HTTPD_URL/ntlm_auth/repo.git" ' test_done