diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index d47f983..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,11 +0,0 @@ -[target.x86_64-apple-darwin] -rustflags = [ - "-C", "link-arg=-undefined", - "-C", "link-arg=dynamic_lookup", -] - -[target.aarch64-apple-darwin] -rustflags = [ - "-C", "link-arg=-undefined", - "-C", "link-arg=dynamic_lookup", -] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d94261..19fa574 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,6 @@ jobs: # Test::Nginx sudo cpanm --notest Test::Nginx - # rust - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - # luarocks sudo apt -y install lua5.1 luarocks luarocks make rockspec/lua-resty-ldap-local-0.rockspec --local --tree ./deps # install deps to local @@ -56,5 +53,7 @@ jobs: docker logs openldap || true exit 1 fi + docker cp t/fixtures/ad.ldif openldap:/tmp/ad.ldif + docker exec openldap ldapadd -x -H ldap://127.0.0.1:1389 -D "cn=admin,dc=example,dc=org" -w adminpassword -f /tmp/ad.ldif export PATH=$OPENRESTY_PREFIX/nginx/sbin:$OPENRESTY_PREFIX/luajit/bin:$PATH make test diff --git a/.gitignore b/.gitignore index 7da5785..18483c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,5 @@ # dev t/servroot -deps/ - -# Added by cargo - -/target -/Cargo.lock +# luarocks --tree ./deps build output (see .github/workflows/ci.yml) +deps/ diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 4ec4258..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "lua-resty-ldap" -version = "0.2.2" -edition = "2021" - -[lib] -name = "rasn" -crate-type = ["cdylib"] - -[dependencies] -mlua = { version = "0.8.6", features = ["module", "send", "luajit"] } -rasn = "0.6.1" -rasn-ldap = "0.6.0" -bytes = "1.4.0" - -[profile.release] -strip = true diff --git a/Makefile b/Makefile index fbc42b4..afd4633 100644 --- a/Makefile +++ b/Makefile @@ -1,52 +1,19 @@ -UNAME ?= $(shell uname) -INSTALL ?= install -ECHO ?= echo -C_SO_NAME := librasn.so - -ifeq ($(UNAME),Darwin) - C_SO_NAME := librasn.dylib -endif - -all: - @echo --- Build - @echo CFLAGS: $(CFLAGS) - @echo LIBFLAG: $(LIBFLAG) - @echo LUA_LIBDIR: $(LUA_LIBDIR) - @echo LUA_BINDIR: $(LUA_BINDIR) - @echo LUA_INCDIR: $(LUA_INCDIR) - - @echo --- Check Rust toolchain - @utils/check-rust.sh - - @echo --- Build Rust cdylib - cargo build --release - -### install: Install the library to runtime -.PHONY: install -install: - @echo --- Install - @echo INST_PREFIX: $(INST_PREFIX) - @echo INST_BINDIR: $(INST_BINDIR) - @echo INST_LIBDIR: $(INST_LIBDIR) - @echo INST_LUADIR: $(INST_LUADIR) - @echo INST_CONFDIR: $(INST_CONFDIR) - $(INSTALL) -d $(INST_LUADIR)/resty/ldap/ - $(INSTALL) lib/resty/ldap/*.lua $(INST_LUADIR)/resty/ldap/ - $(INSTALL) target/release/${C_SO_NAME} $(INST_LIBDIR)/rasn.so - -### dev: Create a development ENV +### dev: Install runtime dependencies locally .PHONY: dev dev: luarocks install rockspec/lua-resty-ldap-main-0.rockspec --only-deps --local +### test: Run the test suite +.PHONY: test +test: + git apply t/patch/unknown_op.patch + trap 'git apply t/patch/unknown_op.patch -R' EXIT; \ + trap 'exit 130' INT TERM HUP; \ + prove -r t/ + ### help: Show Makefile rules .PHONY: help help: @echo Makefile rules: @echo @grep -E '^### [-A-Za-z0-9_]+:' Makefile | sed 's/###/ /' - -test: - git apply t/patch/unknown_op.patch - prove -r t/ - git apply t/patch/unknown_op.patch -R diff --git a/README.md b/README.md index 357cfe4..d1aa427 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ local client = ldap_client:new("127.0.0.1", 1389, { keepalive_timeout = 60000, start_tls = false, ldaps = false, - ssl_verify = false + ssl_verify = true }) local err = client:simple_bind("cn=user01,ou=users,dc=example,dc=org", "password1") ``` @@ -49,7 +49,7 @@ To load this module: | `keepalive_timeout` | number | 60000 | An optional value in milliseconds that defines how long an idle connection to LDAP server will live before being closed. | | `start_tls` | boolean | false | Set it to `true` to issue StartTLS (Transport Layer Security) extended operation over ldap connection. If the start_tls setting is enabled, ensure the ldaps setting is disabled. | | `ldaps` | boolean | false | Set to `true` to connect using the LDAPS protocol (LDAP over TLS). When ldaps is configured, you must use port 636. If the ldap setting is enabled, ensure the start_tls setting is disabled. | -| `ssl_verify` | boolean | false | Set to true to authenticate LDAP server. The server certificate will be verified according to the CA certificates specified by the `lua_ssl_trusted_certificate` directive. | +| `ssl_verify` | boolean | true | Verify the LDAP server's certificate against the CA certificates specified by the `lua_ssl_trusted_certificate` directive. Set to `false` to skip verification. | | `keepalive_pool_name` | string | host:ip | Set and override the default connection pool name for scenarios where the same connection parameters are used but with a different authentication method. The default value is the same as the OpenResty rule, and the value is the `host:port` of the LDAP server. | | `keepalive_pool_size` | number | 2 | Set the size of a certain connection pool. According to OpenResty's rule, it can only be set when the pool is created and cannot be changed dynamically. | @@ -63,13 +63,13 @@ To load this module: `bind_dn` and `password` can be `nil` values, that means the client is instructed to do anonymous bind. -`res` is a boolean type value that will be true when authentication is successful, when it is false, `err` will contain errors. +`res` is `true` when authentication succeeds and `false` when the server rejects the bind (or the request cannot be encoded); on a transport or decoding failure `res` is `nil`. In both failure cases `err` carries the error. #### search -**syntax:** *res, err = client:search(base_dn?, scope?, deref_aliases?, size_limit?, time_limit?, types_only?, filter?, attributes?)* +**syntax:** *res, err = client:search(base_dn, scope?, deref_aliases?, size_limit?, time_limit?, types_only?, filter?, attributes?)* -`base_dn` is the base DN you need to search. Default is `dc=example,dc=org`. +`base_dn` is the base DN you need to search. It is required; a `nil` value is rejected before any request is sent. `scope` is a flag field in the search protocol that specifies how the LDAP server performs the search, such as baseDN only, all subtrees, etc. You can import those values from protocol.lua, `SEARCH_SCOPE_BASE_OBJECT`, `SEARCH_SCOPE_SINGLE_LEVEL` and `SEARCH_SCOPE_WHOLE_SUBTREE`. Default is `SEARCH_SCOPE_WHOLE_SUBTREE`. @@ -84,3 +84,58 @@ To load this module: `filter` is an LDAP filter expression in string. Default is `(objectClass=*)`. `attributes` is an array table that contains one to more query fields that you need to have the LDAP server return. Default is `["objectClass"]`. + +#### Connection lifecycle + +The client opens its connection on the first operation and holds it, so a `simple_bind` followed by a `search` runs on that one connection. Nothing is released implicitly: the caller ends the session with `set_keepalive` (return it to the pool) or `close` (drop it), once it is done issuing operations. + +> **Note:** this changed in v0.3.0. Operations used to return the connection to the pool on their own, so a caller that never released it still got connection reuse. Such a caller now holds the connection until it is garbage collected at the end of the request, and gets no reuse — add a `set_keepalive` or `close` call when you are done with the client. + +An unrecoverable socket error drops the connection, so the next operation opens a new one instead of reusing the dead one. + +Connection pools are partitioned by transport mode (plain, STARTTLS, LDAPS) and TLS verification policy; bind state is not part of the pool key. A released connection keeps whatever bind state its session left, so a drawn connection's identity is unknown until the session establishes its own: issue a `simple_bind` first whenever identity matters — each bind resets the LDAP session (RFC 4513 section 4). A connection that hits a transport or protocol error is closed, never pooled. + +#### set_keepalive + +**syntax:** *ok, err = client:set_keepalive()* + +Returns the connection to the connection pool and detaches it from the client. The client remains usable; the next operation opens or draws another connection. Returns `true` when there is no connection to release. + +#### close + +**syntax:** *ok, err = client:close()* + +Closes the connection without returning it to the pool, and detaches it from the client. + +### resty.ldap + +Compatibility entrypoint kept for APISIX's `ldap-auth` plugin, preserving the v0.1.0 `ldap_authenticate` contract as a thin wrapper over `resty.ldap.client`. New code should use `resty.ldap.client` instead. + +```lua + local ldap = require "resty.ldap" +``` + +#### ldap_authenticate + +**syntax:** *ok, err, user_dn = ldap.ldap_authenticate(username, password, conf)* + +Binds against the directory as `=,` with the given password. `ok` is `true` when authentication succeeds, `false` when the credentials are rejected — an empty or missing password is refused before any request is sent, as it would form an unauthenticated bind (RFC 4513 section 5.1.2) — and `nil` on an invalid `conf` or a connect/TLS/transport failure; in both failure cases `err` carries the error. The connection is released into the pool after the bind attempt — whatever its outcome — and the next call reuses it and rebinds. + +`username` is escaped per RFC 4514 when the bind DN is built, so DN metacharacters (`, + " \ < > ; =`, a leading space or `#`, a trailing space, or a NUL) are treated as literal characters of the RDN value instead of injecting extra RDN components. Any username string is accepted, as in v0.1.0. + +`user_dn` is the canonical (escaped) DN the bind was performed with; callers that key identity on the DN (e.g. the APISIX `ldap-auth` consumer lookup) should use it instead of rebuilding the DN from the raw username. It is returned whenever a bind was attempted, and is `nil` on connect/STARTTLS/handshake failures. + +`conf` is a table of below items (note the key names differ from `resty.ldap.client`'s `client_config`; they follow the v0.1.0 / `ldap-auth` contract): + +| key | type | default value | Description | +| ----------- | ----------- | ----------- | ----------- | +| `ldap_host` | string | *required* | LDAP server host. | +| `ldap_port` | number | 389 | LDAP server port. | +| `timeout` | number | 10000 | Socket timeout in milliseconds. | +| `keepalive` | number | 60000 | How long an idle connection lives in the pool, in milliseconds. | +| `start_tls` | boolean | false | Issue the StartTLS extended operation before binding. | +| `ldaps` | boolean | false | Connect using LDAP over TLS. | +| `tls_verify` | boolean | true | Verify the server certificate during the TLS handshake. This is the key APISIX's `ldap-auth` passes. | +| `base_dn` | string | *required* | Base DN the username is appended to. | +| `attribute` | string | cn | RDN attribute the username is bound as. | + diff --git a/lib/resty/ldap.lua b/lib/resty/ldap.lua new file mode 100644 index 0000000..ca4a546 --- /dev/null +++ b/lib/resty/ldap.lua @@ -0,0 +1,108 @@ +local log = ngx.log +local ERR = ngx.ERR +local client = require "resty.ldap.client" + + +local tostring = tostring + +local default_conf = { + timeout = 10000, + start_tls = false, + ldap_port = 389, + ldaps = false, + tls_verify = true, + attribute = "cn", + keepalive = 60000, +} + +local function set_conf_default_values(conf) + for k, v in pairs(default_conf) do + if conf[k] == nil then + conf[k] = v + end + end +end + + +-- RFC 4514 s2.4 escaping for an RDN value; the bind DN below is built from a +-- client-supplied username. Not filter.escape(), which is RFC 4515 and leaves +-- ',' and '+' alone. +local function escape_dn_value(value) + local lead = value:sub(1, 1) + -- a lone leading space is covered by the lead rule alone + local trail = #value > 1 and value:sub(-1) or "" + + local out = value:gsub('([\\",+<>;=])', "\\%1"):gsub("%z", "\\00") + + if trail == " " then + out = out:sub(1, -2) .. "\\ " + end + if lead == " " or lead == "#" then + out = "\\" .. out + end + + return out +end + + +local _M = {} + + +function _M.ldap_authenticate(given_username, given_password, conf) + set_conf_default_values(conf) + + if conf.ldap_host == nil or conf.ldap_host == "" then + return nil, "ldap_host is required" + end + if conf.base_dn == nil or conf.base_dn == "" then + return nil, "base_dn is required" + end + + -- same coercion contract as protocol.simple_bind_request + if type(given_username) == "number" then + given_username = tostring(given_username) + end + if type(given_username) ~= "string" then + return false, "bind username must be a string" + end + + -- RFC 4513 s5.1.2: a non-empty DN with an empty password is an + -- unauthenticated bind, which some servers accept as an anonymous + -- success -- an authentication bypass, so it must never reach the wire. + -- Other invalid types are rejected by protocol.simple_bind_request. + if given_password == nil or given_password == "" then + return false, "bind password is missing or empty" + end + + local cli = client:new(conf.ldap_host, conf.ldap_port, { + socket_timeout = conf.timeout, + keepalive_timeout = conf.keepalive, + start_tls = conf.start_tls, + ldaps = conf.ldaps, + ssl_verify = conf.tls_verify, + }) + + local who = conf.attribute .. "=" .. escape_dn_value(given_username) .. + "," .. conf.base_dn + local is_authenticated, bind_err = cli:simple_bind(who, given_password) + + -- pooled even when the bind was rejected: a failed bind leaves the + -- session anonymous (RFC 4513 s4), and the next call rebinds anyway + local released, release_err = cli:set_keepalive() + if not released then + log(ERR, "failed to return the connection to the pool: ", release_err) + end + + if is_authenticated == nil then + -- connect, TLS or transport failure; the client already dropped the socket + log(ERR, "failed to bind to ", conf.ldap_host, ":", + tostring(conf.ldap_port), ": ", bind_err) + return nil, bind_err + end + + -- who: the canonical escaped bind DN, for callers that key identity on it + return is_authenticated, bind_err, who +end + + +return _M diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 442dbfc..9dcdc61 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -1,36 +1,49 @@ local bunpack = require("lua_pack").unpack local protocol = require("resty.ldap.protocol") -local to_hex = require("resty.string").to_hex -local ok, rasn = pcall(require, "rasn") - -if not ok then - error("failed to load rasn library: " .. rasn) -end local tostring = tostring local fmt = string.format local tcp = ngx.socket.tcp local table_insert = table.insert -local string_char = string.char -local rasn_decode = rasn.decode_ldap +local decode_ldap = protocol.decode_message + +-- Upper bound on a single LDAP message body (bytes). A well-formed length such +-- as 84 7f ff ff ff is legal BER but would force a multi-GiB allocation in the +-- worker. 16 MiB comfortably exceeds any real entry. +local MAX_LDAP_MESSAGE_SIZE = 16 * 1024 * 1024 + +-- fixed BER TLV prefix: identifier octet + initial length octet +local BER_HEADER_LEN = 2 local _M = {} local mt = { __index = _M } -local function calculate_payload_length(encStr, pos, socket) +-- takes the BER_HEADER_LEN-byte header (reading further length octets from the +-- socket as needed); returns body length + header bytes, or nil + error +local function calculate_payload_length(encStr, socket) local elen + local pos = BER_HEADER_LEN -- the initial length octet is the header's last byte pos, elen = bunpack(encStr, "C", pos) + -- 0x80 (indefinite) and 0xff (reserved) are illegal, not short-form lengths + if elen == 0x80 or elen == 0xff then + return nil, nil, "invalid BER length: indefinite or reserved form" + end + if elen > 128 then elen = elen - 128 local elenCalc = 0 local elenNext - for i = 1, elen do + for _ = 1, elen do elenCalc = elenCalc * 256 - encStr = encStr .. socket:receive(1) + local byte, err = socket:receive(1) + if not byte then + return nil, nil, fmt("receive length header failed: %s", err) + end + encStr = encStr .. byte pos, elenNext = bunpack(encStr, "C", pos) elenCalc = elenCalc + elenNext end @@ -38,25 +51,34 @@ local function calculate_payload_length(encStr, pos, socket) elen = elenCalc end - return pos, elen, encStr + if elen > MAX_LDAP_MESSAGE_SIZE then + return nil, nil, fmt("ldap message too large: %d bytes", elen) + end + + return elen, encStr end +-- every failure path closes sock: _init_socket has not adopted it yet, so +-- nothing else can, and the connection would linger until garbage collection local function _start_tls(sock) -- send STARTTLS request local bytes, err = sock:send(protocol.start_tls_request()) if not bytes then + sock:close() return fmt("send request failed: %s", err) end -- receive STARTTLS response - local len, err = sock:receive(2) + local len, err = sock:receive(BER_HEADER_LEN) if not len then - if err == "timeout" then - sock:close() - end + sock:close() return fmt("receive response header failed: %s", err) end - local _, packet_len, packet_header = calculate_payload_length(len, 2, sock) + local packet_len, packet_header, lerr = calculate_payload_length(len, sock) + if not packet_len then + sock:close() + return lerr + end local packet, err = sock:receive(packet_len) if not packet then @@ -65,16 +87,16 @@ local function _start_tls(sock) end local packet = packet_header .. packet - local ok, res, err = pcall(rasn_decode, packet) - if not ok or err then - return nil, fmt( - "failed to decode ldap message: %s, message: %s", - not ok and res or err, -- error returned in second value by pcall - to_hex(packet) - ) + local res, err = decode_ldap(packet) + if not res then + sock:close() + -- the body can carry DNs and attribute values; keep it out of the error + return fmt("failed to decode ldap message: %s (%d bytes)", + err or "unknown", #packet) end if res.protocol_op ~= protocol.APP_NO.ExtendedResponse then + sock:close() return fmt("received incorrect op in packet: %d, expected %d", res.protocol_op, protocol.APP_NO.ExtendedResponse) end @@ -82,6 +104,7 @@ local function _start_tls(sock) if res.result_code ~= 0 then local error_msg = protocol.ERROR_MSG[res.result_code] + sock:close() return fmt("error: %s, details: %s", error_msg or ("Unknown error occurred (code: " .. res.result_code .. ")"), res.diagnostic_msg or "") @@ -96,16 +119,28 @@ local function _init_socket(self) sock:settimeout(socket_config.socket_timeout) - -- keep TLS connections in a separate pool to avoid reusing non-secure - -- connections and vice-versa, because STARTTLS use the same port + -- Partition the pool by transport mode and verification policy: + -- sslhandshake() returns immediately on a reused connection, so a pooled + -- ssl_verify=false connection must never serve one that demands verification. + local pool_suffix = "" + if socket_config.start_tls then + pool_suffix = ":starttls" + elseif socket_config.ldaps then + pool_suffix = ":ldaps" + end + if pool_suffix ~= "" then + pool_suffix = pool_suffix .. (socket_config.ssl_verify and ":verify" or ":noverify") + end + local opts = { - pool = host .. ":" .. port .. (socket_config.start_tls and ":starttls" or ""), + pool = host .. ":" .. port .. pool_suffix, pool_size = socket_config.keepalive_pool_size, } - -- override the value when the user specifies connection pool name + -- override the value when the user specifies connection pool name, + -- still partitioned by policy if socket_config.keepalive_pool_name and socket_config.keepalive_pool_name ~= "" then - opts.pool = socket_config.keepalive_pool_name + opts.pool = socket_config.keepalive_pool_name .. pool_suffix end local ok, err = sock:connect(host, port, opts) @@ -118,12 +153,13 @@ local function _init_socket(self) local count, err = sock:getreusedtimes() if not count then -- connection was closed, just return instead + sock:close() return fmt("get %s:%s connection re-used time failed: %s", host, tostring(port), err) end if count == 0 then - -- STARTTLS + -- STARTTLS (_start_tls closes the socket on every failure) local err = _start_tls(sock) if err then return fmt("launch STARTTLS connection on %s:%s failed: %s", @@ -133,8 +169,10 @@ local function _init_socket(self) end if socket_config.start_tls or socket_config.ldaps then + local _ _, err = sock:sslhandshake(true, host, socket_config.ssl_verify) if err ~= nil then + sock:close() return fmt("do TLS handshake on %s:%s failed: %s", host, tostring(port), err) end @@ -143,11 +181,18 @@ local function _init_socket(self) self.socket = sock end -local function _send_recieve(cli, request, multi_resp_hint) - -- initialize socket - local err = _init_socket(cli) - if err then - return nil, fmt("initialize socket failed: %s", err) +-- drop the socket after an unrecoverable error +local function _reset_socket(cli) + cli:close() +end + +local function _send_receive(cli, request, multi_resp_hint) + -- opened on first use, held until the caller releases it + if not cli.socket then + local err = _init_socket(cli) + if err then + return nil, fmt("initialize socket failed: %s", err) + end end local socket = cli.socket @@ -155,31 +200,30 @@ local function _send_recieve(cli, request, multi_resp_hint) -- send req local bytes, err = cli.socket:send(request) if not bytes then + _reset_socket(cli) return nil, fmt("send request failed: %s", err) end - -- Each response in a multi-response body has ASCII NULL(0x00) as its ending, - -- so here the reader is created using receiveuntil. - local reader = socket:receiveuntil(string_char(0x00)) - local result = {} -- When the client sends a search request, the server will return several - -- different entries in a string-like concatenation, sto we must use a + -- different entries in a string-like concatenation, so we must use a -- loop to complete the bulk extraction of the data. -- This does not affect the response of a single "response body". while true do - -- Takes the packet header of a single request body, which has a length - -- of two bytes, where the second byte is the length of this response - -- body packet. - local len, err = reader(2) + local len, err = socket:receive(BER_HEADER_LEN) if not len then - if err == "timeout" then - socket:close() - return nil, fmt("receive response failed: %s", err) - end - break -- read done, data has been taken to the end + -- a search ends at SearchResultDone and a single-response op at its + -- one message; a timeout or close before either truncates the + -- exchange, so fail rather than return what arrived so far + _reset_socket(cli) + return nil, fmt("receive response failed: %s", err or "unknown") + end + + local packet_len, packet_header, lerr = calculate_payload_length(len, socket) + if not packet_len then + _reset_socket(cli) + return nil, lerr end - local _, packet_len, packet_header = calculate_payload_length(len, 2, socket) -- Get the data of the specified length local packet, err = socket:receive(packet_len) @@ -187,18 +231,17 @@ local function _send_recieve(cli, request, multi_resp_hint) -- When the packet header is read but the packet body cannot be read, -- this error is considered unacceptable and therefore an error is -- returned directly instead of processing the received data. - socket:close() - return nil, err + _reset_socket(cli) + return nil, fmt("receive response failed: %s", err) end local packet = packet_header .. packet - local ok, res, err = pcall(rasn_decode, packet) - if not ok or err then - return nil, fmt( - "failed to decode ldap message: %s, message: %s", - not ok and res or err, -- error returned in second value by pcall - to_hex(packet) - ) + local res, err = decode_ldap(packet) + if not res then + -- the body can carry DNs and attribute values; keep it out of the error + _reset_socket(cli) + return nil, fmt("failed to decode ldap message: %s (%d bytes)", + err or "unknown", #packet) end table_insert(result, res) @@ -211,17 +254,12 @@ local function _send_recieve(cli, request, multi_resp_hint) -- just a straight stack of LDAP messages. Therefore the parser implementor -- does not know exactly how many bytes of data should be fetched, and has -- to read in greedy mode. - -- The socket read timeout will be used as a fallback when an exception is - -- encountered and this does not end the loop. if not multi_resp_hint or (res and res.protocol_op == protocol.APP_NO.SearchResultDone) then break end end - -- put back into the connection pool - socket:setkeepalive(cli.socket_config.keepalive_timeout) - return multi_resp_hint and result or result[1] end @@ -232,12 +270,17 @@ function _M.new(_, host, port, client_config) end local opts = client_config or {} + local ssl_verify = opts.ssl_verify + if ssl_verify == nil then + ssl_verify = true + end + local socket_config = { socket_timeout = opts.socket_timeout or 10000, keepalive_timeout = opts.keepalive_timeout or (60 * 1000), -- 10 min start_tls = opts.start_tls or false, ldaps = opts.ldaps or false, - ssl_verify = opts.ssl_verify or false, + ssl_verify = ssl_verify, -- Specify the connection pool name directly to ensure that connections -- with the same connection parameters but using different authentication @@ -256,13 +299,44 @@ function _M.new(_, host, port, client_config) end +function _M.set_keepalive(self) + local sock = self.socket + self.socket = nil + if not sock then + return true + end + return sock:setkeepalive(self.socket_config.keepalive_timeout) +end + + +function _M.close(self) + local sock = self.socket + self.socket = nil + if not sock then + return true + end + return sock:close() +end + + function _M.simple_bind(self, dn, password) - local res, err = _send_recieve(self, protocol.simple_bind_request(dn, password)) + -- as the last argument a (nil, err) return would expand into + -- _send_receive's multi_resp_hint and send a nil request + local req, berr = protocol.simple_bind_request(dn, password) + if not req then + return false, berr + end + + local res, err = _send_receive(self, req) if not res then - return false, err + -- transport/decode failure: nil, so callers can tell an unreachable + -- or broken server (nil) from rejected credentials (false) + return nil, err end if res.protocol_op ~= protocol.APP_NO.BindResponse then + -- the BindResponse may still be queued behind this one: not reusable + _reset_socket(self) return false, fmt("Received incorrect Op in packet: %d, expected %d", res.protocol_op, protocol.APP_NO.BindResponse) end @@ -282,8 +356,12 @@ end function _M.search(self, base_dn, scope, deref_aliases, size_limit, time_limit, types_only, filter, attributes) + if not base_dn then + return false, "base_dn cannot be nil" + end + local search_req, err = protocol.search_request( - base_dn or 'dc=example,dc=org', + base_dn, scope or protocol.SEARCH_SCOPE_WHOLE_SUBTREE, deref_aliases or protocol.SEARCH_DEREF_ALIASES_ALWAYS, size_limit or 0, -- size limit @@ -296,7 +374,7 @@ function _M.search(self, base_dn, scope, deref_aliases, size_limit, time_limit, return false, err end - local res, err = _send_recieve(self, search_req, true) -- mark as potential multi-response operation + local res, err = _send_receive(self, search_req, true) -- mark as potential multi-response operation if not res then return false, err end diff --git a/lib/resty/ldap/filter.lua b/lib/resty/ldap/filter.lua index 524deb1..7bb67f9 100644 --- a/lib/resty/ldap/filter.lua +++ b/lib/resty/ldap/filter.lua @@ -190,4 +190,31 @@ function _M.compile(str) end +local ESCAPE_MAP = { + ["\0"] = "\\00", + ["*"] = "\\2a", + ["("] = "\\28", + [")"] = "\\29", + ["\\"] = "\\5c", + ["="] = "\\3d", + ["<"] = "\\3c", + [">"] = "\\3e", +} + +-- RFC 4515 s3: escape the octets that are special in an assertion value so that +-- untrusted input cannot change the filter's structure. The compiler un-escapes +-- \HH back to raw bytes, so escape() is its exact inverse for these octets. +-- The five RFC-mandated specials are `* ( ) \ NUL`; `= < >` are not RFC-special +-- but the grammar above is stricter and rejects them raw inside a value, so we +-- escape them too (escaping any octet is RFC-legal and round-trips). Note: valid +-- multi-byte UTF-8 passes through the grammar unescaped; a lone invalid-UTF-8 +-- byte (0x80-0xFF) is not escaped here and fails the filter closed. +function _M.escape(value) + if type(value) ~= "string" then + return nil, "value must be a string" + end + return (value:gsub("[%z%*%(%)\\=<>]", ESCAPE_MAP)) +end + + return _M diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index 4481559..58c87db 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -243,6 +243,12 @@ local function is_sequence(obj) and obj.cons end +local function is_set(obj) + return obj.class == asn1.CLASS.UNIVERSAL + and obj.tag == asn1.TAG.SET + and obj.cons +end + -- protocolOp tags a server may send; anything else must not reach parse_ldap_result local LDAP_RESULT_OPS = { [_M.APP_NO.BindResponse] = true, @@ -308,13 +314,29 @@ local function parse_search_entry(packet, op, res) "AttributeDescription") if terr then return nil, terr end -- vals is a SET OF: enforce it so the stored value is always an array - local vend, vals, verr = decode_typed(packet, vpos, pastop, - asn1.TAG.SET, "attribute vals") - if verr then return nil, verr end + local vset, serr = asn1_get_object(packet, vpos, pastop) + if not vset then return nil, serr end + if not is_set(vset) then + return nil, "attribute vals is not a universal constructed SET" + end -- PartialAttribute has exactly two components; nothing may follow vals - if vend ~= pastop then + if vset.offset + vset.len ~= pastop then return nil, "trailing bytes in PartialAttribute" end + -- AttributeValue ::= OCTET STRING (s4.1.5); asn1.decode would take any + -- universal type here and break the string-array contract + local vals = {} + local n = 0 + local vp = vset.offset + local vstop = vset.offset + vset.len + while vp < vstop do + local val, verr + vp, val, verr = decode_typed(packet, vp, vstop, + asn1.TAG.OCTET_STRING, "AttributeValue") + if verr then return nil, verr end + n = n + 1 + vals[n] = val + end attributes[atype] = vals -- ALWAYS an array (empty for typesOnly) apos = pastop end @@ -365,6 +387,9 @@ function _M.decode_message(packet) local pos, message_id, merr = decode_typed(packet, env.offset, envstop, asn1.TAG.INTEGER, "messageID") if merr then return nil, merr end + if message_id < 0 or message_id > LDAP_MAX_INT then + return nil, "messageID out of range" + end local op, oerr = asn1_get_object(packet, pos, envstop) -- protocolOp if not op then return nil, oerr end diff --git a/rockspec/lua-resty-ldap-local-0.rockspec b/rockspec/lua-resty-ldap-local-0.rockspec index 1523bfa..6d1fa3d 100644 --- a/rockspec/lua-resty-ldap-local-0.rockspec +++ b/rockspec/lua-resty-ldap-local-0.rockspec @@ -17,20 +17,12 @@ dependencies = { } build = { - type = "make", - build_variables = { - CFLAGS="$(CFLAGS)", - LIBFLAG="$(LIBFLAG)", - LUA_LIBDIR="$(LUA_LIBDIR)", - LUA_BINDIR="$(LUA_BINDIR)", - LUA_INCDIR="$(LUA_INCDIR)", - LUA="$(LUA)", - }, - install_variables = { - INST_PREFIX="$(PREFIX)", - INST_BINDIR="$(BINDIR)", - INST_LIBDIR="$(LIBDIR)", - INST_LUADIR="$(LUADIR)", - INST_CONFDIR="$(CONFDIR)", - }, + type = "builtin", + modules = { + ["resty.ldap"] = "lib/resty/ldap.lua", + ["resty.ldap.asn1"] = "lib/resty/ldap/asn1.lua", + ["resty.ldap.filter"] = "lib/resty/ldap/filter.lua", + ["resty.ldap.protocol"] = "lib/resty/ldap/protocol.lua", + ["resty.ldap.client"] = "lib/resty/ldap/client.lua", + } } diff --git a/rockspec/lua-resty-ldap-main-0.rockspec b/rockspec/lua-resty-ldap-main-0.rockspec index bf078ac..908df4b 100644 --- a/rockspec/lua-resty-ldap-main-0.rockspec +++ b/rockspec/lua-resty-ldap-main-0.rockspec @@ -18,20 +18,12 @@ dependencies = { } build = { - type = "make", - build_variables = { - CFLAGS="$(CFLAGS)", - LIBFLAG="$(LIBFLAG)", - LUA_LIBDIR="$(LUA_LIBDIR)", - LUA_BINDIR="$(LUA_BINDIR)", - LUA_INCDIR="$(LUA_INCDIR)", - LUA="$(LUA)", - }, - install_variables = { - INST_PREFIX="$(PREFIX)", - INST_BINDIR="$(BINDIR)", - INST_LIBDIR="$(LIBDIR)", - INST_LUADIR="$(LUADIR)", - INST_CONFDIR="$(CONFDIR)", - }, + type = "builtin", + modules = { + ["resty.ldap"] = "lib/resty/ldap.lua", + ["resty.ldap.asn1"] = "lib/resty/ldap/asn1.lua", + ["resty.ldap.filter"] = "lib/resty/ldap/filter.lua", + ["resty.ldap.protocol"] = "lib/resty/ldap/protocol.lua", + ["resty.ldap.client"] = "lib/resty/ldap/client.lua", + } } diff --git a/src/ldap_codec/decoder.rs b/src/ldap_codec/decoder.rs deleted file mode 100644 index ebd1f5e..0000000 --- a/src/ldap_codec/decoder.rs +++ /dev/null @@ -1,101 +0,0 @@ -use mlua::prelude::{Lua, LuaResult, LuaValue}; -use bytes::Bytes; -use rasn::der; -use rasn_ldap::{LdapMessage, ProtocolOp}; - -fn bytes_to_string(b: Bytes) -> Result { - return String::from_utf8(b.to_vec()); -} - -pub fn decode<'lua>( - lua: &'lua Lua, - v: LuaValue<'lua>, -) -> LuaResult<(LuaValue<'lua>, LuaValue<'lua>)> { - let der = match v { - LuaValue::String(v) => v, - _ => { - return Ok(( - LuaValue::Nil, - LuaValue::String(lua.create_string("wrong format on input data")?), - )) - } - }; - - let lm = match der::decode::(der.as_bytes()) { - Ok(lm) => lm, - Err(err) => { - let err_str = format!("{}", err.to_string()); - - return Ok(( - LuaValue::Nil, - LuaValue::String(lua.create_string(err_str.as_bytes())?), - )); - } - }; - - let result = lua.create_table()?; - match lm.protocol_op { - ProtocolOp::BindResponse(resp) => { - result.set("protocol_op", 1)?; - result.set("result_code", resp.result_code as i64)?; - result.set("matched_dn", bytes_to_string(resp.matched_dn).unwrap())?; - result.set( - "diagnostic_msg", - bytes_to_string(resp.diagnostic_message).unwrap(), - )?; - return Ok((LuaValue::Table(result), LuaValue::Nil)); - } - ProtocolOp::SearchResEntry(entry) => { - result.set("protocol_op", 4)?; - result.set("entry_dn", bytes_to_string(entry.object_name).unwrap())?; - - let attributes = lua.create_table()?; - for attribute in entry.attributes.into_iter() { - let attribute_vals = lua.create_table()?; - for val in attribute.vals.into_iter() { - attribute_vals.push(bytes_to_string(val).unwrap())? - } - attributes.set(bytes_to_string(attribute.r#type).unwrap(), attribute_vals)?; - } - - result.set("attributes", attributes)?; - return Ok((LuaValue::Table(result), LuaValue::Nil)); - } - ProtocolOp::SearchResDone(done) => { - let resp = done.0; - result.set("protocol_op", 5)?; - result.set("result_code", resp.result_code as i64)?; - result.set("matched_dn", bytes_to_string(resp.matched_dn).unwrap())?; - result.set( - "diagnostic_msg", - bytes_to_string(resp.diagnostic_message).unwrap(), - )?; - return Ok((LuaValue::Table(result), LuaValue::Nil)); - } - ProtocolOp::ModifyResponse(resp0) => { - let resp = resp0.0; - result.set("protocol_op", 7)?; - result.set("result_code", resp.result_code as i64)?; - result.set("matched_dn", bytes_to_string(resp.matched_dn).unwrap())?; - result.set( - "diagnostic_msg", - bytes_to_string(resp.diagnostic_message).unwrap(), - )?; - return Ok((LuaValue::Table(result), LuaValue::Nil)); - } - ProtocolOp::SearchResRef(_) => { - return Ok(( - LuaValue::Nil, - LuaValue::String( - lua.create_string("decoder not yet implement: search result reference")?, - ), - )) - } - _ => { - return Ok(( - LuaValue::Nil, - LuaValue::String(lua.create_string("decoder not yet implement")?), - )) - } - } -} diff --git a/src/ldap_codec/encoder.rs b/src/ldap_codec/encoder.rs deleted file mode 100644 index 463785e..0000000 --- a/src/ldap_codec/encoder.rs +++ /dev/null @@ -1,11 +0,0 @@ -use mlua::prelude::{Lua, LuaResult, LuaValue}; - -pub fn encode<'lua>( - lua: &'lua Lua, - _: LuaValue<'lua>, -) -> LuaResult<(LuaValue<'lua>, LuaValue<'lua>)> { - Ok(( - LuaValue::Nil, - LuaValue::String(lua.create_string("not yet implement")?), - )) -} diff --git a/src/ldap_codec/mod.rs b/src/ldap_codec/mod.rs deleted file mode 100644 index 7b77088..0000000 --- a/src/ldap_codec/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod encoder; -pub mod decoder; diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 32e52d6..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -use ldap_codec::{decoder::decode, encoder::encode}; -use mlua::prelude::{Lua, LuaResult, LuaTable}; - -mod ldap_codec; - -#[mlua::lua_module] -fn rasn(lua: &Lua) -> LuaResult { - let exports = lua.create_table()?; - - exports.set("encode_ldap", lua.create_function(encode)?)?; - exports.set("decode_ldap", lua.create_function(decode)?)?; - - Ok(exports) -} diff --git a/t/asn1_integer.t b/t/asn1_integer.t index 3bd7208..d3b17a5 100644 --- a/t/asn1_integer.t +++ b/t/asn1_integer.t @@ -283,3 +283,41 @@ GET /t ok --- no_error_log [error] + +=== TEST 7: messageID outside 0..maxInt is rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- representable, so TEST 6's overflow guard never saw these + local bad = { + {"30 0c 02 01 ff 65 07 0a 01 00 04 00 04 00", "messageID -1"}, + {"30 10 02 05 00 80 00 00 00 65 07 0a 01 00 04 00 04 00", "messageID maxInt+1"}, + {"30 10 02 05 ff 7f ff ff ff 65 07 0a 01 00 04 00 04 00", "messageID -maxInt-2"}, + } + for _, c in ipairs(bad) do + local res, err = protocol.decode_message(ldap_hex(c[1])) + assert(res == nil and err ~= nil, + c[2] .. " must be rejected; got " .. tostring(res and res.message_id)) + end + + -- both ends of the legal range still decode + local zero = assert(protocol.decode_message(ldap_hex( + "30 0c 02 01 00 65 07 0a 01 00 04 00 04 00"))) + assert(zero.message_id == 0, "messageID 0") + local max = assert(protocol.decode_message(ldap_hex( + "30 0f 02 04 7f ff ff ff 65 07 0a 01 00 04 00 04 00"))) + assert(max.message_id == 2147483647, "messageID maxInt") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/client.t b/t/client.t index 51ab0cd..adeccdc 100644 --- a/t/client.t +++ b/t/client.t @@ -7,7 +7,7 @@ repeat_each(1); plan 'no_plan'; our $HttpConfig = <<'_EOC_'; - lua_package_path 'lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; resolver 127.0.0.53; _EOC_ @@ -92,7 +92,7 @@ simple bind failed, error: The supplied credential is invalid content_by_lua_block { local ldap_client = require("resty.ldap.client") - local client = ldap_client:new("127.0.0.1", 1636, { ldaps = true }) + local client = ldap_client:new("127.0.0.1", 1636, { ldaps = true, ssl_verify = false }) local res, err = client:simple_bind("cn=user01,ou=users,dc=example,dc=org", "password1") if not res then ngx.log(ngx.ERR, err) @@ -115,7 +115,7 @@ GET /t content_by_lua_block { local ldap_client = require("resty.ldap.client") - local client = ldap_client:new("127.0.0.1", 1389, { start_tls = true }) + local client = ldap_client:new("127.0.0.1", 1389, { start_tls = true, ssl_verify = false }) local res, err = client:simple_bind("cn=user01,ou=users,dc=example,dc=org", "password1") if not res then ngx.log(ngx.ERR, err) @@ -152,3 +152,504 @@ GET /t --- no_error_log [error] --- error_code: 200 + + +=== TEST 7: resty.ldap.ldap_authenticate binds a valid user +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + local ok, err = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1389, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok, "authenticate failed: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 8: a pooled unverified TLS connection is never reused with verification on +--- http_config eval: $::HttpConfig +--- config + location /t { + lua_ssl_trusted_certificate ../../certs/mycacert.crt; + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + + local function anon_search(c) + return c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + end + + -- pool a connection established without certificate verification + local a = ldap_client:new("localhost", 1636, { ldaps = true, ssl_verify = false }) + assert(anon_search(a)) + assert(a.socket:getreusedtimes() == 0, "first connection must be fresh") + assert(a:set_keepalive()) + + -- ssl_verify=true must not reuse it (a fresh connection is required) + local b = ldap_client:new("localhost", 1636, { ldaps = true, ssl_verify = true }) + assert(anon_search(b)) + assert(b.socket:getreusedtimes() == 0, + "an unverified pooled connection must not serve ssl_verify=true") + assert(b:set_keepalive()) + + -- control: the same policy does reuse its own pooled connection + local c = ldap_client:new("localhost", 1636, { ldaps = true, ssl_verify = false }) + assert(anon_search(c)) + assert(c.socket:getreusedtimes() > 0, "same-policy reuse should hit the pool") + assert(c:set_keepalive()) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 9: starttls pools are partitioned by verification policy too +--- http_config eval: $::HttpConfig +--- config + location /t { + lua_ssl_trusted_certificate ../../certs/mycacert.crt; + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + + local function anon_search(c) + return c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + end + + local a = ldap_client:new("localhost", 1389, { start_tls = true, ssl_verify = false }) + assert(anon_search(a)) + assert(a.socket:getreusedtimes() == 0, "first connection must be fresh") + assert(a:set_keepalive()) + + local b = ldap_client:new("localhost", 1389, { start_tls = true, ssl_verify = true }) + assert(anon_search(b)) + assert(b.socket:getreusedtimes() == 0, + "an unverified pooled STARTTLS connection must not serve ssl_verify=true") + assert(b:set_keepalive()) + + -- control: the same policy does reuse its own pooled connection + local c = ldap_client:new("localhost", 1389, { start_tls = true, ssl_verify = false }) + assert(anon_search(c)) + assert(c.socket:getreusedtimes() > 0, "same-policy reuse should hit the pool") + assert(c:set_keepalive()) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 10: client returns a controlled error when a response header contains a 0x00 byte +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + + local closed, step = false, 0 + local sock = { + send = function(_, p) return #p end, + receive = function() + step = step + 1 + if step == 1 then + return "\48\00" -- header: SEQUENCE tag, then a 0x00 length octet + end + return "" -- the declared zero-length body + end, + close = function() closed = true return true end, + } + + local client = ldap_client:new("127.0.0.1", 1389) + client.socket = sock + + local res, err = client:simple_bind( + "cn=user01,ou=users,dc=example,dc=org", "password1") + assert(res == nil, "a 0x00 header byte must be a transport failure (nil), got " .. + tostring(res)) + assert(err:find("failed to decode ldap message", 1, true), + "unexpected err: " .. tostring(err)) + assert(closed, "the unusable socket must be closed") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 11: bind and unbound sessions share one pool +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + + -- an unbound search opens a connection and pools it on release + local a = ldap_client:new("127.0.0.1", 1389) + assert(a:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)")) + assert(a:set_keepalive()) + + -- a bind draws that same pooled connection: the pool is keyed by + -- host, port and TLS policy only, not by bind state + local b = ldap_client:new("127.0.0.1", 1389) + assert(b:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + assert(b.socket:getreusedtimes() == 1, + "the bind should draw the pooled connection, got " .. + b.socket:getreusedtimes()) + assert(b:set_keepalive()) + + -- and the bound connection re-enters the same pool for the next session + local c = ldap_client:new("127.0.0.1", 1389) + assert(c:simple_bind("cn=user01,ou=users,dc=example,dc=org", "password1")) + assert(c.socket:getreusedtimes() == 2, + "the bound connection should have been pooled again, got " .. + c.socket:getreusedtimes()) + assert(c:set_keepalive()) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 12: ldap_authenticate reuses one pooled connection across calls +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + local conf = { + ldap_host = "127.0.0.1", + ldap_port = 1389, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + } + + -- the first call opens a connection, the second reuses it: one + -- socket, two binds on it + local ok, err = ldap.ldap_authenticate("user01", "password1", conf) + assert(ok, "authenticate failed: " .. tostring(err)) + local ok2, err2 = ldap.ldap_authenticate("user01", "password1", conf) + assert(ok2, "authenticate failed: " .. tostring(err2)) + + local probe = ngx.socket.tcp() + assert(probe:connect("127.0.0.1", 1389)) + assert(probe:getreusedtimes() == 2, + "the second authenticate should have reused the pooled socket, got " .. + probe:getreusedtimes()) + probe:close() + + -- a rejected bind leaves the session anonymous (RFC 4513 s4), so its + -- socket is pooled like any other, and the next call rebinds on it + local ok3 = ldap.ldap_authenticate("user01", "wrong-password", conf) + assert(ok3 == false, "a wrong password must be rejected (false), got " .. tostring(ok3)) + local ok4, err4 = ldap.ldap_authenticate("user01", "password1", conf) + assert(ok4, "a reused socket must still authenticate: " .. tostring(err4)) + + -- the rejected bind's socket was pooled and rebound: one socket, + -- two checkouts since the probe closed the previous one + local probe2 = ngx.socket.tcp() + assert(probe2:connect("127.0.0.1", 1389)) + assert(probe2:getreusedtimes() == 2, + "the rejected bind's socket should have been pooled, got " .. + probe2:getreusedtimes()) + probe2:close() + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 13: client returns a controlled error when the response header is unreadable +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + + local closed = false + local sock = { + send = function(_, p) return #p end, + receive = function() return nil, "timeout" end, + close = function() closed = true return true end, + } + + local client = ldap_client:new("127.0.0.1", 1389) + client.socket = sock + + local res, err = client:simple_bind( + "cn=user01,ou=users,dc=example,dc=org", "password1") + assert(res == nil, "a receive timeout must be a transport failure (nil), got " .. + tostring(res)) + assert(err:find("receive response failed", 1, true), + "unexpected err: " .. tostring(err)) + assert(closed, "the unusable socket must be closed") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 14: client returns a controlled error when the response body is truncated +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + + local closed, step = false, 0 + local sock = { + send = function(_, p) return #p end, + receive = function() + step = step + 1 + if step == 1 then + return "\48\05" -- header: SEQUENCE tag, body length 5 + end + return nil, "closed" -- body read fails + end, + close = function() closed = true return true end, + } + + local client = ldap_client:new("127.0.0.1", 1389) + client.socket = sock + + local res, err = client:simple_bind( + "cn=user01,ou=users,dc=example,dc=org", "password1") + assert(res == nil, "a truncated body must be a transport failure (nil), got " .. + tostring(res)) + assert(err:find("receive response failed", 1, true), + "unexpected err: " .. tostring(err)) + assert(closed, "the unusable socket must be closed") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + +=== TEST 15: an invalid bind dn/password is reported without opening a socket +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + + -- port 1 has no listener: a socket error here instead of the + -- validation message means simple_bind connected before checking + local client = ldap_client:new("127.0.0.1", 1) + + local res, err = client:simple_bind({}, "password1") + assert(res == false, "table dn must fail") + assert(err == "bind dn must be a string", "got: " .. tostring(err)) + + local res2, err2 = client:simple_bind("cn=user01", true) + assert(res2 == false, "boolean password must fail") + assert(err2 == "bind password must be a string", "got: " .. tostring(err2)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + +=== TEST 16: a dropped connection before any response is a reported failure +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + + -- a pooled socket the server has already dropped reads "closed", + -- not "timeout", on the very first header read + local closed = false + local sock = { + send = function(_, p) return #p end, + receive = function() return nil, "closed" end, + close = function() closed = true return true end, + } + + local client = ldap_client:new("127.0.0.1", 1389) + client.socket = sock + + local res, err = client:simple_bind( + "cn=user01,ou=users,dc=example,dc=org", "password1") + assert(res == nil, "a dropped connection must be a transport failure (nil)") + assert(err ~= nil, "it must carry a diagnostic, got nil") + assert(err:find("receive response failed", 1, true), + "unexpected err: " .. tostring(err)) + assert(closed, "the unusable socket must be closed") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + +=== TEST 17: a search cut short before SearchResultDone must fail, not return partial results +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local ldap_hex = require("ldap_hex") + + -- one complete SearchResultEntry, then the peer goes away without + -- ever sending SearchResultDone + local entry = ldap_hex("30 16 02 01 03 64 11 04 01 78" .. + " 30 0c 30 0a 04 01 73 31 05 04 03 01 00 02") + local step, closed = 0, false + local sock = { + send = function(_, p) return #p end, + receive = function(_, n) + step = step + 1 + if step == 1 then return entry:sub(1, 2) end + if step == 2 then return entry:sub(3) end + return nil, "closed" + end, + close = function() closed = true return true end, + } + + local client = ldap_client:new("127.0.0.1", 1389) + client.socket = sock + + local res, err = client:search("dc=example,dc=org") + assert(res == false, + "a truncated search must fail; got " .. type(res) .. + " with " .. tostring(type(res) == "table" and #res or "n/a") .. " entries") + assert(err ~= nil, "it must carry a diagnostic, got nil") + assert(closed, "the unusable socket must be closed") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + +=== TEST 18: an omitted ssl_verify verifies the server certificate +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + + -- no ssl_verify key, and no lua_ssl_trusted_certificate to satisfy it + local client = ldap_client:new("127.0.0.1", 1636, { ldaps = true }) + local res, err = client:simple_bind("cn=user01,ou=users,dc=example,dc=org", "password1") + assert(res == nil, "an omitted ssl_verify must still verify, got " .. tostring(res)) + assert(err:find("TLS handshake", 1, true), "unexpected err: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +certificate verify error + + +=== TEST 19: ldap_authenticate rejects an empty or missing password before any bind +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + local conf = { + ldap_host = "127.0.0.1", + ldap_port = 1389, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + } + + -- RFC 4513 s5.1.2: an empty password with a non-empty DN is an + -- unauthenticated bind, which some servers accept as an anonymous + -- success; the client must refuse to send it + local ok, err = ldap.ldap_authenticate("user01", "", conf) + assert(ok == false, "an empty password must be rejected (false), got " .. tostring(ok)) + assert(err:find("missing or empty", 1, true), "unexpected err: " .. tostring(err)) + + local ok2, err2 = ldap.ldap_authenticate("user01", nil, conf) + assert(ok2 == false, "a nil password must be rejected (false), got " .. tostring(ok2)) + assert(err2:find("missing or empty", 1, true), "unexpected err: " .. tostring(err2)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/compat_ldap.t b/t/compat_ldap.t new file mode 100644 index 0000000..e21701c --- /dev/null +++ b/t/compat_ldap.t @@ -0,0 +1,331 @@ +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: tls_verify=true enforces certificate verification +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + -- no lua_ssl_trusted_certificate, so verifying the test CA must fail + local ok, err = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1636, + ldaps = true, + tls_verify = true, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok == nil, "a rejected handshake must be a transport failure (nil), got " .. + tostring(ok)) + assert(err:find("TLS handshake", 1, true), "unexpected err: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +certificate verify error + + + +=== TEST 2: tls_verify=false skips verification and binds +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + local ok, err = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1636, + ldaps = true, + tls_verify = false, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok, "handshake without verification should bind: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 3: verification is on when tls_verify is unset +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + -- no tls_verify key at all, and no lua_ssl_trusted_certificate + local ok, err = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1636, + ldaps = true, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok == nil, "an unset tls_verify must still verify: " .. + "expected a transport failure (nil), got " .. tostring(ok)) + assert(err:find("TLS handshake", 1, true), "unexpected err: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +certificate verify error + + + +=== TEST 4: a DN-metacharacter username is escaped into the bind DN +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + -- "al,ice" must reach the server as cn=al\,ice and fail as a clean + -- credential error; sent raw, the server would refuse the DN itself + local ok, err, user_dn = ldap.ldap_authenticate("al,ice", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1389, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok == false, "an unknown escaped username must be rejected (false), got " .. + tostring(ok)) + assert(err:find("credential", 1, true), + "expected a credential error: " .. tostring(err)) + assert(user_dn == "cn=al\\,ice,ou=users,dc=example,dc=org", + "expected the escaped bind DN, got: " .. tostring(user_dn)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 5: ldap_authenticate binds a valid user with the raw DN +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + local ok, err, user_dn = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1389, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok, "authenticate failed: " .. tostring(err)) + assert(user_dn == "cn=user01,ou=users,dc=example,dc=org", + "expected the canonical bind DN, got: " .. tostring(user_dn)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 6: a wrong password is a clean auth failure +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + local ok, err = ldap.ldap_authenticate("user01", "wrong-password", { + ldap_host = "127.0.0.1", + ldap_port = 1389, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok == false, "a wrong password must be rejected (false), got " .. tostring(ok)) + assert(err:find("credential", 1, true), "expected a credential error: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 7: an earlier unverified call never lets a tls_verify=true call skip verification +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + + -- authenticate with verification disabled first + local ok, err = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1636, + ldaps = true, + tls_verify = false, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok, "unverified handshake should bind: " .. tostring(err)) + + -- the first call pools its socket under :noverify, so the + -- verifying call cannot draw it and needs a fresh handshake, which + -- fails: no trusted certificate is configured + local ok2, err2 = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1636, + ldaps = true, + tls_verify = true, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok2 == nil, "the unverified call must not satisfy a verifying one: " .. + "expected a transport failure (nil), got " .. tostring(ok2)) + assert(err2:find("TLS handshake", 1, true), "unexpected err: " .. tostring(err2)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +certificate verify error + + + +=== TEST 8: a missing ldap_host is a config error, never a bind against localhost +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + local ok, err = ldap.ldap_authenticate("user01", "password1", { + ldap_port = 1389, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok == nil, "a missing ldap_host must not authenticate, got " .. tostring(ok)) + assert(err == "ldap_host is required", "unexpected err: " .. tostring(err)) + + -- an empty string is the same misconfiguration, set rather than omitted + local ok2, err2 = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "", + ldap_port = 1389, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok2 == nil, "an empty ldap_host must not authenticate, got " .. tostring(ok2)) + assert(err2 == "ldap_host is required", "unexpected err: " .. tostring(err2)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 9: a missing base_dn is a config error, never a bind under a placeholder DN +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + -- the directory really does hold ou=users,dc=example,dc=org, so a + -- placeholder default would authenticate under a DN never configured + local ok, err = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1389, + attribute = "cn", + }) + assert(ok == nil, "a missing base_dn must not authenticate, got " .. tostring(ok)) + assert(err == "base_dn is required", "unexpected err: " .. tostring(err)) + + -- an empty base_dn would bind under "cn=user01," and be reported as + -- a credential failure rather than the misconfiguration it is + local ok2, err2 = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "127.0.0.1", + ldap_port = 1389, + base_dn = "", + attribute = "cn", + }) + assert(ok2 == nil, "an empty base_dn must not authenticate, got " .. tostring(ok2)) + assert(err2 == "base_dn is required", "unexpected err: " .. tostring(err2)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + +=== TEST 10: an invalid password is reported without opening a socket +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + -- port 1 has no listener: a socket error here instead of the + -- validation message means the bind connected before checking + local ok, err = ldap.ldap_authenticate("user01", true, { + ldap_host = "127.0.0.1", + ldap_port = 1, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok == false, "a non-string password must be rejected (false), got " .. + tostring(ok)) + assert(err == "bind password must be a string", "unexpected err: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode.t b/t/decode.t index 2a13578..7b4cbb1 100644 --- a/t/decode.t +++ b/t/decode.t @@ -20,8 +20,8 @@ __DATA__ --- config location /t { content_by_lua_block { - local decode = require("ldap_decode") local protocol = require("resty.ldap.protocol") + local decode = protocol.decode_message local ldap_hex = require("ldap_hex") local res = assert(decode(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) assert(res.protocol_op == protocol.APP_NO.BindResponse, "op " .. tostring(res.protocol_op)) @@ -43,8 +43,8 @@ ok --- config location /t { content_by_lua_block { - local decode = require("ldap_decode") local protocol = require("resty.ldap.protocol") + local decode = protocol.decode_message local ldap_hex = require("ldap_hex") local res = assert(decode(ldap_hex("30 0c 02 01 02 65 07 0a 01 20 04 00 04 00"))) assert(res.protocol_op == protocol.APP_NO.SearchResultDone, "op") @@ -64,8 +64,8 @@ ok --- config location /t { content_by_lua_block { - local decode = require("ldap_decode") local protocol = require("resty.ldap.protocol") + local decode = protocol.decode_message local ldap_hex = require("ldap_hex") -- entry_dn "x"; attribute s = { "\1\0\2" } (3 bytes, contains NUL) local res = assert(decode(ldap_hex("30 16 02 01 03 64 11 04 01 78 30 0c 30 0a 04 01 73 31 05 04 03 01 00 02"))) @@ -90,7 +90,7 @@ ok --- config location /t { content_by_lua_block { - local decode = require("ldap_decode") + local decode = require("resty.ldap.protocol").decode_message local ldap_hex = require("ldap_hex") local res = assert(decode(ldap_hex( "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 04 01 73 31 00"))) diff --git a/t/decode_hostile.t b/t/decode_hostile.t index d2c9e00..bcb0292 100644 --- a/t/decode_hostile.t +++ b/t/decode_hostile.t @@ -371,3 +371,68 @@ GET /t ok --- no_error_log [error] + +=== TEST 11: every AttributeValue must be an OCTET STRING +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- rasn dropped the whole attribute here; the pure-Lua decoder used to + -- store the value, leaking a number or a table into a string array + local cases = { + { "INTEGER value", + "30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 04 01 73 31 03 02 01 05" }, + { "ENUMERATED value", + "30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 04 01 73 31 03 0a 01 07" }, + { "SEQUENCE value", + "30 13 02 01 03 64 0e 04 01 78 30 09 30 07 04 01 73 31 02 30 00" }, + { "nested SET value", + "30 16 02 01 03 64 11 04 01 78 30 0c 30 0a 04 01 73 31 05 31 03 04 01 41" }, + { "one good value then an INTEGER", + "30 17 02 01 03 64 12 04 01 78 30 0d 30 0b 04 01 73 31 06 04 01 41 02 01 05" }, + } + + local leaks = {} + for _, c in ipairs(cases) do + local pok, res, err = pcall(protocol.decode_message, ldap_hex(c[2])) + if not pok then + table.insert(leaks, c[1] .. " RAISED: " .. tostring(res)) + elseif res ~= nil then + table.insert(leaks, string.format("%s accepted (vals[1] is a %s)", + c[1], type(res.attributes and res.attributes.s + and res.attributes.s[1]))) + elseif err == nil then + table.insert(leaks, c[1] .. " returned nil with no error") + end + end + if #leaks > 0 then + ngx.say(#leaks .. " non-OCTET-STRING values accepted:") + ngx.say(table.concat(leaks, "\n")) + return + end + + -- the tightened check must not cost BER leniency + local lenient = assert(protocol.decode_message(ldap_hex( + "30 17 02 01 03 64 12 04 01 78 30 0d 30 0b 04 01 73 31 06 04 81 03 41 42 43"))) + assert(lenient.attributes.s[1] == "ABC", "non-minimal length still decodes") + + -- and well-formed entries are untouched + local ok = assert(protocol.decode_message(ldap_hex( + "30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 04 01 73 31 03 04 01 41"))) + assert(ok.attributes.s[1] == "A", "single value") + local empty = assert(protocol.decode_message(ldap_hex( + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 04 01 73 31 00"))) + assert(#empty.attributes.s == 0, "typesOnly empty SET stays an empty array") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/filter.t b/t/filter.t index 7737fde..5626762 100644 --- a/t/filter.t +++ b/t/filter.t @@ -449,3 +449,73 @@ GET /t --- no_error_log [error] --- error_code: 200 + +=== TEST 5: escape escapes the five special octets +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local filter = require("resty.ldap.filter") + assert(filter.escape("a*b") == "a\\2ab", "star") + assert(filter.escape("a(b") == "a\\28b", "lparen") + assert(filter.escape("a)b") == "a\\29b", "rparen") + assert(filter.escape("a\\b") == "a\\5cb", "backslash") + assert(filter.escape("a\0b") == "a\\00b", "nul") + assert(filter.escape("plain") == "plain", "plain unchanged") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: escaped user input cannot widen the filter (injection) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local filter = require("resty.ldap.filter") + local raw = "*)(uid=*" + local esc = filter.escape(raw) + local ast = assert(filter.compile("(cn=" .. esc .. ")")) + assert(ast.item_type == "simple", "must be a simple item, got " .. tostring(ast.item_type)) + assert(ast.filter_type == "equal", "must be equality") + -- the compiler un-escapes back to the exact raw bytes + assert(ast.attribute_value == raw, "value round-trips to raw: " .. tostring(ast.attribute_value)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: escape covers < and > which the grammar rejects raw +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local filter = require("resty.ldap.filter") + assert(filter.escape("ab") == "a\\3eb", "gt") + -- round-trip: a value with < and > compiles to a simple equality + -- whose attribute_value is the exact raw input + local raw = "ac" + local ast = assert(filter.compile("(cn=" .. filter.escape(raw) .. ")"), + "value with must compile after escaping") + assert(ast.item_type == "simple" and ast.filter_type == "equal", "simple equality") + assert(ast.attribute_value == raw, "round-trips to raw: " .. tostring(ast.attribute_value)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/fixtures/ad.ldif b/t/fixtures/ad.ldif new file mode 100644 index 0000000..17d11b7 --- /dev/null +++ b/t/fixtures/ad.ldif @@ -0,0 +1,9 @@ +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 +description:: AQAC diff --git a/t/lib/ldap_decode.lua b/t/lib/ldap_decode.lua deleted file mode 100644 index 8e08426..0000000 --- a/t/lib/ldap_decode.lua +++ /dev/null @@ -1,5 +0,0 @@ -local ok, rasn = pcall(require, "rasn") -if ok and type(rasn) == "table" and rasn.decode_ldap then - return rasn.decode_ldap -end -return require("resty.ldap.protocol").decode_message diff --git a/t/patch/unknown_op.patch b/t/patch/unknown_op.patch index 52bb839..2ab3395 100644 --- a/t/patch/unknown_op.patch +++ b/t/patch/unknown_op.patch @@ -8,7 +8,7 @@ index 6b8eb18..45cef3f 100644 +function _M.unknown(self, hex_data, multi_resp_hint) + local raw_data = hex_data:gsub("%x%x", function(digits) return string.char(tonumber(digits, 16)) end) -+ local res, err = _send_recieve(self, raw_data, multi_resp_hint or false) ++ local res, err = _send_receive(self, raw_data, multi_resp_hint or false) + if not res then + return false, err + end diff --git a/t/search.t b/t/search.t index 4800933..70c8ec8 100644 --- a/t/search.t +++ b/t/search.t @@ -24,7 +24,12 @@ __DATA__ local ldap_client = require("resty.ldap.client") local client = ldap_client:new("127.0.0.1", 1389) - local res, err = client:search() + + local bres, berr = client:search() + assert(bres == false, "nil base_dn must fail") + assert(berr == "base_dn cannot be nil", "got: " .. tostring(berr)) + + local res, err = client:search("dc=example,dc=org") if not res then ngx.log(ngx.ERR, err) ngx.exit(401) @@ -371,7 +376,8 @@ GET /t ngx.exit(401) end - assert(#res == 1, "result length is not equal to 1") + -- 2 entries: the base entry plus cn=Jane Doe from t/fixtures/ad.ldif + assert(#res == 2, "result length is not equal to 2") assert(res[1].entry_dn == "dc=example,dc=org", "result entry_dn is not equal to dc=example,dc=org") } } @@ -533,8 +539,8 @@ GET /t ngx.exit(401) end - -- whole subtree: 6 entries bootstrapped by the image - assert(#res == 6, "result length is not equal to 6") + -- 6 bootstrapped by the image plus cn=Jane Doe from t/fixtures/ad.ldif + assert(#res == 7, "result length is not equal to 7") } } --- request @@ -609,6 +615,8 @@ GET /t assert(res.protocol_op == 7, "protocol_op is not equal to 7, " .. res.protocol_op) assert(res.result_code == 0, "result_code is not equal to 0, " .. res.result_code) + + client:close() } } --- request @@ -683,6 +691,8 @@ GET /t assert(res.protocol_op == 7, "protocol_op is not equal to 7, " .. res.protocol_op) assert(res.result_code == 0, "result_code is not equal to 0, " .. res.result_code) + + client:close() } } --- request diff --git a/t/search_ad.t b/t/search_ad.t new file mode 100644 index 0000000..b185da8 --- /dev/null +++ b/t/search_ad.t @@ -0,0 +1,76 @@ +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: search by login attribute (uid) returns an entry whose RDN is cn +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + local filter = require("resty.ldap.filter") + + local c = ldap_client:new("127.0.0.1", 1389) + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + + local login = "jdoe" + local flt = "(uid=" .. filter.escape(login) .. ")" + local res, err = c:search("ou=users,dc=example,dc=org", + protocol.SEARCH_SCOPE_WHOLE_SUBTREE, nil, nil, nil, nil, flt, {"uid", "description"}) + assert(res, "search: " .. tostring(err)) + assert(#res == 1, "exactly one entry, got " .. #res) + assert(res[1].entry_dn == "cn=Jane Doe,ou=users,dc=example,dc=org", + "DN is the RDN-based DN: " .. tostring(res[1].entry_dn)) + assert(res[1].attributes.uid[1] == "jdoe", "uid value") + assert(c:set_keepalive()) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: binary attribute value with embedded NUL survives end-to-end +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + local c = ldap_client:new("127.0.0.1", 1389) + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + local res = assert(c:search("ou=users,dc=example,dc=org", + protocol.SEARCH_SCOPE_WHOLE_SUBTREE, nil, nil, nil, nil, + "(uid=jdoe)", {"description"})) + assert(#res == 1, "one entry") + local d = res[1].attributes.description[1] + assert(#d == 3, "description length is 3 (not truncated at NUL): got " .. #d) + assert(d == "\1\0\2", "description bytes preserved") + c:close() + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/session.t b/t/session.t new file mode 100644 index 0000000..ee29221 --- /dev/null +++ b/t/session.t @@ -0,0 +1,193 @@ +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: bind and search share one connection until it is released +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + + local c = ldap_client:new("127.0.0.1", 1389) + + -- prime the pool so reuse below is observable via getreusedtimes() + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + assert(c:set_keepalive()) + + local ok, err = c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword") + assert(ok, "bind: " .. tostring(err)) + local session_sock = c.socket + assert(session_sock:getreusedtimes() >= 1, + "a released bind connection should have been pooled and reused") + + local res, serr = c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + assert(res, "search: " .. tostring(serr)) + assert(#res == 1 and res[1].entry_dn == "dc=example,dc=org", "base entry") + + -- the search must have run on the bind's connection, not its own + assert(rawequal(c.socket, session_sock), "socket changed mid-session") + + assert(c:set_keepalive()) + assert(c.socket == nil, "set_keepalive releases the connection") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: a lone operation needs no explicit connect +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + local c = ldap_client:new("127.0.0.1", 1389) + local res = assert(c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)")) + assert(#res == 1, "one entry") + assert(c.socket ~= nil, "the connection is held until released") + assert(c:set_keepalive()) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: close() drops the connection and the next op opens another +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + + local c = ldap_client:new("127.0.0.1", 1389) + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + local session_sock = c.socket + assert(session_sock ~= nil, "the bind opened a connection") + + assert(c:close()) + assert(c.socket == nil, "close drops the socket") + + -- a later op opens its own connection + local res, err = c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + assert(res, "search after close: " .. tostring(err)) + assert(#res == 1, "one entry") + assert(not rawequal(c.socket, session_sock), "closed socket was reused") + assert(c:set_keepalive()) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: a hard socket error detaches the connection instead of stranding it +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + + local c = ldap_client:new("127.0.0.1", 1389) + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + + -- kill the held connection underneath the client + c.socket:close() + + local ok, err = c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + assert(not ok, "a search on a dead socket must fail") + assert(err ~= nil, "a search on a dead socket reports an error") + + -- the failure must detach the dead socket, not strand the client on it + assert(c.socket == nil, "hard error left a dead socket attached") + + -- and the client is usable again without an explicit close() + local res, serr = c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + assert(res, "client unusable after a hard error: " .. tostring(serr)) + assert(#res == 1, "one entry") + assert(c:set_keepalive()) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +attempt to send data on a closed socket + +=== TEST 5: a bound connection is pooled as-is and the next session rebinds on it +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap_client = require("resty.ldap.client") + local protocol = require("resty.ldap.protocol") + + -- an unbound search opens the connection, a bind runs on it, and the + -- release pools it unchanged: bind state is not part of the pool key + local c = ldap_client:new("127.0.0.1", 1389) + assert(c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)")) + local session_sock = c.socket + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + assert(rawequal(c.socket, session_sock), "the bind switched connections") + assert(c:set_keepalive()) + + -- the next session draws that same connection and rebinds to reach its + -- own desired state: each bind resets the session (RFC 4513 s4) + local d = ldap_client:new("127.0.0.1", 1389) + assert(d:simple_bind("cn=user01,ou=users,dc=example,dc=org", "password1")) + assert(d.socket:getreusedtimes() == 1, + "the bound connection should have been pooled and reused, got " .. + d.socket:getreusedtimes()) + local res, serr = d:search("ou=users,dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + assert(res, "search on the rebound connection: " .. tostring(serr)) + assert(d:set_keepalive()) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/utils/check-rust.sh b/utils/check-rust.sh deleted file mode 100755 index 7d47072..0000000 --- a/utils/check-rust.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -command -v cargo >/dev/null 2>&1 && { echo "The cargo is installed"; } || { - echo "The cargo is not installed" - echo "This rock contains the Rust code: make sure you have a Rust development environment installed and try again" - exit 1 -}