POST /api/v2.0/ldap/ping ignores its own documented rule "an empty request loads the current configuration" for every field except the password
Expected behavior and actual behavior:
Expected: the endpoint's own OpenAPI description (api/v2.0/swagger.yaml) says that an empty request body tests the current saved LDAP configuration:
"ldap configuration. support input ldap service configuration. If it is a empty request, will load current configuration from the system."
So POST /api/v2.0/ldap/ping with {} should return {"success":true} when the saved configuration works. We already know it works, because /ldap/users/search and /ldap/groups/search prove it with the same saved configuration.
Actual: it always returns {"message":"error: ldap server network timeout"} instead. This happens even though:
- the saved configuration works fine for real LDAP searches (see Evidence);
- sending the exact same values again, directly in the request body, works (see Evidence);
- a real, unreachable LDAP server takes more than a minute to fail this same call. It does not fail in under one second. So this error is not a real timeout (see Evidence).
Steps to reproduce the problem:
$HARBOR_URL / $HARBOR_ADMIN_PASSWORD are your own Harbor instance and its admin password. $AD_DC_IP / $AD_READER_DN / $AD_READER_PASSWORD / $AD_DOMAIN_DN are your own AD/LDAP server's connection details. $LDAP_TEST_USERNAME / $LDAP_TEST_GROUPNAME are any username and group that exist in that directory. We use them below only to check that the saved configuration really works. None of these values matter for the bug itself. The bug only needs the ping request body to be empty.
- Set up LDAP auth with
PUT /api/v2.0/configurations. Point it at any real, reachable AD/LDAP server:
curl -sk -u "admin:$HARBOR_ADMIN_PASSWORD" -X PUT "https://${HARBOR_URL}/api/v2.0/configurations" \
-H "Content-Type: application/json" -d "{
\"auth_mode\": \"ldap_auth\",
\"ldap_url\": \"ldap://${AD_DC_IP}\",
\"ldap_search_dn\": \"${AD_READER_DN}\",
\"ldap_search_password\": \"${AD_READER_PASSWORD}\",
\"ldap_base_dn\": \"CN=Users,${AD_DOMAIN_DN}\",
\"ldap_filter\": \"(objectClass=user)\",
\"ldap_uid\": \"sAMAccountName\",
\"ldap_scope\": 2,
\"ldap_verify_cert\": false
}"- Check that the saved configuration really works against the real LDAP server:
curl -sk -u "admin:$HARBOR_ADMIN_PASSWORD" "https://${HARBOR_URL}/api/v2.0/ldap/users/search?username=${LDAP_TEST_USERNAME}"
# -> returns that user, confirming the saved config authenticates for real- Send a ping with an empty body, exactly as the endpoint's own documentation says:
curl -sk -u "admin:$HARBOR_ADMIN_PASSWORD" -X POST "https://${HARBOR_URL}/api/v2.0/ldap/ping" \
-H "Content-Type: application/json" -d '{}'Versions:
- harbor version: we reproduced this on a Mirantis MSR4 instance. MSR4 is a downstream product based on Harbor. MSR4's own version numbers do not match upstream Harbor's version numbers, so they are not useful here. We checked the exact code path below directly in
goharbor/harbor's ownmainbranch, commit6084e31(checked on 2026-09-10). It matches what we saw downstream exactly. - docker engine version / docker-compose version: not applicable. This reproduction used the Helm chart on Kubernetes, not the docker-compose install. The bug does not depend on how you install Harbor.
Additional context:
Evidence that the saved configuration is correct. We checked this right after the failed ping above:
$ curl -sk -u "admin:$HARBOR_ADMIN_PASSWORD" "https://${HARBOR_URL}/api/v2.0/ldap/groups/search?groupname=${LDAP_TEST_GROUPNAME}"
# -> returns that group with its ldap_group_dn, confirming the saved config authenticates for realEvidence that the ping handler itself works, when we send the exact same values directly in the request body instead of an empty one:
$ curl -sk -u "admin:$HARBOR_ADMIN_PASSWORD" -X POST "https://${HARBOR_URL}/api/v2.0/ldap/ping" \
-H "Content-Type: application/json" -d "{
\"ldap_url\": \"ldap://${AD_DC_IP}\",
\"ldap_search_dn\": \"${AD_READER_DN}\",
\"ldap_search_password\": \"${AD_READER_PASSWORD}\",
\"ldap_base_dn\": \"CN=Users,${AD_DOMAIN_DN}\",
\"ldap_filter\": \"(objectClass=user)\",
\"ldap_uid\": \"sAMAccountName\",
\"ldap_scope\": 2,
\"ldap_verify_cert\": false
}"
{"success":true}Evidence that "network timeout" is not a real timeout. We point ldap_url at a genuinely unreachable address and keep everything else the same. This request does not fail fast at all. It is still waiting when curl's own 60-second client timeout gives up:
$ time curl -sk -m 60 -u "admin:$HARBOR_ADMIN_PASSWORD" -X POST "https://${HARBOR_URL}/api/v2.0/ldap/ping" \
-H "Content-Type: application/json" -d "{
\"ldap_url\": \"ldap://10.255.255.1\",
\"ldap_search_dn\": \"${AD_READER_DN}\",
\"ldap_search_password\": \"${AD_READER_PASSWORD}\",
\"ldap_base_dn\": \"CN=Users,${AD_DOMAIN_DN}\",
\"ldap_filter\": \"(objectClass=user)\",
\"ldap_uid\": \"sAMAccountName\",
\"ldap_scope\": 2,
\"ldap_verify_cert\": false
}"
curl: (28) Operation timed out after 60003 milliseconds with 0 bytes received
real 1m0.021sA real timeout on this LDAP server takes more than a minute. The empty-body ping fails in under one second. These two numbers do not match.
Evidence that this is also not a credential problem with the wrong label. The saved bind password (ldap_search_password) is a write-only field. It never comes back from GET /api/v2.0/configurations. But if we send a wrong password directly to the ping handler, we do not get "network timeout". We get a different, correct error instead, just as fast:
$ time curl -sk -u "admin:$HARBOR_ADMIN_PASSWORD" -X POST "https://${HARBOR_URL}/api/v2.0/ldap/ping" \
-H "Content-Type: application/json" -d "{
\"ldap_url\": \"ldap://${AD_DC_IP}\",
\"ldap_search_dn\": \"${AD_READER_DN}\",
\"ldap_search_password\": \"definitely-wrong-password\",
\"ldap_base_dn\": \"CN=Users,${AD_DOMAIN_DN}\",
\"ldap_filter\": \"(objectClass=user)\",
\"ldap_uid\": \"sAMAccountName\",
\"ldap_scope\": 2,
\"ldap_verify_cert\": false
}"
{"message":"error: invalid credential"}
real 0m0.676sIf we leave out ldap_search_password completely, and keep every other field explicit, the ping succeeds instead:
$ curl -sk -u "admin:$HARBOR_ADMIN_PASSWORD" -X POST "https://${HARBOR_URL}/api/v2.0/ldap/ping" \
-H "Content-Type: application/json" -d "{
\"ldap_url\": \"ldap://${AD_DC_IP}\",
\"ldap_search_dn\": \"${AD_READER_DN}\",
\"ldap_base_dn\": \"CN=Users,${AD_DOMAIN_DN}\",
\"ldap_filter\": \"(objectClass=user)\",
\"ldap_uid\": \"sAMAccountName\",
\"ldap_scope\": 2,
\"ldap_verify_cert\": false
}"
{"success":true}This success is not because this particular AD account allows an anonymous bind. Harbor's own web UI does exactly this on purpose. pingTestServer() in config-auth.component.ts builds the ping request from every ldap_* field currently loaded in the form. It only includes ldap_search_password if the user just changed it in the form. Otherwise, it deletes that key from the request completely, and lets the backend fill it in. Then it sends that same settings object directly to this.configService.testLDAPServer(settings), the client code for this exact POST /api/v2.0/ldap/ping endpoint. The web UI never sends an empty body. "Test LDAP Server" in the UI works by sending the whole saved configuration again, except the password, not by asking the API to load it.
Root cause. We confirmed this by following the whole request path through the code:
PingLdapis the HTTP handler. It builds itsLdapConfonly from the request body's fields (params.Ldapconf.LdapURL,.LdapBaseDn,.LdapSearchDn,.LdapFilter,.LdapUID,.LdapScope,.LdapVerifyCert). There is no code here that loads anything from the saved system configuration.controller.Pingfills in only one field from the saved configuration. Ifcfg.SearchPasswordis empty, it callsdefaultPassword. This function reads the real savedldap_search_passwordthroughconfig.LDAPConf(ctx). Every other field incfgstays exactly as the request body set it.- So
POST /ldap/ping -d '{}'does not test "the current configuration". It tests a configuration whereldap_url,ldap_base_dn,ldap_search_dn,ldap_filter,ldap_uid,ldap_scope, andldap_verify_certare all Go zero values ("",0,false). Only the password is real. manager.Pingsends this straight toTestConfig. This function callsts.Open()against that emptyldap_url. IfOpen()fails, Harbor checks the error withgoldap.IsErrorWithCode(err, goldap.ErrorNetwork). Harbor maps every error in this class to the same hardcoded message:ErrLDAPServerTimeout="ldap server network timeout". An empty or broken URL fails the connection almost instantly, long before any real network timeout could happen. But Harbor still reports it with this same message.- Closed issue #14389 shows that Harbor added this exact message on purpose, to mean "ldap server not reachable". Using it here, for "the URL field was empty because the request body was empty", is wrong. It is a different problem reported with the wrong, misleading label. It is not a real connectivity problem.
There are two separate problems here. Either one alone would already be worth fixing. First, the documented rule ("empty request loads current configuration") does not work for six of its seven fields. Second, TestConfig reports every network-class connection error with the same fixed "network timeout" message, no matter what the real cause is. This second problem is misleading even outside the empty-body case.
Possible fixes for whoever picks this up: implement the documented full-config fallback in PingLdap/controller.Ping for every field, not just the password, when the request body is empty. This would match the code to the swagger description. Or, at minimum, fix the swagger description to say that only the password loads from the saved configuration. Clients should send the rest of the fields explicitly, the same way Harbor's own web UI already does.
Source: goharbor/harbor