From 6c8c2d1f17600a715ce2a5813004bd484daf56c4 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Thu, 30 Jul 2026 14:26:40 +0800 Subject: [PATCH 1/3] feat: add ldap-auth-advanced plugin (core authentication) Add the ldap-auth-advanced plugin (priority 2541), Kong ldap-auth-advanced parity, core authentication: search-then-bind (AD sAMAccountName shape), service-account or anonymous search bind, user_dn Consumer association, LDAPS/StartTLS, LDAP filter-injection defense, strict 401-vs-500 auth/transport error split, multi-auth compatibility. Bump lua-resty-ldap 0.1.0 -> 0.3.0 (pure-Lua client/protocol/filter; the released client is lazy-connect, so the plugin binds first and classifies a (nil, err) return as a transport failure). Note: under 0.1.0 the ldap-auth plugin's tls_verify was a silent no-op (the library read verify_ldap_host); 0.3.0 fixes the field name, so tls_verify:true configs begin real certificate verification. Also regenerate t/certs/localhost_slapd_cert.pem: the old cert has CN=test.com but no subjectAltName, and nginx's hostname verification does not fall back to CN, so any real tls_verify handshake against it fails with "certificate host mismatch". The new cert (same key, same test CA) adds SAN DNS:test.com, DNS:localhost, IP:127.0.0.1. Group collection/authorization and credential caching/anonymous-consumer follow in separate PRs; docs follow up separately. --- apisix-master-0.rockspec | 2 +- apisix/cli/config.lua | 1 + apisix/plugins/ldap-auth-advanced.lua | 382 +++++++++ ci/pod/docker-compose.plugin.yml | 4 +- ci/pod/openldap/ad.ldif | 102 +++ ci/pod/openldap/enable-anon-bind.sh | 67 ++ conf/config.yaml.example | 1 + t/admin/plugins.t | 3 +- t/certs/localhost_slapd_cert.pem | 43 +- t/plugin/ldap-auth-advanced.t | 1111 +++++++++++++++++++++++++ 10 files changed, 1694 insertions(+), 22 deletions(-) create mode 100644 apisix/plugins/ldap-auth-advanced.lua create mode 100644 ci/pod/openldap/ad.ldif create mode 100755 ci/pod/openldap/enable-anon-bind.sh create mode 100644 t/plugin/ldap-auth-advanced.t diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec index a15b57290fcb..c56dab4141a6 100644 --- a/apisix-master-0.rockspec +++ b/apisix-master-0.rockspec @@ -79,7 +79,7 @@ dependencies = { "net-url = 1.2-1", "xml2lua = 1.6-2", "lua-resty-mediador = 0.1.2-1", - "lua-resty-ldap = 0.1.0-0", + "lua-resty-ldap = 0.3.0-0", "lua-resty-t1k = 1.1.6-0", "brotli-ffi = 0.3-1", "lua-ffi-zlib = 0.6-0", diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index 374e259d8161..98c3ff581218 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -222,6 +222,7 @@ local _M = { "authz-casbin", "authz-casdoor", "wolf-rbac", + "ldap-auth-advanced", "ldap-auth", "hmac-auth", "basic-auth", diff --git a/apisix/plugins/ldap-auth-advanced.lua b/apisix/plugins/ldap-auth-advanced.lua new file mode 100644 index 000000000000..a740385ef2b6 --- /dev/null +++ b/apisix/plugins/ldap-auth-advanced.lua @@ -0,0 +1,382 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local core = require("apisix.core") +local schema_def = require("apisix.schema_def") +local auth_utils = require("apisix.utils.auth") +local consumer_mod = require("apisix.consumer") +local ldap_client = require("resty.ldap.client") +local ldap_protocol = require("resty.ldap.protocol") +local ldap_filter = require("resty.ldap.filter") +local ngx = ngx +local ipairs = ipairs +local type = type +local ngx_decode_base64 = ngx.decode_base64 +local ngx_re_match = ngx.re.match +local str_find = string.find +local str_sub = string.sub +local parse_addr = core.utils.parse_addr + +-- RFC 4512 attribute-description shape: a leading letter, then letters, +-- digits, semicolons (option separators) or hyphens. +local ATTR_PATTERN = "^[A-Za-z][A-Za-z0-9;-]*$" + +local schema = { + type = "object", + title = "work with route or service object", + properties = { + -- connection + ldap_uri = { type = "string" }, -- "host[:port]" + use_ldaps = { type = "boolean", default = false }, + use_starttls = { type = "boolean", default = false }, + ssl_verify = { type = "boolean", default = true }, + timeout = { type = "integer", minimum = 1, maximum = 60000, + default = 3000 }, -- milliseconds + + -- connection pool + keepalive = { type = "boolean", default = true }, + keepalive_timeout = { type = "integer", minimum = 1000, default = 60000 }, + keepalive_pool_size = { type = "integer", minimum = 1, default = 5 }, + keepalive_pool_name = { type = "string" }, + + -- user resolution (search-then-bind) + base_dn = { type = "string" }, -- search root + attribute = { type = "string", -- filter: (attribute=username) + default = "cn", pattern = ATTR_PATTERN }, + bind_dn = { type = "string" }, -- absent => anonymous search + ldap_password = { type = "string" }, + + -- search bounds + size_limit = { type = "integer", minimum = 2, default = 2 }, + time_limit = { type = "integer", minimum = 0, default = 5 }, -- seconds; 0 = server default + + + + -- consumer + consumer_required = { type = "boolean", default = true }, + + -- request handling + header_type = { type = "string", enum = {"ldap", "basic"}, default = "ldap" }, + realm = schema_def.get_realm_schema("ldap"), + + }, + encrypt_fields = {"ldap_password"}, + required = {"ldap_uri", "base_dn"}, +} + +local consumer_schema = { + type = "object", + title = "work with consumer object", + properties = { + user_dn = { type = "string" }, + }, + required = {"user_dn"}, +} + +local plugin_name = "ldap-auth-advanced" + + +local _M = { + version = 0.1, + priority = 2541, + type = 'auth', + name = plugin_name, + schema = schema, + consumer_schema = consumer_schema, +} + +function _M.check_schema(conf, schema_type) + if schema_type == core.schema.TYPE_CONSUMER then + return core.schema.check(consumer_schema, conf) + end + + local ok, err = core.schema.check(schema, conf) + if not ok then + return false, err + end + + if conf.use_ldaps and conf.use_starttls then + return false, "use_ldaps and use_starttls are mutually exclusive" + end + + if conf.bind_dn and not conf.ldap_password then + return false, "ldap_password is required when bind_dn is set" + end + + -- ldap_uri may omit ":port"; the effective port (636 with use_ldaps, + -- else 389) is resolved when the connection is opened. + + return true +end + + + +-- Shared 401 helper for the authentication-failure paths. +local function auth_failed(conf, ctx, reason) + + -- under multi-auth, decline quietly and let the wrapper render the 401 + if auth_utils.is_running_under_multi_auth(ctx) then + return 401 + end + + if reason then + core.log.warn(plugin_name, ": ", reason) + end + core.response.set_header("WWW-Authenticate", + conf.header_type .. " realm=\"" .. conf.realm .. "\"") + return 401, { message = "Authorization required" } +end + + + + +-- Extract username/password from the credential header: the scheme word is +-- conf.header_type ("ldap" or "basic", case-insensitive), the payload is +-- base64("username:password"). +local function extract_credentials(conf, ctx) + -- Proxy-Authorization is checked before Authorization + local header_name = "Proxy-Authorization" + local auth_header = core.request.header(ctx, header_name) + if not auth_header then + header_name = "Authorization" + auth_header = core.request.header(ctx, header_name) + end + if not auth_header then + return nil, nil, "missing authorization header" + end + + local m, err = ngx_re_match(auth_header, + "(?i:" .. conf.header_type .. ")\\s(.+)", "jo") + if err then + return nil, nil, "error matching authorization header: " .. err + end + if not m then + return nil, nil, "invalid authorization header format" + end + + local decoded = ngx_decode_base64(m[1]) + if not decoded then + return nil, nil, "failed to base64-decode authorization header" + end + + -- split on the FIRST colon only: the password may itself contain ':' + local sep = str_find(decoded, ":", 1, true) + if not sep then + return nil, nil, "invalid credential: missing ':' separator" + end + + return str_sub(decoded, 1, sep - 1), str_sub(decoded, sep + 1) +end + + +-- Tell a directory result-code failure (an auth failure -> 401) apart from a +-- socket/TLS/timeout error (an outage -> 500) by the library's error prefixes. +local function is_result_code_failure(err) + if type(err) ~= "string" then + return false + end + return str_sub(err, 1, 18) == "simple bind failed" + or str_sub(err, 1, 13) == "search failed" +end + + + + +-- Build the user_dn lookup map in one pass over the Consumers, cached +-- against the Consumer config version. +local consumer_lrucache = core.lrucache.new({ ttl = 300, count = 512 }) + +local function build_consumer_maps(consumer_conf) + local by_user_dn = {} + for _, node in ipairs(consumer_conf.nodes) do + local auth_conf = node.auth_conf + if auth_conf and auth_conf.user_dn then + by_user_dn[auth_conf.user_dn] = node + end + end + return { by_user_dn = by_user_dn } +end + + + + +-- The LDAP round trip: resolve the user DN and authenticate the user's bind +-- on ONE pinned connection. Returns (nil, nil, user_dn) on success, or +-- (code, body) on failure. The socket is closed on every failure path and +-- released to the pool only on success, so a poisoned socket is never pooled. +local function ldap_resolve(conf, ctx, username, password) + -- The only client-controlled part of the search filter is the escaped + -- username. filter.escape leaves bytes the filter grammar rejects (e.g. + -- invalid UTF-8), and a grammar reject at search time would surface as a + -- 500 -- misclassifying a bad credential as a server fault. Pre-compile + -- the filter so any reject is a clean 401. + local search_filter = "(" .. conf.attribute .. "=" + .. ldap_filter.escape(username) .. ")" + if not ldap_filter.compile(search_filter) then + return auth_failed(conf, ctx, "invalid username") + end + + -- ldap_uri is "host" or "host:port"; when the port is omitted it + -- defaults to 636 under LDAPS, else 389. + local host, port = parse_addr(conf.ldap_uri) + if not port then + port = conf.use_ldaps and 636 or 389 + end + + local client = ldap_client:new(host, port, { + socket_timeout = conf.timeout, + keepalive_timeout = conf.keepalive_timeout, + keepalive_pool_size = conf.keepalive_pool_size, + keepalive_pool_name = conf.keepalive_pool_name, + start_tls = conf.use_starttls, + ldaps = conf.use_ldaps, + ssl_verify = conf.ssl_verify, + }) + + -- Bind before every search: a pooled socket may arrive bound as a + -- previous request's end user. Anonymous bind when bind_dn is unset. + -- The client connects lazily on the first operation, so a socket/TLS + -- failure surfaces here as (nil, err) -- an outage, never auth -- while + -- a directory rejection is (false, err). + local bind_ok, berr + if conf.bind_dn then + bind_ok, berr = client:simple_bind(conf.bind_dn, conf.ldap_password) + else + bind_ok, berr = client:simple_bind("", "") + end + if not bind_ok then + client:close() + if bind_ok == nil then + core.log.error(plugin_name, ": LDAP connect failed: ", berr) + return 500 + end + if is_result_code_failure(berr) then + -- a rejected search bind is a misconfiguration; fail closed + return auth_failed(conf, ctx, "search bind rejected by directory") + end + core.log.error(plugin_name, ": LDAP search bind failed: ", berr) + return 500 + end + + -- Search for the user. size_limit floors at 2 (schema minimum) so a 2nd + -- match is observable. + local entries, serr = client:search( + conf.base_dn, + ldap_protocol.SEARCH_SCOPE_WHOLE_SUBTREE, + ldap_protocol.SEARCH_DEREF_ALIASES_ALWAYS, + conf.size_limit, conf.time_limit, + false, + search_filter) + if entries == false then + client:close() + if is_result_code_failure(serr) then + return auth_failed(conf, ctx, "user search rejected by directory") + end + core.log.error(plugin_name, ": LDAP user search failed: ", serr) + return 500 + end + + -- count SearchResultEntry rows (the library drops SearchResultDone) + local user_dn + local match_count = 0 + for _, entry in ipairs(entries) do + if entry.entry_dn then + match_count = match_count + 1 + user_dn = entry.entry_dn + end + end + if match_count == 0 then + client:close() + return auth_failed(conf, ctx, "user not found") + end + if match_count > 1 then + -- the login attribute is not unique under base_dn: a directory + -- misconfiguration. Fail closed rather than bind an arbitrary entry. + client:close() + return auth_failed(conf, ctx, + "ambiguous user match (>1 entry); check attribute uniqueness") + end + + -- Authenticate: bind as the resolved user. A result-code failure is a + -- wrong password (401); a transport error is an outage (500). + local auth_ok, aerr = client:simple_bind(user_dn, password) + if not auth_ok then + client:close() + if is_result_code_failure(aerr) then + return auth_failed(conf, ctx, "user authentication failed") + end + core.log.error(plugin_name, ": LDAP authentication bind failed: ", aerr) + return 500 + end + + + if conf.keepalive == false then + client:close() + else + client:set_keepalive() + end + + return nil, nil, user_dn +end + + +function _M.rewrite(conf, ctx) + -- Strip any client-supplied X-Authenticated-Groups before any auth work. + core.request.set_header(ctx, "X-Authenticated-Groups", nil) + + local username, password, err = extract_credentials(conf, ctx) + if err then + return auth_failed(conf, ctx, err) + end + + -- A zero-length password would be an RFC 4513 5.1.2 unauthenticated bind, + -- which authenticates anyone whose username resolves. Reject before any + -- bind can be attempted. + if password == "" then + return auth_failed(conf, ctx, "empty password rejected before bind") + end + + if username == "" then + return auth_failed(conf, ctx, "empty username") + end + + local code, body, user_dn = ldap_resolve(conf, ctx, username, password) + if code then + return code, body + end + + -- Associate a Consumer with the authenticated identity, unless + -- consumer_required is false. + if conf.consumer_required ~= false then + local consumer_conf = consumer_mod.consumers_conf(plugin_name) + if not consumer_conf or not consumer_conf.nodes then + return auth_failed(conf, ctx, "consumer_required but no Consumer is configured") + end + + local maps = consumer_lrucache(consumer_conf, consumer_conf.conf_version, + build_consumer_maps, consumer_conf) + + local consumer = maps.by_user_dn[user_dn] + if not consumer then + return auth_failed(conf, ctx, + "no Consumer maps to the authenticated user_dn") + end + + consumer_mod.attach_consumer(ctx, consumer, consumer_conf) + end +end + +return _M diff --git a/ci/pod/docker-compose.plugin.yml b/ci/pod/docker-compose.plugin.yml index 81b2da0df3dc..53d1b6726014 100644 --- a/ci/pod/docker-compose.plugin.yml +++ b/ci/pod/docker-compose.plugin.yml @@ -242,7 +242,7 @@ services: openldap: image: bitnamilegacy/openldap:2.5.8 environment: - - LDAP_ADMIN_USERNAME=amdin + - LDAP_ADMIN_USERNAME=admin - LDAP_ADMIN_PASSWORD=adminpassword - LDAP_USERS=user01,user02 - LDAP_PASSWORDS=password1,password2 @@ -255,6 +255,8 @@ services: - "1636:1636" volumes: - ./t/certs:/certs + - ./ci/pod/openldap/ad.ldif:/ldifs/ad.ldif + - ./ci/pod/openldap/enable-anon-bind.sh:/docker-entrypoint-initdb.d/enable-anon-bind.sh ## Grafana Loki diff --git a/ci/pod/openldap/ad.ldif b/ci/pod/openldap/ad.ldif new file mode 100644 index 000000000000..a782a83a8b02 --- /dev/null +++ b/ci/pod/openldap/ad.ldif @@ -0,0 +1,102 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Self-contained bootstrap tree: a non-empty /ldifs makes bitnami skip its +# default tree and LDAP_USERS bootstrap, so this file rebuilds the base tree +# and the stock user01/user02/readers entries (keeping the existing ldap-auth +# tests passing) alongside the ldap-auth-advanced fixtures. + +dn: dc=example,dc=org +objectClass: dcObject +objectClass: organization +dc: example +o: example + +dn: ou=users,dc=example,dc=org +objectClass: organizationalUnit +ou: users + +dn: cn=user01,ou=users,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: shadowAccount +cn: User1 +cn: user01 +sn: Bar1 +uid: user01 +uidNumber: 1000 +gidNumber: 1000 +homeDirectory: /home/user01 +userPassword: password1 + +dn: cn=user02,ou=users,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: shadowAccount +cn: User2 +cn: user02 +sn: Bar2 +uid: user02 +uidNumber: 1001 +gidNumber: 1001 +homeDirectory: /home/user02 +userPassword: password2 + +dn: cn=readers,ou=users,dc=example,dc=org +objectClass: groupOfNames +cn: readers +member: cn=user01,ou=users,dc=example,dc=org +member: cn=user02,ou=users,dc=example,dc=org + +# AD shape: the RDN is cn ("Jane Doe") but the login attribute is uid (jdoe). +dn: cn=Jane Doe,ou=users,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +cn: Jane Doe +sn: Doe +uid: jdoe +userPassword: janesecret + +# Two entries share uid=dupuser: the ambiguous-match case. +dn: cn=Dup User One,ou=users,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +cn: Dup User One +sn: One +uid: dupuser +userPassword: duppass1 + +dn: cn=Dup User Two,ou=users,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +cn: Dup User Two +sn: Two +uid: dupuser +userPassword: duppass2 + +# Hidden from anonymous searches by an ACL (enable-anon-bind.sh) yet readable +# by any authenticated identity: the tripwire for the bind-state-leak probe. +dn: cn=Secret User,ou=users,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +cn: Secret User +sn: Secret +uid: secretuser +userPassword: secretpass diff --git a/ci/pod/openldap/enable-anon-bind.sh b/ci/pod/openldap/enable-anon-bind.sh new file mode 100755 index 000000000000..15e3733a7958 --- /dev/null +++ b/ci/pod/openldap/enable-anon-bind.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Boot hook (docker-entrypoint-initdb.d). +# +# olcAllows bind_anon_dn: a simple bind with a DN and an EMPTY password +# succeeds at the server (RFC 4513 5.1.2, result: anonymous) -- lets the +# tests prove the plugin itself rejects empty passwords. +# +# The olcAccess rules make the bind identity observable: +# * cn=Secret User: invisible to an anonymous search, readable by any +# authenticated identity (userPassword keeps `auth` so the entry can +# still be bound once found) -- the bind-state-leak tripwire. +# * The catch-all keeps everything else readable, so the ldap-auth +# regression is unaffected. + +set -o errexit +set -o nounset +set -o pipefail + +. /opt/bitnami/scripts/liblog.sh +. /opt/bitnami/scripts/libopenldap.sh + +eval "$(ldap_env)" + +info "Enabling RFC 4513 unauthenticated bind (olcAllows: bind_anon_dn)" + +ldap_start_bg + +ldapmodify -Y EXTERNAL -H "ldapi:///" </dev/null \ + | awk '/^dn:/ { $1=""; sub(/^ /,""); print; exit }')" + +ldapmodify -Y EXTERNAL -H "ldapi:///" <request) { + $block->set_value("request", "GET /t"); + } +}); + +run_tests(); + + +__DATA__ + +=== TEST 1: minimal valid conf (ldap_uri + base_dn) passes +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + }) + ngx.say(ok and "passed" or err) + } + } +--- response_body +passed + + + +=== TEST 2: missing ldap_uri is rejected +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + base_dn = "ou=users,dc=example,dc=org", + }) + ngx.say(ok and "passed" or err) + } + } +--- response_body_like eval +qr/property "ldap_uri" is required/ + + + +=== TEST 3: missing base_dn is rejected +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + }) + ngx.say(ok and "passed" or err) + } + } +--- response_body_like eval +qr/property "base_dn" is required/ + + + +=== TEST 4: use_ldaps and use_starttls are mutually exclusive +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + use_ldaps = true, + use_starttls = true, + }) + ngx.say(ok and "passed" or err) + } + } +--- response_body +use_ldaps and use_starttls are mutually exclusive + + + +=== TEST 5: use_ldaps alone (port-less uri) passes +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "ldap.example.org", + base_dn = "ou=users,dc=example,dc=org", + use_ldaps = true, + }) + ngx.say(ok and "passed" or err) + } + } +--- response_body +passed + + + +=== TEST 6: bind_dn set without ldap_password is rejected +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + bind_dn = "cn=admin,dc=example,dc=org", + }) + ngx.say(ok and "passed" or err) + } + } +--- response_body +ldap_password is required when bind_dn is set + + + +=== TEST 7: bind_dn with ldap_password passes +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + bind_dn = "cn=admin,dc=example,dc=org", + ldap_password = "adminpassword", + }) + ngx.say(ok and "passed" or err) + } + } +--- response_body +passed + + + +=== TEST 8: attribute with a bad pattern is rejected +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + attribute = "1abc", + }) + ngx.say(ok and "passed" or "rejected") + } + } +--- response_body +rejected + + + +=== TEST 9: attribute containing a space is rejected +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + attribute = "a b", + }) + ngx.say(ok and "passed" or "rejected") + } + } +--- response_body +rejected + + + +=== TEST 10: valid attribute (uid) passes +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + attribute = "uid", + }) + ngx.say(ok and "passed" or err) + } + } +--- response_body +passed + + + +=== TEST 11: size_limit below the floor of 2 is rejected +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + size_limit = 1, + }) + ngx.say(ok and "passed" or "rejected") + } + } +--- response_body +rejected + + + +=== TEST 12: consumer schema with user_dn passes +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema( + { user_dn = "cn=user01,ou=users,dc=example,dc=org" }, + core.schema.TYPE_CONSUMER) + ngx.say(ok and "passed" or err) + } + } +--- response_body +passed + + + +=== TEST 13: empty consumer schema is rejected +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ok, err = plugin.check_schema({}, core.schema.TYPE_CONSUMER) + ngx.say(ok and "passed" or "rejected") + } + } +--- response_body +rejected + + + +=== TEST 14: set up a route protected by ldap-auth-advanced (live LDAP) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 15: no credential header -> 401 with WWW-Authenticate ldap realm +--- request +GET /hello +--- error_code: 401 +--- response_headers +WWW-Authenticate: ldap realm="ldap" + + + +=== TEST 16: malformed base64 payload ("aca_a" does not decode) -> 401 +--- request +GET /hello +--- more_headers +Authorization: ldap aca_a +--- error_code: 401 + + + +=== TEST 17: base64 payload without a ':' separator (decodes to "useronly") -> 401 +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcm9ubHk= +--- error_code: 401 + + + +=== TEST 18: empty password (payload decodes to "user01:") rejected before any bind (RFC 4513 5.1.2 unauthenticated bind) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOg== +--- error_code: 401 +--- grep_error_log eval +qr/empty password/ +--- grep_error_log_out +empty password + + + +=== TEST 19: scheme word parsed case-insensitively (uppercase), empty password still rejected (creds: user01:) +--- request +GET /hello +--- more_headers +Authorization: LDAP dXNlcjAxOg== +--- error_code: 401 +--- grep_error_log eval +qr/empty password/ +--- grep_error_log_out +empty password + + + +=== TEST 20: scheme word parsed case-insensitively (mixed case), empty password still rejected (creds: user01:) +--- request +GET /hello +--- more_headers +Authorization: lDaP dXNlcjAxOg== +--- error_code: 401 +--- grep_error_log eval +qr/empty password/ +--- grep_error_log_out +empty password + + + +=== TEST 21: set up a route with header_type basic +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "header_type": "basic" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 22: header_type basic emits a Basic-scheme WWW-Authenticate +--- request +GET /hello +--- error_code: 401 +--- response_headers +WWW-Authenticate: basic realm="ldap" + + + +=== TEST 23: inbound X-Authenticated-Groups is cleared before any auth work, on every path +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local plugin = require("apisix.plugins.ldap-auth-advanced") + local ctx = { var = {} } + local before = core.request.header(ctx, "X-Authenticated-Groups") or "nil" + -- no credential header -> auth_failed path; the strip must still happen + plugin.rewrite({ header_type = "ldap", realm = "ldap" }, ctx) + local after = core.request.header(ctx, "X-Authenticated-Groups") or "nil" + ngx.say("before: ", before) + ngx.say("after: ", after) + } + } +--- more_headers +X-Authenticated-Groups: injected +--- response_body +before: injected +after: nil + + + +=== TEST 24: set up a route with multi-auth wrapping ldap-auth-advanced and basic-auth +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "multi-auth": { + "auth_plugins": [ + { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid" + } + }, + { + "basic-auth": {} + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 25: under multi-auth ldap-auth-advanced declines quietly (creds: user01:) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOg== +--- error_code: 401 +--- response_body +{"message":"Authorization Failed"} +--- response_headers +WWW-Authenticate: Basic realm="basic" +--- no_error_log +empty password + + + +=== TEST 26: set up the plain search-then-bind route (uid attribute) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "keepalive_pool_size": 4 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 27: consumer_required (default true) with NO matching Consumer -> 401 (fails closed) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 401 +--- grep_error_log eval +qr/no Consumer is configured/ +--- grep_error_log_out +no Consumer is configured + + + +=== TEST 28: create the ldap-auth-advanced Consumers (user_dn associations) +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local t = require("lib.test_admin").test + local users = { + { name = "ldapadvuser01", dn = "cn=user01,ou=users,dc=example,dc=org" }, + { name = "ldapadvuser02", dn = "cn=user02,ou=users,dc=example,dc=org" }, + { name = "ldapadvjdoe", dn = "cn=Jane Doe,ou=users,dc=example,dc=org" }, + { name = "ldapadvsecret", dn = "cn=Secret User,ou=users,dc=example,dc=org" }, + } + for _, u in ipairs(users) do + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + core.json.encode({ + username = u.name, + plugins = { + ["ldap-auth-advanced"] = { user_dn = u.dn }, + }, + })) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 29: happy path -- uid=user01 (cn=user01) matches Consumer ldapadvuser01 (200 + attached) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 200 +--- response_body +hello world +--- error_log +find consumer ldapadvuser01 + + + +=== TEST 30: AD-shape happy path -- uid=jdoe (cn=Jane Doe) matches Consumer ldapadvjdoe (200) (creds: jdoe:janesecret) +--- request +GET /hello +--- more_headers +Authorization: ldap amRvZTpqYW5lc2VjcmV0 +--- error_code: 200 +--- response_body +hello world +--- error_log +find consumer ldapadvjdoe + + + +=== TEST 31: wrong password -> 401 (a result-code failure, not a transport error) (creds: user01:wrong) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOndyb25n +--- error_code: 401 + + + +=== TEST 32: unknown user -> 401 (the user search returns 0 entries) (creds: nouser:x) +--- request +GET /hello +--- more_headers +Authorization: ldap bm91c2VyOng= +--- error_code: 401 + + + +=== TEST 33: ambiguous match (two uid=dupuser entries) -> 401 + "ambiguous" warn (creds: dupuser:duppass1) +--- request +GET /hello +--- more_headers +Authorization: ldap ZHVwdXNlcjpkdXBwYXNzMQ== +--- error_code: 401 +--- grep_error_log eval +qr/ambiguous user match/ +--- grep_error_log_out +ambiguous user match + + + +=== TEST 34: set up the search-then-bind route with consumer_required=false +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "consumer_required": false + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 35: consumer_required=false -> user01 authenticated, no Consumer attached (200) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 200 +--- response_body +hello world +--- no_error_log +find consumer + + + +=== TEST 36: point the route at a dead LDAP port (transport-error case) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1390", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "timeout": 1000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 37: LDAP unreachable -> 500 (a transport error is never an auth failure) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 500 +--- error_log +LDAP connect failed + + + +=== TEST 38: set up an LDAPS route on 1636 (use_ldaps, ssl_verify off) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1636", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "use_ldaps": true, + "ssl_verify": false + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 39: happy path over LDAPS (200) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 200 +--- response_body +hello world + + + +=== TEST 40: set up a StartTLS route on 1389 (use_starttls, ssl_verify off) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "use_starttls": true, + "ssl_verify": false + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 41: happy path over StartTLS (200) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 200 +--- response_body +hello world + + + +=== TEST 42: restore the plain search-then-bind route for the injection suite +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 43: filter-injection usernames each 401 (none widens the search) +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local port = ngx.var.server_port + -- RFC 4515 s3 specials plus a NUL byte. Once escaped, every payload + -- is a literal that matches NO user, so each 401s via the "user not + -- found" path. The log assertions below are the real proof of + -- escaping: an UNescaped "*" would build the presence filter (uid=*), + -- match >1 entry, and 401 via the "ambiguous user match" path instead + -- -- so we assert "user not found" IS logged and "ambiguous user + -- match" is NOT (status 401 alone cannot tell the two paths apart). + local injections = { "*", "*)(objectClass=*", "(", ")", "\\", "\0" } + local all_401 = true + local statuses = {} + for _, u in ipairs(injections) do + local hc = http.new() + local cred = ngx.encode_base64(u .. ":x") + local res, err = hc:request_uri( + "http://127.0.0.1:" .. port .. "/hello", + { headers = { ["Authorization"] = "ldap " .. cred } }) + local st = res and res.status or ("ERR:" .. tostring(err)) + statuses[#statuses + 1] = tostring(st) + if st ~= 401 then all_401 = false end + end + ngx.say("all_401: ", tostring(all_401)) + ngx.say("statuses: ", table.concat(statuses, ",")) + } + } +--- response_body +all_401: true +statuses: 401,401,401,401,401,401 +--- error_log +user not found +--- no_error_log +ambiguous user match + + + +=== TEST 44: username with an invalid UTF-8 byte -> clean 401, never a 500 +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local port = ngx.var.server_port + -- 0xFF is a lone high byte: never valid UTF-8. filter.escape leaves it + -- untouched, so if it reached the search the library's filter grammar would + -- reject it as a "syntax error", which the plugin's non-result-code + -- branch turns into HTTP 500. A malformed username is a bad credential + -- (a client error), so it must be rejected up front as a clean 401 -- + -- never surfaced as a server-side 500. + local hc = http.new() + local cred = ngx.encode_base64(string.char(0xff) .. ":x") + local res, err = hc:request_uri( + "http://127.0.0.1:" .. port .. "/hello", + { headers = { ["Authorization"] = "ldap " .. cred } }) + ngx.say("status: ", res and res.status or ("ERR:" .. tostring(err))) + } + } +--- response_body +status: 401 +--- error_log +invalid username +--- no_error_log +LDAP user search failed + + + +=== TEST 45: well-formed multibyte UTF-8 username reaches the search and 401s as not-found +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local port = ngx.var.server_port + -- "h" + U+00E9 (e-acute, bytes 0xC3 0xA9) + "llo": valid 2-byte UTF-8. + -- It must pass the encoding check and reach the search, where it + -- matches no user -> the "user not found" 401 path. This proves valid + -- multibyte input is NOT rejected as bad encoding (only invalid byte + -- sequences are). + local hc = http.new() + local username = "h" .. string.char(0xc3, 0xa9) .. "llo" + local cred = ngx.encode_base64(username .. ":x") + local res, err = hc:request_uri( + "http://127.0.0.1:" .. port .. "/hello", + { headers = { ["Authorization"] = "ldap " .. cred } }) + ngx.say("status: ", res and res.status or ("ERR:" .. tostring(err))) + } + } +--- response_body +status: 401 +--- error_log +user not found +--- no_error_log +invalid username + + + +=== TEST 46: username with a grammar-reserved ASCII byte (trailing '~') -> clean 401, never a 500 +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local port = ngx.var.server_port + -- "admin~" is valid ASCII (it passes any UTF-8 check) but filter.escape + -- leaves '~' untouched and the library's filter grammar rejects a trailing + -- '~' as a "syntax error". Before the compile pre-check this reached the + -- search and the non-result-code branch turned that "syntax error" into + -- HTTP 500. A malformed username is a bad credential (a client error), + -- so it must be rejected up front as a clean 401 with "invalid username" + -- -- never surfaced as a 500 and never logged as an LDAP search failure. + local hc = http.new() + local cred = ngx.encode_base64("admin~:x") + local res, err = hc:request_uri( + "http://127.0.0.1:" .. port .. "/hello", + { headers = { ["Authorization"] = "ldap " .. cred } }) + ngx.say("status: ", res and res.status or ("ERR:" .. tostring(err))) + } + } +--- response_body +status: 401 +--- error_log +invalid username +--- no_error_log +LDAP user search failed + + + +=== TEST 47: set up the three concurrent-probe routes (churn + anon-secret + svc-secret) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- route 1 (/hello): bind_dn UNSET, the pool-churn route. + local code = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "plugins": { "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "keepalive_pool_size": 4 + } }, + "upstream": { "nodes": { "127.0.0.1:1980": 1 }, "type": "roundrobin" }, + "uri": "/hello" + }]]) + -- route 2 (/uri): bind_dn UNSET -- searches anonymously (Route A). + local code2 = t('/apisix/admin/routes/2', ngx.HTTP_PUT, [[{ + "plugins": { "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "keepalive_pool_size": 4 + } }, + "upstream": { "nodes": { "127.0.0.1:1980": 1 }, "type": "roundrobin" }, + "uri": "/uri" + }]]) + -- route 3 (/hello1): bind_dn SET -- searches as the service account (Route B). + local code3 = t('/apisix/admin/routes/3', ngx.HTTP_PUT, [[{ + "plugins": { "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "bind_dn": "cn=admin,dc=example,dc=org", + "ldap_password": "adminpassword", + "keepalive_pool_size": 4 + } }, + "upstream": { "nodes": { "127.0.0.1:1980": 1 }, "type": "roundrobin" }, + "uri": "/hello1" + }]]) + local ok = code < 300 and code2 < 300 and code3 < 300 + ngx.say(ok and "passed" or "failed") + } + } +--- response_body +passed + + + +=== TEST 48: CONCURRENT bind-state-leak probe: anon re-bind must not leak +--- config + location /probe { + content_by_lua_block { + local http = require("resty.http") + local args = ngx.req.get_uri_args() + local hc = http.new() + local cred = ngx.encode_base64(args.u .. ":" .. args.p) + local res, err = hc:request_uri( + "http://127.0.0.1:" .. ngx.var.server_port .. args.path, + { headers = { ["Authorization"] = "ldap " .. cred } }) + ngx.print(res and tostring(res.status) or ("ERR:" .. tostring(err))) + } + } + location /t { + content_by_lua_block { + -- Phase 1: CONCURRENTLY authenticate several end users on the + -- bind_dn-UNSET churn route so multiple pooled sockets end up + -- bound as DIFFERENT end users. + -- keepalive_pool_size=4 (>1) and all routes share pool 127.0.0.1:1389. + local churn = { + {u="user01", p="password1"}, {u="user02", p="password2"}, + {u="jdoe", p="janesecret"}, + } + local reqs = {} + for i = 1, 9 do + local c = churn[((i - 1) % 3) + 1] + reqs[i] = { "/probe", { args = { path = "/hello", u = c.u, p = c.p } } } + end + -- ngx.thread.spawn drives the concurrent capture_multi wave. + local th = assert(ngx.thread.spawn(function() + return { ngx.location.capture_multi(reqs) } + end)) + local _, churn_resps = ngx.thread.wait(th) + local churn_all_200 = true + for _, r in ipairs(churn_resps) do + if r.body ~= "200" then churn_all_200 = false end + end + + -- Phase 2: Route A (bind_dn UNSET) authenticating `secretuser`, which + -- is hidden from an anonymous search. Its anonymous simple_bind("","") + -- MUST reset any reused (end-user-bound) socket to anonymous -> the + -- search finds nothing -> 401. A leaked end-user bind would resolve + -- secretuser and 200. + local a = {} + for i = 1, 5 do + a[i] = { "/probe", { args = { path = "/uri", u = "secretuser", p = "secretpass" } } } + end + local ares = { ngx.location.capture_multi(a) } + local routeA_all_401 = true + for _, r in ipairs(ares) do + if r.body ~= "401" then routeA_all_401 = false end + end + + -- Phase 3: Route B (bind_dn SET) authenticating `secretuser`. Its + -- search bind as the service account resolves secretuser -> 200. + local b = {} + for i = 1, 5 do + b[i] = { "/probe", { args = { path = "/hello1", u = "secretuser", p = "secretpass" } } } + end + local bres = { ngx.location.capture_multi(b) } + local routeB_all_200 = true + for _, r in ipairs(bres) do + if r.body ~= "200" then routeB_all_200 = false end + end + + ngx.say("churn_all_200: ", tostring(churn_all_200)) + ngx.say("routeA_anon_all_401: ", tostring(routeA_all_401)) + ngx.say("routeB_svc_all_200: ", tostring(routeB_all_200)) + } + } +--- timeout: 15 +--- response_body +churn_all_200: true +routeA_anon_all_401: true +routeB_svc_all_200: true From c750f293a094d5258184f7bef71e861850100f6a Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 31 Jul 2026 12:30:29 +0800 Subject: [PATCH 2/3] fix(ldap-auth-advanced): address first-round review - strip client-supplied X-Consumer-Username/X-Consumer-Custom-ID/ X-Credential-Identifier (with X-Authenticated-Groups) before any auth work, so consumer_required=false cannot pass spoofed identity upstream - accept RFC 4512 numeric-OID attribute types and tighten ;option validation in the attribute schema pattern - fall back to Authorization when Proxy-Authorization is present but does not parse into credentials for header_type - classify directory failures by operation and result code: 401 only for invalidCredentials on the user bind plus the not-found/ambiguous cases (including sizeLimitExceeded, the >size_limit ambiguity); service-bind rejections and search operational failures are 500 - look up Consumers via consumer.find_consumer() so a secret-ref user_dn resolves (and unresolved references fail closed) - register ldap-auth-advanced user_dn in plugin_unique_key_attrs so the Admin API rejects duplicate user_dn values --- apisix/consumer.lua | 1 + apisix/plugins/ldap-auth-advanced.lua | 118 +++---- t/admin/consumers.t | 75 ++++- t/plugin/ldap-auth-advanced.t | 458 +++++++++++++++++++++++++- 4 files changed, 588 insertions(+), 64 deletions(-) diff --git a/apisix/consumer.lua b/apisix/consumer.lua index e8877495f1a9..c13d3a73c4b1 100644 --- a/apisix/consumer.lua +++ b/apisix/consumer.lua @@ -303,6 +303,7 @@ local plugin_unique_key_attrs = { ["jwt-auth"] = "key", ["hmac-auth"] = "key_id", ["ldap-auth"] = "user_dn", + ["ldap-auth-advanced"] = "user_dn", } diff --git a/apisix/plugins/ldap-auth-advanced.lua b/apisix/plugins/ldap-auth-advanced.lua index a740385ef2b6..c87d2b3142d9 100644 --- a/apisix/plugins/ldap-auth-advanced.lua +++ b/apisix/plugins/ldap-auth-advanced.lua @@ -30,9 +30,12 @@ local str_find = string.find local str_sub = string.sub local parse_addr = core.utils.parse_addr --- RFC 4512 attribute-description shape: a leading letter, then letters, --- digits, semicolons (option separators) or hyphens. -local ATTR_PATTERN = "^[A-Za-z][A-Za-z0-9;-]*$" +-- RFC 4512 attribute-description: a descriptor ("cn", "sAMAccountName") or a +-- numeric OID ("1.2.840.113556.1.4.656"), either optionally followed by +-- ";option" suffixes ("cn;lang-en", "1.2.840.113556.1.4.656;binary"). +local ATTR_PATTERN = "^(?:[A-Za-z][A-Za-z0-9-]*" + .. "|(?:0|[1-9][0-9]*)(?:\\.(?:0|[1-9][0-9]*))+)" + .. "(?:;[A-Za-z0-9-]+)*$" local schema = { type = "object", @@ -143,23 +146,12 @@ end --- Extract username/password from the credential header: the scheme word is --- conf.header_type ("ldap" or "basic", case-insensitive), the payload is +-- Parse one credential header value: the scheme word is conf.header_type +-- ("ldap" or "basic", case-insensitive), the payload is -- base64("username:password"). -local function extract_credentials(conf, ctx) - -- Proxy-Authorization is checked before Authorization - local header_name = "Proxy-Authorization" - local auth_header = core.request.header(ctx, header_name) - if not auth_header then - header_name = "Authorization" - auth_header = core.request.header(ctx, header_name) - end - if not auth_header then - return nil, nil, "missing authorization header" - end - +local function parse_credential_header(conf, auth_header) local m, err = ngx_re_match(auth_header, - "(?i:" .. conf.header_type .. ")\\s(.+)", "jo") + "^(?i:" .. conf.header_type .. ")\\s+(.+)", "jo") if err then return nil, nil, "error matching authorization header: " .. err end @@ -182,32 +174,43 @@ local function extract_credentials(conf, ctx) end --- Tell a directory result-code failure (an auth failure -> 401) apart from a --- socket/TLS/timeout error (an outage -> 500) by the library's error prefixes. -local function is_result_code_failure(err) - if type(err) ~= "string" then - return false +-- Proxy-Authorization takes priority, but only when it parses into +-- credentials for conf.header_type: a forward proxy may spend that header +-- on its own credentials (e.g. "Basic ...") while the end user's ride in +-- Authorization, so its mere presence must not mask a usable Authorization. +local function extract_credentials(conf, ctx) + local proxy_err + local proxy_header = core.request.header(ctx, "Proxy-Authorization") + if proxy_header then + local username, password + username, password, proxy_err = parse_credential_header(conf, proxy_header) + if username then + return username, password + end end - return str_sub(err, 1, 18) == "simple bind failed" - or str_sub(err, 1, 13) == "search failed" -end + local auth_header = core.request.header(ctx, "Authorization") + if not auth_header then + return nil, nil, proxy_err or "missing authorization header" + end + return parse_credential_header(conf, auth_header) +end --- Build the user_dn lookup map in one pass over the Consumers, cached --- against the Consumer config version. -local consumer_lrucache = core.lrucache.new({ ttl = 300, count = 512 }) +-- resty.ldap reports a directory result-code failure as +-- " failed, error: , details: "; anything +-- else is a socket/TLS/timeout error or a protocol violation. Match against +-- the library's own message table so the strings cannot drift from it. +local RESULT_INVALID_CREDENTIALS = ldap_protocol.ERROR_MSG[49] +local RESULT_SIZE_LIMIT_EXCEEDED = ldap_protocol.ERROR_MSG[4] -local function build_consumer_maps(consumer_conf) - local by_user_dn = {} - for _, node in ipairs(consumer_conf.nodes) do - local auth_conf = node.auth_conf - if auth_conf and auth_conf.user_dn then - by_user_dn[auth_conf.user_dn] = node - end +local function is_result_code(err, op, result_msg) + if type(err) ~= "string" then + return false end - return { by_user_dn = by_user_dn } + local prefix = op .. " failed, error: " .. result_msg .. ", details:" + return str_sub(err, 1, #prefix) == prefix end @@ -263,10 +266,8 @@ local function ldap_resolve(conf, ctx, username, password) core.log.error(plugin_name, ": LDAP connect failed: ", berr) return 500 end - if is_result_code_failure(berr) then - -- a rejected search bind is a misconfiguration; fail closed - return auth_failed(conf, ctx, "search bind rejected by directory") - end + -- a rejected search bind (e.g. a rotated service-account password) + -- is a misconfiguration, never the client's auth failure core.log.error(plugin_name, ": LDAP search bind failed: ", berr) return 500 end @@ -282,8 +283,12 @@ local function ldap_resolve(conf, ctx, username, password) search_filter) if entries == false then client:close() - if is_result_code_failure(serr) then - return auth_failed(conf, ctx, "user search rejected by directory") + if is_result_code(serr, "search", RESULT_SIZE_LIMIT_EXCEEDED) then + -- more than size_limit entries matched the login attribute: the + -- same ambiguity as match_count > 1 below; fail closed + return auth_failed(conf, ctx, + "ambiguous user match (size limit exceeded); " + .. "check attribute uniqueness") end core.log.error(plugin_name, ": LDAP user search failed: ", serr) return 500 @@ -310,12 +315,13 @@ local function ldap_resolve(conf, ctx, username, password) "ambiguous user match (>1 entry); check attribute uniqueness") end - -- Authenticate: bind as the resolved user. A result-code failure is a - -- wrong password (401); a transport error is an outage (500). + -- Authenticate: bind as the resolved user. invalidCredentials is a wrong + -- password (401); any other result code (busy, unavailable, ...) or a + -- transport error is an outage (500). local auth_ok, aerr = client:simple_bind(user_dn, password) if not auth_ok then client:close() - if is_result_code_failure(aerr) then + if is_result_code(aerr, "simple bind", RESULT_INVALID_CREDENTIALS) then return auth_failed(conf, ctx, "user authentication failed") end core.log.error(plugin_name, ": LDAP authentication bind failed: ", aerr) @@ -334,8 +340,13 @@ end function _M.rewrite(conf, ctx) - -- Strip any client-supplied X-Authenticated-Groups before any auth work. + -- Strip the client-supplied identity headers before any auth work: only + -- attach_consumer() may set them, and with consumer_required=false it + -- never runs, so an inbound value would otherwise pass through untouched. core.request.set_header(ctx, "X-Authenticated-Groups", nil) + core.request.set_header(ctx, "X-Consumer-Username", nil) + core.request.set_header(ctx, "X-Credential-Identifier", nil) + core.request.set_header(ctx, "X-Consumer-Custom-ID", nil) local username, password, err = extract_credentials(conf, ctx) if err then @@ -359,17 +370,14 @@ function _M.rewrite(conf, ctx) end -- Associate a Consumer with the authenticated identity, unless - -- consumer_required is false. + -- consumer_required is false. find_consumer() resolves secret references + -- in the Consumer's user_dn and skips unresolved ones fail-closed. if conf.consumer_required ~= false then - local consumer_conf = consumer_mod.consumers_conf(plugin_name) - if not consumer_conf or not consumer_conf.nodes then + local consumer, consumer_conf, err = + consumer_mod.find_consumer(plugin_name, "user_dn", user_dn) + if err then return auth_failed(conf, ctx, "consumer_required but no Consumer is configured") end - - local maps = consumer_lrucache(consumer_conf, consumer_conf.conf_version, - build_consumer_maps, consumer_conf) - - local consumer = maps.by_user_dn[user_dn] if not consumer then return auth_failed(conf, ctx, "no Consumer maps to the authenticated user_dn") diff --git a/t/admin/consumers.t b/t/admin/consumers.t index a30a77cc5f78..fd958885d302 100644 --- a/t/admin/consumers.t +++ b/t/admin/consumers.t @@ -702,12 +702,83 @@ GET /t -=== TEST 21: clean up consumers +=== TEST 21: add consumer adv_case_a with ldap-auth-advanced user_dn --- config location /t { content_by_lua_block { local t = require("lib.test_admin").test - for _, name in ipairs({"case_a", "case_b", "case_c", "enc_case_a", "ldap_case_a"}) do + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "adv_case_a", + "plugins": { + "ldap-auth-advanced": { + "user_dn": "cn=adv-duplicate-check,ou=users,dc=example,dc=org" + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 22: add consumer adv_case_b with the same ldap-auth-advanced user_dn, should fail +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- the duplicate check runs against the locally synced consumer + -- data, wait until the watcher catches up with the previous write + local find_consumer = require("apisix.consumer").find_consumer + for _ = 1, 100 do + if find_consumer("ldap-auth-advanced", "user_dn", + "cn=adv-duplicate-check,ou=users,dc=example,dc=org") then + break + end + ngx.sleep(0.05) + end + + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "adv_case_b", + "plugins": { + "ldap-auth-advanced": { + "user_dn": "cn=adv-duplicate-check,ou=users,dc=example,dc=org" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate user_dn of plugin ldap-auth-advanced found with consumer: adv_case_a"} + + + +=== TEST 23: clean up consumers +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + for _, name in ipairs({"case_a", "case_b", "case_c", "enc_case_a", "ldap_case_a", + "adv_case_a"}) do local code, body = t('/apisix/admin/consumers/' .. name, ngx.HTTP_DELETE) if code >= 300 then ngx.say("failed to delete consumer ", name, ": ", body) diff --git a/t/plugin/ldap-auth-advanced.t b/t/plugin/ldap-auth-advanced.t index 7bc992256fff..d69e5920709a 100644 --- a/t/plugin/ldap-auth-advanced.t +++ b/t/plugin/ldap-auth-advanced.t @@ -406,26 +406,39 @@ WWW-Authenticate: basic realm="ldap" -=== TEST 23: inbound X-Authenticated-Groups is cleared before any auth work, on every path +=== TEST 23: inbound identity headers are cleared before any auth work, on every path --- config location /t { content_by_lua_block { local core = require("apisix.core") local plugin = require("apisix.plugins.ldap-auth-advanced") + local headers = { + "X-Authenticated-Groups", "X-Consumer-Username", + "X-Credential-Identifier", "X-Consumer-Custom-ID", + } local ctx = { var = {} } - local before = core.request.header(ctx, "X-Authenticated-Groups") or "nil" + local before = {} + for i, h in ipairs(headers) do + before[i] = core.request.header(ctx, h) or "nil" + end -- no credential header -> auth_failed path; the strip must still happen plugin.rewrite({ header_type = "ldap", realm = "ldap" }, ctx) - local after = core.request.header(ctx, "X-Authenticated-Groups") or "nil" - ngx.say("before: ", before) - ngx.say("after: ", after) + local after = {} + for i, h in ipairs(headers) do + after[i] = core.request.header(ctx, h) or "nil" + end + ngx.say("before: ", table.concat(before, " ")) + ngx.say("after: ", table.concat(after, " ")) } } --- more_headers X-Authenticated-Groups: injected +X-Consumer-Username: spoofed-user +X-Credential-Identifier: spoofed-cred +X-Consumer-Custom-ID: spoofed-id --- response_body -before: injected -after: nil +before: injected spoofed-user spoofed-cred spoofed-id +after: nil nil nil nil @@ -1109,3 +1122,434 @@ passed churn_all_200: true routeA_anon_all_401: true routeB_svc_all_200: true + + + +=== TEST 49: swap in a Consumer whose user_dn is an $ENV:// secret reference +--- main_config +env ADV_LDAP_USER_DN=cn=user02,ou=users,dc=example,dc=org; +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers/ldapadvuser02', ngx.HTTP_DELETE) + if code >= 300 then + ngx.status = code + return ngx.say(body) + end + + code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "ldapadvenvref", + "plugins": { + "ldap-auth-advanced": { + "user_dn": "$ENV://ADV_LDAP_USER_DN" + } + } + }]] + ) + if code >= 300 then + ngx.status = code + return ngx.say(body) + end + + -- wait until the watcher has synced BOTH writes: the resolved + -- env-ref consumer must be the one the runtime map returns + local find_consumer = require("apisix.consumer").find_consumer + for _ = 1, 100 do + local consumer = find_consumer("ldap-auth-advanced", "user_dn", + "cn=user02,ou=users,dc=example,dc=org") + if consumer and consumer.consumer_name == "ldapadvenvref" then + break + end + ngx.sleep(0.05) + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 50: env-ref user_dn resolves and matches the Consumer (200) (creds: user02:password2) +--- main_config +env ADV_LDAP_USER_DN=cn=user02,ou=users,dc=example,dc=org; +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAyOnBhc3N3b3JkMg== +--- error_code: 200 +--- response_body +hello world +--- error_log +find consumer ldapadvenvref + + + +=== TEST 51: set the /uri header-printing route to consumer_required=false +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/2', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "consumer_required": false + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/uri" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 52: spoofed identity headers never reach the upstream without a Consumer (creds: user01:password1) +--- request +GET /uri +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +X-Consumer-Username: spoofed-user +X-Credential-Identifier: spoofed-cred +X-Consumer-Custom-ID: spoofed-id +X-Authenticated-Groups: spoofed-groups +--- error_code: 200 +--- response_body +uri: /uri +authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +host: localhost +x-real-ip: 127.0.0.1 + + + +=== TEST 53: RFC 4512 attribute forms are accepted (descriptor, OID, options) +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local cases = { + "sAMAccountName", + "cn;lang-en", + "1.2.840.113556.1.4.656", + "0.9.2342.19200300.100.1.1;binary", + } + for _, attr in ipairs(cases) do + local ok = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + attribute = attr, + }) + ngx.say(attr, ": ", ok and "passed" or "rejected") + end + } + } +--- response_body +sAMAccountName: passed +cn;lang-en: passed +1.2.840.113556.1.4.656: passed +0.9.2342.19200300.100.1.1;binary: passed + + + +=== TEST 54: malformed attribute forms are rejected +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ldap-auth-advanced") + local cases = { + "1", -- a bare number is neither a descriptor nor an OID + "1.2.", -- trailing dot + "01.2", -- leading zero in an arc + "cn;", -- empty option + ";binary", -- missing attribute type + } + for _, attr in ipairs(cases) do + local ok = plugin.check_schema({ + ldap_uri = "127.0.0.1:1389", + base_dn = "ou=users,dc=example,dc=org", + attribute = attr, + }) + ngx.say(attr, ": ", ok and "passed" or "rejected") + end + } + } +--- response_body +1: rejected +1.2.: rejected +01.2: rejected +cn;: rejected +;binary: rejected + + + +=== TEST 55: set up the search-then-bind route with the uid attribute's numeric OID +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "0.9.2342.19200300.100.1.1" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 56: OID attribute resolves the user end-to-end (200) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 200 +--- response_body +hello world +--- error_log +find consumer ldapadvuser01 + + + +=== TEST 57: a proxy's own Basic Proxy-Authorization must not mask a valid Authorization +--- request +GET /hello +--- more_headers +Proxy-Authorization: Basic cHJveHk6aHVudGVyMg== +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 200 +--- response_body +hello world +--- error_log +find consumer ldapadvuser01 + + + +=== TEST 58: Proxy-Authorization alone carries the credential (200) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Proxy-Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 200 +--- response_body +hello world +--- error_log +find consumer ldapadvuser01 + + + +=== TEST 59: when both headers parse, Proxy-Authorization wins (proxy: user01, auth: jdoe) +--- request +GET /hello +--- more_headers +Proxy-Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +Authorization: ldap amRvZTpqYW5lc2VjcmV0 +--- error_code: 200 +--- response_body +hello world +--- error_log +find consumer ldapadvuser01 + + + +=== TEST 60: undecodable Proxy-Authorization payload falls back to Authorization +--- request +GET /hello +--- more_headers +Proxy-Authorization: ldap !!!not-base64!!! +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 200 +--- response_body +hello world +--- error_log +find consumer ldapadvuser01 + + + +=== TEST 61: unusable Proxy-Authorization with no Authorization still 401s +--- request +GET /hello +--- more_headers +Proxy-Authorization: Basic cHJveHk6aHVudGVyMg== +--- error_code: 401 + + + +=== TEST 62: set up a route whose service-account password is wrong +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "uid", + "bind_dn": "cn=admin,dc=example,dc=org", + "ldap_password": "rotated-away" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 63: rejected service bind -> 500, never a client auth failure (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 500 +--- error_log +LDAP search bind failed + + + +=== TEST 64: set up a route with a nonexistent base_dn +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=nowhere,dc=example,dc=org", + "attribute": "uid" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 65: search against a nonexistent base_dn -> 500 (noSuchObject is a misconfiguration) (creds: user01:password1) +--- request +GET /hello +--- more_headers +Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ== +--- error_code: 500 +--- error_log +LDAP user search failed + + + +=== TEST 66: set up a route whose login attribute matches many entries (objectClass) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "ldap-auth-advanced": { + "ldap_uri": "127.0.0.1:1389", + "base_dn": "ou=users,dc=example,dc=org", + "attribute": "objectClass" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 67: sizeLimitExceeded stays a fail-closed 401, not a 500 (creds: inetOrgPerson:x) +--- request +GET /hello +--- more_headers +Authorization: ldap aW5ldE9yZ1BlcnNvbjp4 +--- error_code: 401 +--- grep_error_log eval +qr/ambiguous user match \(size limit exceeded\)/ +--- grep_error_log_out +ambiguous user match (size limit exceeded) From f331da3e5b2c4f13ec752d4049544f6fea78afe9 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 31 Jul 2026 15:43:34 +0800 Subject: [PATCH 3/3] fix(ldap-auth-advanced): address second-round review --- apisix/plugins/ldap-auth-advanced.lua | 27 +++++++++----- ci/pod/openldap/enable-anon-bind.sh | 5 +++ t/plugin/ldap-auth-advanced.t | 52 ++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/apisix/plugins/ldap-auth-advanced.lua b/apisix/plugins/ldap-auth-advanced.lua index c87d2b3142d9..00dd29bc7d04 100644 --- a/apisix/plugins/ldap-auth-advanced.lua +++ b/apisix/plugins/ldap-auth-advanced.lua @@ -42,25 +42,28 @@ local schema = { title = "work with route or service object", properties = { -- connection - ldap_uri = { type = "string" }, -- "host[:port]" + ldap_uri = { type = "string", -- "host[:port]" + minLength = 1, maxLength = 256 }, use_ldaps = { type = "boolean", default = false }, use_starttls = { type = "boolean", default = false }, ssl_verify = { type = "boolean", default = true }, timeout = { type = "integer", minimum = 1, maximum = 60000, - default = 3000 }, -- milliseconds + default = 10000 }, -- milliseconds -- connection pool keepalive = { type = "boolean", default = true }, keepalive_timeout = { type = "integer", minimum = 1000, default = 60000 }, keepalive_pool_size = { type = "integer", minimum = 1, default = 5 }, - keepalive_pool_name = { type = "string" }, + keepalive_pool_name = { type = "string", minLength = 1, maxLength = 256 }, -- user resolution (search-then-bind) - base_dn = { type = "string" }, -- search root - attribute = { type = "string", -- filter: (attribute=username) + base_dn = { type = "string", -- search root + minLength = 1, maxLength = 4096 }, + attribute = { type = "string", maxLength = 256, -- filter: (attribute=username) default = "cn", pattern = ATTR_PATTERN }, - bind_dn = { type = "string" }, -- absent => anonymous search - ldap_password = { type = "string" }, + bind_dn = { type = "string", -- absent => anonymous search + minLength = 1, maxLength = 4096 }, + ldap_password = { type = "string", minLength = 1, maxLength = 4096 }, -- search bounds size_limit = { type = "integer", minimum = 2, default = 2 }, @@ -84,7 +87,7 @@ local consumer_schema = { type = "object", title = "work with consumer object", properties = { - user_dn = { type = "string" }, + user_dn = { type = "string", minLength = 1, maxLength = 4096 }, }, required = {"user_dn"}, } @@ -126,6 +129,11 @@ function _M.check_schema(conf, schema_type) end +local CHALLENGE_SCHEME = { + ldap = "ldap", + basic = "Basic", +} + -- Shared 401 helper for the authentication-failure paths. local function auth_failed(conf, ctx, reason) @@ -139,7 +147,8 @@ local function auth_failed(conf, ctx, reason) core.log.warn(plugin_name, ": ", reason) end core.response.set_header("WWW-Authenticate", - conf.header_type .. " realm=\"" .. conf.realm .. "\"") + CHALLENGE_SCHEME[conf.header_type] + .. " realm=\"" .. conf.realm .. "\"") return 401, { message = "Authorization required" } end diff --git a/ci/pod/openldap/enable-anon-bind.sh b/ci/pod/openldap/enable-anon-bind.sh index 15e3733a7958..073dfac8c152 100755 --- a/ci/pod/openldap/enable-anon-bind.sh +++ b/ci/pod/openldap/enable-anon-bind.sh @@ -55,6 +55,11 @@ DATA_DB_DN="$(ldapsearch -Y EXTERNAL -H "ldapi:///" -b cn=config \ "(olcSuffix=dc=example,dc=org)" dn 2>/dev/null \ | awk '/^dn:/ { $1=""; sub(/^ /,""); print; exit }')" +if [ -z "${DATA_DB_DN}" ]; then + error "cannot resolve the cn=config database DN for suffix dc=example,dc=org" + exit 1 +fi + ldapmodify -Y EXTERNAL -H "ldapi:///" <