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 0c314d0..19fa574 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,36 +9,27 @@ on: jobs: build: - runs-on: "ubuntu-20.04" + runs-on: "ubuntu-latest" env: OPENRESTY_PREFIX: "/usr/local/openresty" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: get dependencies run: | - sudo apt-get -y install --no-install-recommends wget curl gnupg ca-certificates lsb-release + sudo apt-get update + sudo apt-get -y install --no-install-recommends wget curl gnupg ca-certificates lsb-release cpanminus # openresty - wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add - - echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list + sudo install -m 0755 -d /etc/apt/keyrings + wget -O - https://openresty.org/package/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/openresty.gpg + echo "deb [signed-by=/etc/apt/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list sudo apt-get update sudo apt-get -y install openresty # Test::Nginx - sudo cpan Test::Nginx - - # docker - sudo apt-get remove docker docker-engine docker.io containerd runc - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null - sudo apt-get update - sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin - sudo docker run hello-world - - # rust - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + sudo cpanm --notest Test::Nginx # luarocks sudo apt -y install lua5.1 luarocks @@ -46,7 +37,23 @@ jobs: - name: script run: | - sudo docker run --detach --rm --name openldap -p 1389:1389 -p 1636:1636 -v $PWD/t/certs:/opt/bitnami/openldap/certs -e LDAP_ENABLE_TLS=yes -e LDAP_TLS_CERT_FILE=/opt/bitnami/openldap/certs/localhost_slapd_cert.pem -e LDAP_TLS_KEY_FILE=/opt/bitnami/openldap/certs/localhost_slapd_key.pem -e LDAP_TLS_CA_FILE=/opt/bitnami/openldap/certs/mycacert.crt -e LDAP_ADMIN_USERNAME=admin -e LDAP_ADMIN_PASSWORD=adminpassword -e LDAP_USERS=user01,user02 -e LDAP_PASSWORDS=password1,password2 bitnami/openldap:2.6 - sleep 3 + sudo docker run --detach --rm --name openldap -p 1389:1389 -p 1636:1636 -v $PWD/t/certs:/opt/bitnami/openldap/certs -e LDAP_ENABLE_TLS=yes -e LDAP_TLS_CERT_FILE=/opt/bitnami/openldap/certs/localhost_slapd_cert.pem -e LDAP_TLS_KEY_FILE=/opt/bitnami/openldap/certs/localhost_slapd_key.pem -e LDAP_TLS_CA_FILE=/opt/bitnami/openldap/certs/mycacert.crt -e LDAP_ADMIN_USERNAME=admin -e LDAP_ADMIN_PASSWORD=adminpassword -e LDAP_USERS=user01,user02 -e LDAP_PASSWORDS=password1,password2 bitnamilegacy/openldap:2.6.10-debian-12-r4 + ok=0 + for _ in $(seq 1 60); do + if docker exec openldap ldapsearch -x -H ldap://127.0.0.1:1389 -D "cn=admin,dc=example,dc=org" -w adminpassword -b "dc=example,dc=org" -s base >/dev/null 2>&1; then + ok=$((ok+1)) + if [ "$ok" -ge 3 ]; then break; fi + else + ok=0 + fi + sleep 1 + done + if [ "$ok" -lt 3 ]; then + echo "OpenLDAP did not become ready" >&2 + 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 c51fdfb..18483c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,5 @@ # dev t/servroot - -# 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..2711e95 100644 --- a/Makefile +++ b/Makefile @@ -1,52 +1,17 @@ -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 + prove -r t/; ret=$$?; git apply t/patch/unknown_op.patch -R; exit $$ret + ### 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..6145e9d 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ 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 @@ -84,3 +84,57 @@ 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"]`. + +#### connect + +**syntax:** *ok, err = client:connect()* + +Establishes and pins the underlying connection, so that subsequent operations on this client (for example a `simple_bind` followed by a `search`) run on that same connection instead of each drawing one from the connection pool. Release the connection with `set_keepalive` or `close` when done. + +An unrecoverable socket error also releases the pin, so the next operation draws a fresh connection instead of reusing the dead one. + +#### set_keepalive + +**syntax:** *ok, err = client:set_keepalive()* + +Releases a pinned connection back into the connection pool. The client remains usable; subsequent operations draw pooled connections as before. + +#### close + +**syntax:** *ok, err = client:close()* + +Closes a pinned connection without returning it to the pool. + +### 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 server rejects the credentials, and `nil` on a connect/TLS/transport failure; in both failure cases `err` carries the error. The connection is always closed after the bind attempt — a socket that carried a Bind is never returned to the pool. + +`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 | localhost | LDAP server host. | +| `ldap_port` | number | 389 | LDAP server port. | +| `timeout` | number | 10000 | Socket timeout in milliseconds. | +| `keepalive` | number | 60000 | Accepted for v0.1.0 compatibility, but unused: sockets that carried a bind are always closed, never pooled. | +| `start_tls` | boolean | false | Issue the StartTLS extended operation before binding. | +| `ldaps` | boolean | false | Connect using LDAP over TLS. | +| `tls_verify` | boolean | false | Verify the server certificate during the TLS handshake. This is the key APISIX's `ldap-auth` passes. | +| `verify_ldap_host` | boolean | false | Legacy alias for `tls_verify` (pre-0.2). Honoured only when `tls_verify` is not set. | +| `base_dn` | string | ou=users,dc=example,dc=org | 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..0583026 --- /dev/null +++ b/lib/resty/ldap.lua @@ -0,0 +1,106 @@ +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_host = "localhost", + ldap_port = 389, + ldaps = false, + base_dn = "ou=users,dc=example,dc=org", + 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 + + +local function resolve_tls_verify(conf) + if conf.tls_verify ~= nil then + return conf.tls_verify + end + if conf.verify_ldap_host ~= nil then + return conf.verify_ldap_host + end + return false +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) + + -- 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 + + 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 = resolve_tls_verify(conf), + }) + + local ok, err = cli:connect() + if not ok then + log(ERR, "failed to connect to ", conf.ldap_host, ":", + tostring(conf.ldap_port), ": ", err) + return nil, err + end + + local who = conf.attribute .. "=" .. escape_dn_value(given_username) .. + "," .. conf.base_dn + local is_authenticated, bind_err = cli:simple_bind(who, given_password) + + -- the socket carried a Bind attempt: always close it, never pool it + cli:close() + + if is_authenticated == nil then + -- transport failure mid-bind; the client already dropped the socket + 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/asn1.lua b/lib/resty/ldap/asn1.lua index a248f65..190a35e 100644 --- a/lib/resty/ldap/asn1.lua +++ b/lib/resty/ldap/asn1.lua @@ -1,12 +1,18 @@ -local ffi = require("ffi") -local C = ffi.C -local ffi_new = ffi.new -local ffi_string = ffi.string -local string_char = string.char -local new_tab = require("resty.core.base").new_tab +local ffi = require("ffi") +local C = ffi.C +local ffi_new = ffi.new +local ffi_string = ffi.string +local ffi_cast = ffi.cast +local band = require("bit").band +local string_byte = string.byte +local string_char = string.char +local string_format = string.format +local string_sub = string.sub +local new_tab = require("resty.core.base").new_tab local ucharpp = ffi_new("unsigned char*[1]") local charpp = ffi_new("char*[1]") +local cucharpp = ffi_new("const unsigned char*[1]") ffi.cdef [[ @@ -22,6 +28,9 @@ ffi.cdef [[ void ASN1_INTEGER_free(ASN1_INTEGER *a); void ASN1_STRING_free(ASN1_STRING *a); + // frees the buffer i2d_* allocates when *pp is NULL + void CRYPTO_free(void *ptr, const char *file, int line); + long ASN1_INTEGER_get(const ASN1_INTEGER *a); long ASN1_ENUMERATED_get(const ASN1_ENUMERATED *a); @@ -76,56 +85,104 @@ local TAG = { _M.TAG = TAG +-- ASN1_put_object emits at most 11 header bytes (tag + long-form length) +local hdrbuf = ffi_new("unsigned char[16]") + +-- length is a C int; guard against FFI-boundary truncation/clamping +local INT_MAX = 2147483647 + local function asn1_put_object(tag, class, constructed, data, len) - len = type(data) == "string" and #data or len or 0 - if len < 0 then + -- a non-string payload would coerce via tostring() with len 0, emitting a + -- header that disagrees with the bytes that follow it + if data ~= nil and type(data) ~= "string" then + return nil, "invalid object payload" + end + if data ~= nil then + len = #data + elseif len == nil then + len = 0 + end + if type(len) ~= "number" or len % 1 ~= 0 or len < 0 or len > INT_MAX then return nil, "invalid object length" end - local outbuf = ffi_new("unsigned char[?]", len) - ucharpp[0] = outbuf - + -- read back exactly the header bytes written; a strlen-based read would + -- truncate a length octet containing 0x00 (e.g. 256 -> 82 01 00) + ucharpp[0] = hdrbuf C.ASN1_put_object(ucharpp, constructed, len, tag, class) + local header = ffi_string(hdrbuf, ucharpp[0] - hdrbuf) if not data then - return ffi_string(outbuf) + return header end - return ffi_string(outbuf) .. data + return header .. data end _M.put_object = asn1_put_object +-- 2^53: largest magnitude a Lua 5.1 double holds exactly; matches the decoder +local INT_EXACT = 9007199254740992 + local encode do local encoder = new_tab(0, 3) + -- refuse values the C long conversion would truncate/clamp; the integrality + -- test also rejects NaN and +/-inf ((0/0)%1 and (1/0)%1 are NaN) + local function integer_encodable(val) + return type(val) == "number" and val % 1 == 0 + and val <= INT_EXACT and val >= -INT_EXACT + end + + -- i2d with *pp == NULL allocates a buffer we own; copy it out then free it, + -- else a worker leaks one encoding per encode. charpp is module-level. + local function take_i2d_output(ret) + if ret <= 0 then + charpp[0] = nil + return nil, "failed to encode ASN.1 value" + end + local out = ffi_string(charpp[0], ret) + C.CRYPTO_free(charpp[0], "asn1.lua", 0) + charpp[0] = nil + return out + end + -- Integer encoder[TAG.INTEGER] = function(val) + if not integer_encodable(val) then + return nil, "INTEGER value is not an exactly representable integer" + end local typ = C.ASN1_INTEGER_new() C.ASN1_INTEGER_set(typ, val) charpp[0] = nil local ret = C.i2d_ASN1_INTEGER(typ, charpp) C.ASN1_INTEGER_free(typ) - return ffi_string(charpp[0], ret) + return take_i2d_output(ret) end -- Octet String encoder[TAG.OCTET_STRING] = function(val) + if type(val) ~= "string" then + return nil, "OCTET STRING value must be a string" + end local typ = C.ASN1_OCTET_STRING_new() C.ASN1_STRING_set(typ, val, #val) charpp[0] = nil local ret = C.i2d_ASN1_OCTET_STRING(typ, charpp) C.ASN1_STRING_free(typ) - return ffi_string(charpp[0], ret) + return take_i2d_output(ret) end encoder[TAG.ENUMERATED] = function(val) + if not integer_encodable(val) then + return nil, "ENUMERATED value is not an exactly representable integer" + end local typ = C.ASN1_ENUMERATED_new() C.ASN1_ENUMERATED_set(typ, val) charpp[0] = nil local ret = C.i2d_ASN1_ENUMERATED(typ, charpp) - C.ASN1_INTEGER_free(typ) - return ffi_string(charpp[0], ret) + C.ASN1_INTEGER_free(typ) -- shares the ASN1_STRING struct + return take_i2d_output(ret) end encoder[TAG.SEQUENCE] = function(val) @@ -137,8 +194,13 @@ do end encoder[TAG.BOOLEAN] = function(val) + -- reject non-booleans: under Lua truthiness 0 is true, so a caller's + -- 0-for-false would silently encode as TRUE + if type(val) ~= "boolean" then + return nil, "BOOLEAN value must be a boolean" + end -- tag(BOOLEAN), length(1), value(TRUE: 0xFF, FALSE: 0) - return string_char(1, 1, val and 0xff or 0) + return string_char(TAG.BOOLEAN, 1, val and 0xff or 0) end function encode(val, tag) @@ -153,12 +215,208 @@ do end end - if encoder[tag] then - return encoder[tag](val) + local enc = encoder[tag] + if not enc then + -- return an error, not a bare nil callers would concatenate + return nil, string_format("no encoder for ASN.1 tag %s (value of type %s)", + tostring(tag), type(val)) end + return enc(val) end end _M.encode = encode +local MAX_DECODE_DEPTH = 100 + +local asn1_get_object +do + local lenp = ffi_new("long[1]") + local tagp = ffi_new("int[1]") + local classp = ffi_new("int[1]") + local strpp = ffi_new("const unsigned char*[1]") + + function asn1_get_object(der, start, stop) + start = start or 0 + stop = stop or #der + -- validate offsets before they feed pointer arithmetic and omax below + if type(start) ~= "number" or start < 0 or start % 1 ~= 0 + or type(stop) ~= "number" or stop % 1 ~= 0 then + return nil, "invalid offset" + end + if stop <= start or stop > #der then + return nil, "invalid offset" + end + + local s_der = ffi_cast("const unsigned char *", der) + strpp[0] = s_der + start + + local ret = C.ASN1_get_object(strpp, lenp, tagp, classp, stop - start) + -- 0x80 signals an encoding error (incl. indefinite/too-long forms) + if band(ret, 0x80) == 0x80 or band(ret, 0x01) == 0x01 then + return nil, "invalid BER: bad tag/length encoding" + end + + -- X.690 s8.1.2.2/s8.1.2.4.2: tags 0..30 must use the low-tag-number form + -- and forbid a leading 0x80 padding octet. ASN1_get_object normalises the + -- non-canonical forms to the same tag, so guard them here. + local id = string_byte(der, start + 1) + if band(id, 0x1f) == 0x1f then + if tagp[0] < 31 then + return nil, "invalid BER: tag <= 30 must use the low-tag-number form" + end + if band(string_byte(der, start + 2) or 0, 0x7f) == 0 then + return nil, "invalid BER: non-canonical tag number padding" + end + end + + local content_off = strpp[0] - s_der + return { + tag = tagp[0], + class = classp[0], + len = tonumber(lenp[0]), + offset = content_off, -- 0-indexed content start + hl = content_off - start, -- header length (tag + length octets) + cons = band(ret, 0x20) == 0x20, + } + end +end +_M.get_object = asn1_get_object + + +local decode +do + local decoder = new_tab(0, 5) + + -- required form per universal type (X.690 s8.9.1/s8.11.1, RFC 4511 s5.1); + -- the wrong form is a BER-smuggling vector against auth parsers + local CONSTRUCTED_FORM = { + [TAG.INTEGER] = false, + [TAG.OCTET_STRING] = false, + [TAG.ENUMERATED] = false, + [TAG.SEQUENCE] = true, + [TAG.SET] = true, + } + + local TAG_NAME = { + [TAG.INTEGER] = "INTEGER", + [TAG.OCTET_STRING] = "OCTET STRING", + [TAG.ENUMERATED] = "ENUMERATED", + [TAG.SEQUENCE] = "SEQUENCE", + [TAG.SET] = "SET", + } + + -- refuse magnitudes a Lua double can't hold exactly; RFC 4511 caps LDAP + -- integers at 2^31-1 so nothing legitimate is lost. Compared on the int64 + -- cdata, before tonumber() rounds. + local INT_EXACT_MAX = 9007199254740992LL -- 2^53 + + local function exact_number(v, what) + if v > INT_EXACT_MAX or v < -INT_EXACT_MAX then + return nil, what .. " out of representable range" + end + return tonumber(v) + end + + -- OCTET STRING: slice raw content bytes; binary/NUL-safe (no d2i, no strlen) + decoder[TAG.OCTET_STRING] = function(der, _, obj) + return string_sub(der, obj.offset + 1, obj.offset + obj.len) + end + + -- d2i parses a full TLV, so offset is the TLV start. >8 content octets + -- cannot fit a C long, so the magnitude is out of range. + decoder[TAG.INTEGER] = function(der, offset, obj) + if obj.len > 8 then + return nil, "INTEGER out of representable range" + end + cucharpp[0] = ffi_cast("const unsigned char *", der) + offset + local typ = C.d2i_ASN1_INTEGER(nil, cucharpp, obj.hl + obj.len) + if typ == nil then + return nil, "invalid INTEGER encoding" + end + local v = C.ASN1_INTEGER_get(typ) + C.ASN1_INTEGER_free(typ) + return exact_number(v, "INTEGER") + end + + decoder[TAG.ENUMERATED] = function(der, offset, obj) + if obj.len > 8 then + return nil, "ENUMERATED out of representable range" + end + cucharpp[0] = ffi_cast("const unsigned char *", der) + offset + local typ = C.d2i_ASN1_ENUMERATED(nil, cucharpp, obj.hl + obj.len) + if typ == nil then + return nil, "invalid ENUMERATED encoding" + end + local v = C.ASN1_ENUMERATED_get(typ) + C.ASN1_INTEGER_free(typ) -- shares the ASN1_STRING struct + return exact_number(v, "ENUMERATED") + end + + local function decode_children(der, _, obj, depth) + local stop = obj.offset + obj.len + local pos = obj.offset + local values = {} + local n = 0 + while pos < stop do + local value, err + -- bound to the parent's content so an overrunning child cannot + -- absorb a sibling's bytes without an error + pos, value, err = decode(der, pos, stop, depth + 1) + if err then + return nil, err + end + -- decode() never returns a nil value with a nil err, so no holes + n = n + 1 + values[n] = value + end + return values + end + + decoder[TAG.SEQUENCE] = decode_children + decoder[TAG.SET] = decode_children + + -- offset is 0-indexed; stop bounds the TLV to its parent, defaulting to the + -- whole buffer. Returns next_offset, value, err. + function decode(der, offset, stop, depth) + offset = offset or 0 + depth = depth or 0 + if depth > MAX_DECODE_DEPTH then + return nil, nil, "max decode depth exceeded" + end + local obj, err = asn1_get_object(der, offset, stop) + if not obj then + return nil, nil, err + end + + -- dispatch on class + tag + form; tag alone would confuse + -- context-specific [4]/[16]/[17] with universal types + if obj.class ~= CLASS.UNIVERSAL then + return nil, nil, string_format("unsupported element: class 0x%02x tag %d", + obj.class, obj.tag) + end + local d = decoder[obj.tag] + if not d then + -- error, not a silent nil ("absent" vs "present but unknown") + return nil, nil, string_format("unsupported ASN.1 tag %d", obj.tag) + end + local want_cons = CONSTRUCTED_FORM[obj.tag] + if obj.cons ~= want_cons then + return nil, nil, string_format("%s must be %s", TAG_NAME[obj.tag], + want_cons and "constructed" or "primitive") + end + + local value, derr = d(der, offset, obj, depth) + if derr then + return nil, nil, derr + end + if value == nil then + return nil, nil, string_format("%s produced no value", TAG_NAME[obj.tag]) + end + return obj.offset + obj.len, value + end +end +_M.decode = decode + + return _M diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 442dbfc..e29edfd 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -1,36 +1,44 @@ 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 local _M = {} local mt = { __index = _M } +-- returns body length + header bytes, or nil + error (length is validated) local function calculate_payload_length(encStr, pos, socket) local elen 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,7 +46,11 @@ 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 local function _start_tls(sock) @@ -56,7 +68,11 @@ local function _start_tls(sock) end 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, 2, sock) + if not packet_len then + sock:close() + return lerr + end local packet, err = sock:receive(packet_len) if not packet then @@ -65,16 +81,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 +98,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 +113,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) @@ -133,6 +162,7 @@ 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 return fmt("do TLS handshake on %s:%s failed: %s", @@ -143,11 +173,26 @@ 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 (and its pin) after an unrecoverable error +local function _reset_socket(cli) + local sock = cli.socket + cli.socket = nil + cli.pinned = nil + cli.bound = nil + if sock then + sock:close() + end +end + +local function _send_receive(cli, request, multi_resp_hint) + -- In a pinned session the socket is already checked out; otherwise check out + -- one for this single operation. + if not cli.pinned then + local err = _init_socket(cli) + if err then + cli.bound = nil + return nil, fmt("initialize socket failed: %s", err) + end end local socket = cli.socket @@ -155,13 +200,10 @@ 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 @@ -171,15 +213,20 @@ local function _send_recieve(cli, request, multi_resp_hint) -- 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(2) if not len then if err == "timeout" then - socket:close() + _reset_socket(cli) return nil, fmt("receive response failed: %s", err) end break -- read done, data has been taken to the end end - local _, packet_len, packet_header = calculate_payload_length(len, 2, socket) + + local packet_len, packet_header, lerr = calculate_payload_length(len, 2, socket) + if not packet_len then + _reset_socket(cli) + return nil, lerr + end -- Get the data of the specified length local packet, err = socket:receive(packet_len) @@ -187,18 +234,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) @@ -219,8 +265,17 @@ local function _send_recieve(cli, request, multi_resp_hint) end end - -- put back into the connection pool - socket:setkeepalive(cli.socket_config.keepalive_timeout) + -- single-shot ops return the socket to the pool; a pinned session is + -- released explicitly by the caller. A bound socket carries the Bind + -- identity while the pool key does not, so close it instead of pooling. + if not cli.pinned then + if cli.bound then + cli.bound = nil + socket:close() + else + socket:setkeepalive(cli.socket_config.keepalive_timeout) + end + end return multi_resp_hint and result or result[1] end @@ -256,10 +311,63 @@ function _M.new(_, host, port, client_config) end +function _M.connect(self) + if self.pinned then + return true + end + local err = _init_socket(self) + if err then + return nil, err + end + self.pinned = true + return true +end + + +function _M.set_keepalive(self) + if not self.pinned then + return true + end + self.pinned = nil + local sock = self.socket + self.socket = nil + if not sock then + return true + end + if self.bound then + self.bound = nil + return sock:close() + end + return sock:setkeepalive(self.socket_config.keepalive_timeout) +end + + +function _M.close(self) + self.pinned = nil + self.bound = nil + 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)) + -- bind to a local first: as the last call 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 + + self.bound = true + 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 @@ -296,7 +404,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 ef25f56..04933ba 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -2,13 +2,17 @@ local asn1 = require("resty.ldap.asn1") local filter_compiler = require("resty.ldap.filter") local asn1_put_object = asn1.put_object local asn1_encode = asn1.encode -local string_char = string.char +local asn1_get_object = asn1.get_object +local asn1_decode = asn1.decode +local string_format = string.format local _M = {} local ldapMessageId = 1 +local LDAP_MAX_INT = 2147483647 + _M.ERROR_MSG = { [1] = "Initialization of LDAP library failed", [4] = "Size limit exceeded", @@ -25,6 +29,8 @@ _M.APP_NO = { SearchRequest = 3, SearchResultEntry = 4, SearchResultDone = 5, + ModifyResponse = 7, + SearchResultReference = 19, ExtendedRequest = 23, ExtendedResponse = 24 } @@ -35,6 +41,9 @@ local function ldap_message(app_no, req) asn1_put_object(app_no, asn1.CLASS.APPLICATION, 1, req) ldapMessageId = ldapMessageId + 1 + if ldapMessageId > LDAP_MAX_INT then + ldapMessageId = 1 + end return ldapMsg end @@ -48,14 +57,23 @@ end function _M.simple_bind_request(dn, password) - local ldapAuth = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, password or "") - if not password then - -- When password is nil, ASN1_put_object does not generate a zero length for it, - -- so we need to fill it in manually. - -- This is a compatibility measure for anonymous bind. - ldapAuth = ldapAuth .. string_char(0) - end - local bindReq = asn1_encode(3) .. asn1_encode(dn or "") .. ldapAuth + -- simple [0] OCTET STRING; a zero-length password encodes as `80 00`. + -- Pin the LDAPString type: put_object rejects a non-string payload, which for + -- a numeric password would encode an empty (unauthenticated) bind. Coerce + -- numbers; reject anything else. + dn = dn == nil and "" or dn + password = password == nil and "" or password + if type(dn) == "number" then dn = tostring(dn) end + if type(password) == "number" then password = tostring(password) end + if type(dn) ~= "string" then + return nil, "bind dn must be a string" + end + if type(password) ~= "string" then + return nil, "bind password must be a string" + end + + local ldapAuth = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, password) + local bindReq = asn1_encode(3) .. asn1_encode(dn) .. ldapAuth local ldapMsg = ldap_message(_M.APP_NO.BindRequest, bindReq) return asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) end @@ -157,12 +175,27 @@ end function _M.search_request(base_obj, scope, deref_aliases, size_limit, time_limit, types_only, filter, attributes) - local base_obj = asn1_encode(base_obj, asn1.TAG.OCTET_STRING) - local scope = asn1_encode(scope, asn1.TAG.ENUMERATED) - local deref_aliases = asn1_encode(deref_aliases, asn1.TAG.ENUMERATED) - local size_limit = asn1_encode(size_limit, asn1.TAG.INTEGER) - local time_limit = asn1_encode(time_limit, asn1.TAG.INTEGER) - local types_only = asn1_encode(types_only, asn1.TAG.BOOLEAN) + -- typesOnly is a BOOLEAN, but callers write 0 for false among the numeric + -- args; Lua treats 0 as true, so normalise the numeric spelling here + if types_only == nil or types_only == 0 then + types_only = false + elseif type(types_only) == "number" then + types_only = true + end + + -- surface asn1.encode's (nil, err) here; the results are concatenated below + local base_obj, err = asn1_encode(base_obj, asn1.TAG.OCTET_STRING) + if not base_obj then return nil, err end + local scope, err = asn1_encode(scope, asn1.TAG.ENUMERATED) + if not scope then return nil, err end + local deref_aliases, err = asn1_encode(deref_aliases, asn1.TAG.ENUMERATED) + if not deref_aliases then return nil, err end + local size_limit, err = asn1_encode(size_limit, asn1.TAG.INTEGER) + if not size_limit then return nil, err end + local time_limit, err = asn1_encode(time_limit, asn1.TAG.INTEGER) + if not time_limit then return nil, err end + local types_only, err = asn1_encode(types_only, asn1.TAG.BOOLEAN) + if not types_only then return nil, err end -- compile filter local filter_tbl, err = filter_compiler.compile(filter) @@ -185,4 +218,185 @@ function _M.search_request(base_obj, scope, deref_aliases, size_limit, time_limi end +-- Response decoding. + +-- Every decode below passes the enclosing element's end offset, so a field that +-- overruns its parent is rejected instead of swallowing the next field's bytes. + +-- decode one element and require the ASN.1 type RFC 4511 assigns to the field; +-- asn1.decode checks class, but the tag must be pinned per field. Returns +-- next_offset, value, err. +local function decode_typed(packet, pos, stop, tag, what) + local obj, err = asn1_get_object(packet, pos, stop) + if not obj then return nil, nil, err end + if obj.class ~= asn1.CLASS.UNIVERSAL or obj.tag ~= tag then + return nil, nil, string_format("%s has the wrong ASN.1 type (class 0x%02x tag %d)", + what, obj.class, obj.tag) + end + return asn1_decode(packet, pos, stop) +end + +-- hand-walked containers skip asn1.decode's dispatch, so assert their form here +local function is_sequence(obj) + return obj.class == asn1.CLASS.UNIVERSAL + and obj.tag == asn1.TAG.SEQUENCE + 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, + [_M.APP_NO.SearchResultDone] = true, + [_M.APP_NO.ModifyResponse] = true, + [_M.APP_NO.ExtendedResponse] = true, +} + +local function parse_ldap_result(packet, op, res) + -- RFC 4511 s4.1.9: LDAPResult ::= SEQUENCE { resultCode ENUMERATED, + -- matchedDN LDAPDN, diagnosticMessage LDAPString, referral [3] OPTIONAL } + local stop = op.offset + op.len + local _, pos, code, matched_dn, diag, err + pos, code, err = decode_typed(packet, op.offset, stop, + asn1.TAG.ENUMERATED, "resultCode") + if err then return nil, err end + pos, matched_dn, err = decode_typed(packet, pos, stop, + asn1.TAG.OCTET_STRING, "matchedDN") + if err then return nil, err end + _, diag, err = decode_typed(packet, pos, stop, + asn1.TAG.OCTET_STRING, "diagnosticMessage") + if err then return nil, err end + res.result_code = code + res.matched_dn = matched_dn + res.diagnostic_msg = diag + -- no whole-op check: optional trailing fields (referral [3], SASL creds, + -- responseName/Value) legally follow and AD emits them constantly + return res +end + +local function parse_search_entry(packet, op, res) + -- RFC 4511 s4.5.2: SearchResultEntry ::= [APPLICATION 4] SEQUENCE + -- { objectName LDAPDN, attributes PartialAttributeList } + local stop = op.offset + op.len + local pos, entry_dn, err + pos, entry_dn, err = decode_typed(packet, op.offset, stop, + asn1.TAG.OCTET_STRING, "objectName") + if err then return nil, err end + res.entry_dn = entry_dn + + -- PartialAttributeList ::= SEQUENCE OF PartialAttribute + local attrs, aerr = asn1_get_object(packet, pos, stop) + if not attrs then return nil, aerr end + if not is_sequence(attrs) then + return nil, "PartialAttributeList is not a universal constructed SEQUENCE" + end + + local attributes = {} + local apos = attrs.offset + local astop = attrs.offset + attrs.len + while apos < astop do + -- PartialAttribute ::= SEQUENCE { type AttributeDescription, + -- vals SET OF AttributeValue } + local pa, perr = asn1_get_object(packet, apos, astop) + if not pa then return nil, perr end + if not is_sequence(pa) then + return nil, "PartialAttribute is not a universal constructed SEQUENCE" + end + local pastop = pa.offset + pa.len + -- becomes a table key, so its string type is load-bearing + local vpos, atype, terr = decode_typed(packet, pa.offset, pastop, + asn1.TAG.OCTET_STRING, + "AttributeDescription") + if terr then return nil, terr end + -- vals is a SET OF: enforce it so the stored value is always an array + local _, vals, verr = decode_typed(packet, vpos, pastop, + asn1.TAG.SET, "attribute vals") + if verr then return nil, verr end + attributes[atype] = vals -- ALWAYS an array (empty for typesOnly) + apos = pastop + end + -- SearchResultEntry has exactly two components; nothing may follow + if astop ~= stop then + return nil, "trailing bytes in SearchResultEntry" + end + res.attributes = attributes + return res +end + +local function parse_search_reference(packet, op, res) + -- [APPLICATION 19] IMPLICIT replaces the SEQUENCE OF tag, so `op` IS the + -- sequence. RFC 4511 s4.5.2: SearchResultReference ::= [APPLICATION 19] + -- SEQUENCE SIZE (1..MAX) OF uri URI, with URI ::= LDAPString (s4.1.10). + local uris = {} + local n = 0 + local pos = op.offset + local stop = op.offset + op.len + while pos < stop do + local uri, err + pos, uri, err = decode_typed(packet, pos, stop, asn1.TAG.OCTET_STRING, + "SearchResultReference URI") + if err then return nil, err end + n = n + 1 + uris[n] = uri + end + res.uris = uris + return res +end + +function _M.decode_message(packet) + local env, err = asn1_get_object(packet, 0) + if not env then return nil, err end + -- LDAPMessage is a constructed SEQUENCE (X.690 s8.9.1) + if not is_sequence(env) then + return nil, "invalid LDAPMessage envelope" + end + + local envstop = env.offset + env.len + -- the envelope must account for every byte; trailing data is a second + -- message or garbage that would otherwise be silently discarded + if envstop ~= #packet then + return nil, "trailing bytes after LDAPMessage" + end + + -- messageID ::= INTEGER (0..maxInt), RFC 4511 s4.1.1 + local pos, message_id, merr = decode_typed(packet, env.offset, envstop, + asn1.TAG.INTEGER, "messageID") + if merr then return nil, merr end + + local op, oerr = asn1_get_object(packet, pos, envstop) -- protocolOp + if not op then return nil, oerr end + if op.class ~= asn1.CLASS.APPLICATION then + return nil, "protocolOp is not APPLICATION-tagged" + end + -- every response protocolOp is a constructed SEQUENCE + if not op.cons then + return nil, "protocolOp is not constructed" + end + + -- exactly one protocolOp per LDAPMessage; only controls [0] may follow it + -- (RFC 4511 s4.1.1). A second APPLICATION element is smuggled data. + local opend = op.offset + op.len + if opend ~= envstop then + local ctrl, cerr = asn1_get_object(packet, opend, envstop) + if not ctrl then return nil, cerr end + if ctrl.class ~= asn1.CLASS.CONTEXT_SPECIFIC or ctrl.tag ~= 0 then + return nil, "unexpected element after protocolOp" + end + if ctrl.offset + ctrl.len ~= envstop then + return nil, "trailing bytes after controls" + end + end + + local res = { message_id = message_id, protocol_op = op.tag } + + if op.tag == _M.APP_NO.SearchResultEntry then + return parse_search_entry(packet, op, res) + elseif op.tag == _M.APP_NO.SearchResultReference then + return parse_search_reference(packet, op, res) + elseif LDAP_RESULT_OPS[op.tag] then + return parse_ldap_result(packet, op, res) + end + return nil, "unsupported protocolOp " .. op.tag +end + + return _M diff --git a/rockspec/lua-resty-ldap-0.3.0-0.rockspec b/rockspec/lua-resty-ldap-0.3.0-0.rockspec new file mode 100644 index 0000000..654e787 --- /dev/null +++ b/rockspec/lua-resty-ldap-0.3.0-0.rockspec @@ -0,0 +1,29 @@ +package = "lua-resty-ldap" +version = "0.3.0-0" +source = { + url = "git+https://github.com/api7/lua-resty-ldap", + tag = "v0.3.0" +} + +description = { + summary = "Nonblocking Lua LDAP driver library for OpenResty", + homepage = "https://github.com/api7/lua-resty-ldap", + license = "Apache License 2.0", + maintainer = "Yuansheng Wang " +} + +dependencies = { + "lua_pack = 2.0.0-0", + "lpeg = 1.0.2-1", +} + +build = { + 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-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.t b/t/asn1.t new file mode 100644 index 0000000..5618ec0 --- /dev/null +++ b/t/asn1.t @@ -0,0 +1,196 @@ +# Test vectors: rasn v0.6.1 (octet_string); Go go1.26.3 encoding/asn1 (int64TestData). + +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: get_object reads tag/class/len/hl for short and long form +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- short form: 30 03 02 01 05 (SEQUENCE len 3) + local o = assert(asn1.get_object(ldap_hex("30 03 02 01 05"))) + assert(o.tag == 16, "tag " .. o.tag) -- SEQUENCE + assert(o.class == asn1.CLASS.UNIVERSAL, "class") + assert(o.cons == true, "cons") + assert(o.len == 3, "len " .. o.len) + assert(o.hl == 2, "hl " .. o.hl) + assert(o.offset == 2, "offset " .. o.offset) + + -- long form: 30 81 82 <130 bytes> (2-extra-byte header) + local body = string.rep("\0", 130) + local o2 = assert(asn1.get_object(ldap_hex("30 81 82") .. body)) + assert(o2.len == 130, "long len " .. o2.len) + assert(o2.hl == 3, "long hl " .. o2.hl) -- NOT 2 + + -- application-tagged: 64 00 (SearchResultEntry, empty) + local o3 = assert(asn1.get_object(ldap_hex("64 00"))) + assert(o3.tag == 4, "app tag " .. o3.tag) + assert(o3.class == asn1.CLASS.APPLICATION, "app class") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: decode octet string is NUL-safe (regression: no strlen truncation) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- 04 05 41 42 00 43 44 => "AB\0CD" (5 bytes, embedded NUL) + local _, v = asn1.decode(ldap_hex("04 05 41 42 00 43 44")) + assert(#v == 5, "length " .. #v) -- a strlen-based read would stop at the NUL + assert(v == "AB\0CD", "value") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: decode integer, enumerated, and nested SEQUENCE/SET (uses hl, not +2) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, id = asn1.decode(ldap_hex("02 01 03")) + assert(id == 3, "int " .. tostring(id)) + local _, code = asn1.decode(ldap_hex("0a 01 00")) + assert(code == 0, "enum " .. tostring(code)) + + -- SET OF two octet strings: 31 0d 04 03 746f70 04 06 646f6d61696e + local _, vals = asn1.decode(ldap_hex("31 0d 04 03 74 6f 70 04 06 64 6f 6d 61 69 6e")) + assert(type(vals) == "table", "set is table") + assert(vals[1] == "top" and vals[2] == "domain", "set values") + + -- long-form SET (>=128 bytes of content) parses via hl, not +2 + local big = string.rep("\4\1X", 60) -- 60 octet strings "X" = 180 bytes + local seq = ldap_hex("31 81 b4") .. big -- 0xb4 = 180 + local _, arr = asn1.decode(seq) + assert(#arr == 60, "long set count " .. #arr) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: malformed input returns error, never crashes +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- claims 5 content bytes, only 2 present + local off, v, err = asn1.decode(ldap_hex("30 05 02 01")) + assert(v == nil, "no value on malformed") + assert(err ~= nil, "error reported") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: decoder errors surface, not swallowed as empty success +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- truncated-child case is original: exercises the `stop` bound (asn1.lua:412) + + -- truncated child: outer len 3, inner OCTET STRING claims 5 but 1 present + local _, v, err = asn1.decode(ldap_hex("30 03 04 05 41")) + assert(v == nil, "no value for truncated child") + assert(err ~= nil, "truncated child reports error") + + -- zero-length INTEGER: d2i_ASN1_INTEGER returns nil + local _, v2, err2 = asn1.decode(ldap_hex("02 00")) + assert(v2 == nil, "no value for zero-len INTEGER") + assert(err2 ~= nil, "zero-len INTEGER reports error") + + -- zero-length ENUMERATED: d2i_ASN1_ENUMERATED returns nil + local _, v3, err3 = asn1.decode(ldap_hex("0a 00")) + assert(v3 == nil, "no value for zero-len ENUMERATED") + assert(err3 ~= nil, "zero-len ENUMERATED reports error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: constructed OCTET STRING (0x24) is rejected (BER smuggling) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- constructed OCTET STRING: inner TLVs must not be handed back (RFC 4511 s5.1) + local _, v, err = asn1.decode(ldap_hex("24 07 04 02 41 42 04 01 43")) + assert(v == nil, "constructed OCTET STRING yields no value") + assert(err ~= nil, "constructed OCTET STRING reports error") + + -- primitive OCTET STRING still decodes normally + local _, v2 = asn1.decode(ldap_hex("04 02 41 42")) + assert(v2 == "AB", "primitive OCTET STRING still works") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_bounds.t b/t/asn1_bounds.t new file mode 100644 index 0000000..de484c9 --- /dev/null +++ b/t/asn1_bounds.t @@ -0,0 +1,378 @@ +# Test vectors: pyasn1 v0.6.4 (LengthFieldLimit, NestingDepthLimit); Go encoding/asn1. + +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: degenerate [start, stop) regions are rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local d = ldap_hex("04 03 41 42 43") -- 5 bytes + + -- empty region: start == stop + local o, e = asn1.get_object(d, 0, 0) + assert(o == nil and e ~= nil, "start==stop must error") + local o2, e2 = asn1.get_object(d, 3, 3) + assert(o2 == nil and e2 ~= nil, "start==stop mid-buffer must error") + + -- inverted region: start > stop + local o3, e3 = asn1.get_object(d, 4, 2) + assert(o3 == nil and e3 ~= nil, "start>stop must error") + + -- stop past the end of the buffer + local o4, e4 = asn1.get_object(d, 0, 99) + assert(o4 == nil and e4 ~= nil, "stop>#der must error") + + -- start at/past the end of the buffer + local o5, e5 = asn1.get_object(d, 5) + assert(o5 == nil and e5 ~= nil, "start==#der must error") + local o6, e6 = asn1.get_object(d, 6) + assert(o6 == nil and e6 ~= nil, "start>#der must error") + + -- empty buffer + local o7, e7 = asn1.get_object("") + assert(o7 == nil and e7 ~= nil, "empty der must error") + local n8, v8, e8 = asn1.decode("") + assert(v8 == nil and e8 ~= nil, "decode('') must error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: a negative start is rejected, never read out of bounds +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- guard at asn1.lua:187 has no lower bound; negative start reads before payload + for _, hex in ipairs({"04 03 41 42 43", "30 06 04 01 41 04 01 42", + "30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"}) do + local d = ldap_hex(hex) + for s = -8, -1 do + local o, e = asn1.get_object(d, s) + assert(o == nil, "get_object must reject start=" .. s .. + " (returned tag=" .. tostring(o and o.tag) .. + " len=" .. tostring(o and o.len) .. + " offset=" .. tostring(o and o.offset) .. ")") + assert(e ~= nil, "negative start must report an error, s=" .. s) + end + end + + -- decode() must refuse too, and never return a negative next-offset + local d = ldap_hex("30 06 04 01 41 04 01 42") + for s = -8, -1 do + local n, v, e = asn1.decode(d, s) + assert(e ~= nil, "decode must error on start=" .. s .. + " (got next=" .. tostring(n) .. ")") + assert(n == nil or n >= 0, "decode must not return a negative offset, s=" .. s) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: non-integer offsets are rejected; hl is always a whole number +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- fractional start escapes with hl = 1.5, corrupting d2i decoding + local d = ldap_hex("04 02 41 42 43") + local o, e = asn1.get_object(d, 0.5) + assert(o == nil, "fractional start must be rejected (got hl=" .. + tostring(o and o.hl) .. ")") + assert(e ~= nil, "fractional start must report an error") + + local ds, es = asn1.get_object(d, 0, 4.5) + assert(ds == nil and es ~= nil, "fractional stop must be rejected") + + -- same INTEGER decodes to 5 at start 0 but fails at start 0.5 (hl+len truncated) + local di = ldap_hex("02 01 05 41 42") + local _, v0 = asn1.decode(di, 0) + assert(v0 == 5, "INTEGER at start 0 is 5, got " .. tostring(v0)) + local _, vf, ef = asn1.decode(di, 0.5) + assert(ef ~= nil, "fractional start must error, not silently mis-decode") + + -- fractional start at a NONZERO offset where flooring would land on a valid TLV: + -- rejection must be pinned, not left to a downstream parse error + local der2 = ldap_hex("41 04 03 41 42 43 00 00 00 00") + local og, eg = asn1.get_object(der2, 1.9, #der2) + assert(og == nil, "fractional start 1.9 must be rejected, not floored onto the TLV at 1" + .. (og and (" (got hl=" .. tostring(og.hl) .. ")") or "")) + assert(eg ~= nil, "fractional start 1.9 must report an error") + local _, vg, dg = asn1.decode(der2, 1.2) + assert(vg == nil and dg ~= nil, + "decode must reject fractional offset 1.2 instead of silently flooring it") + + -- whenever get_object succeeds, every reported offset must be integral + local buffers = { + ldap_hex("04 03 41 42 43"), + ldap_hex("30 81 82") .. string.rep("\0", 130), + ldap_hex("04 81 03 41 42 43"), + } + for i, buf in ipairs(buffers) do + local ok = assert(asn1.get_object(buf), "buffer " .. i) + assert(ok.hl % 1 == 0, "hl must be integral, got " .. tostring(ok.hl)) + assert(ok.offset % 1 == 0, "offset must be integral") + assert(ok.len % 1 == 0, "len must be integral") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: zero-length content and content ending exactly at the buffer end +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- string_sub with len == 0 must yield "", not a slice from the end + local n, v, e = asn1.decode(ldap_hex("04 00")) + assert(e == nil, "zero-length OCTET STRING must decode") + assert(v == "", "zero-length value must be empty string, got " .. tostring(v)) + assert(n == 2, "next offset must be 2, got " .. tostring(n)) + + -- zero-length constructed types + local _, seq = asn1.decode(ldap_hex("30 00")) + assert(type(seq) == "table" and #seq == 0, "empty SEQUENCE is an empty array") + local _, set = asn1.decode(ldap_hex("31 00")) + assert(type(set) == "table" and #set == 0, "empty SET is an empty array") + + -- content ends exactly on the last byte of the buffer + local n2, v2, e2 = asn1.decode(ldap_hex("04 03 41 42 43")) + assert(e2 == nil and v2 == "ABC", "value at buffer end") + assert(n2 == 5, "next offset equals #der, got " .. tostring(n2)) + + -- zero-length element as the very last byte pair of the buffer + local n3, v3 = asn1.decode(ldap_hex("30 02 04 00")) + assert(type(v3) == "table" and #v3 == 1 and v3[1] == "", "trailing empty child") + assert(n3 == 4, "next offset " .. tostring(n3)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: child TLVs are bounded exactly by the parent's content +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- child ends EXACTLY on the parent boundary: must decode + local n, v, e = asn1.decode(ldap_hex("30 05 04 03 41 42 43")) + assert(e == nil, "exact fit must not error: " .. tostring(e)) + assert(type(v) == "table" and #v == 1 and v[1] == "ABC", "exact-fit value") + assert(n == 7, "next offset " .. tostring(n)) + + -- two children ending exactly on both parent and buffer boundary + local n2, v2, e2 = asn1.decode(ldap_hex("30 06 04 01 41 04 01 42")) + assert(e2 == nil and #v2 == 2 and v2[1] == "A" and v2[2] == "B", "two exact children") + assert(n2 == 8, "next offset " .. tostring(n2)) + + -- child overshoots parent by exactly 1 byte, buffer long enough: reject + local _, v3, e3 = asn1.decode(ldap_hex("30 04 04 03 41 42 43")) + assert(v3 == nil and e3 ~= nil, "overshoot by 1 must be rejected") + + -- child header itself is truncated by the parent bound + local _, v4, e4 = asn1.decode(ldap_hex("30 01 04 03 41 42 43")) + assert(v4 == nil and e4 ~= nil, "truncated child header must be rejected") + + -- one stray byte left inside the parent after the last child + local _, v5, e5 = asn1.decode(ldap_hex("30 07 04 01 41 04 01 42 43")) + assert(v5 == nil and e5 ~= nil, "leftover byte inside parent must be rejected") + + -- MAX_DECODE_DEPTH: 100 levels ok, 101 rejected, and neither raises + local function nest(k) + local s = ldap_hex("04 00") + for _ = 1, k do + s = asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, s) + end + return s + end + local ok100, _, _, e100 = pcall(asn1.decode, nest(100)) + assert(ok100, "depth 100 must not raise") + assert(e100 == nil, "depth 100 must decode, got " .. tostring(e100)) + local ok101, _, v101, e101 = pcall(asn1.decode, nest(101)) + assert(ok101, "depth 101 must not raise") + assert(v101 == nil and e101 ~= nil, "depth 101 must be rejected") + local okdeep, _, vdeep, edeep = pcall(asn1.decode, nest(2000)) + assert(okdeep, "depth 2000 must not raise (no C-stack blowout)") + assert(vdeep == nil and edeep ~= nil, "depth 2000 must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: zero-length children always advance pos (no spin in decode_children) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- zero-length child still costs a 2-byte header, so pos always advances + local n, v, e = asn1.decode(ldap_hex("30 06 04 00 04 00 04 00")) + assert(e == nil, "three empty children must decode: " .. tostring(e)) + assert(type(v) == "table" and #v == 3, "three empty children, got " .. + tostring(type(v) == "table" and #v or v)) + assert(v[1] == "" and v[2] == "" and v[3] == "", "all empty") + assert(n == 8, "next offset " .. tostring(n)) + + -- stress: 1000 zero-length children must terminate, not spin + local body = string.rep(ldap_hex("04 00"), 1000) + local seq = asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, body) + local n2, v2, e2 = asn1.decode(seq) + assert(e2 == nil, "1000 empty children: " .. tostring(e2)) + assert(#v2 == 1000, "1000 children, got " .. tostring(#v2)) + assert(n2 == #seq, "consumed whole buffer") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: bytes trailing the PartialAttributeList inside the op are rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- parse_search_entry stops at attrs end, not op end: trailing bytes ride along + local baseline = assert(protocol.decode_message( + ldap_hex("30 0a 02 01 03 64 05 04 01 78 30 00"))) + assert(baseline.entry_dn == "x", "baseline decodes") + + -- same entry, op grown from 5 to 9 bytes with `de ad be ef` appended + local res, err = protocol.decode_message( + ldap_hex("30 0e 02 01 03 64 09 04 01 78 30 00 de ad be ef")) + assert(res == nil, "trailing bytes inside the op must not decode " .. + "(got entry_dn=" .. tostring(res and res.entry_dn) .. ")") + assert(err ~= nil, "trailing bytes inside the op must report an error") + + -- a trailing element that is itself well-formed BER is equally invalid + local res2, err2 = protocol.decode_message( + ldap_hex("30 0f 02 01 03 64 0a 04 01 78 30 00 04 03 41 42 43")) + assert(res2 == nil and err2 ~= nil, "trailing TLV inside the op must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 8: BER leniency on non-minimal lengths is preserved (guard rail) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- RFC 4511 s5.1 wants DER, but real servers emit BER non-minimal lengths: keep them + local _, v, e = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(e == nil and v == "ABC", "1-octet long form must still decode, got " .. + tostring(v) .. "/" .. tostring(e)) + local _, v2, e2 = asn1.decode(ldap_hex("04 82 00 03 41 42 43")) + assert(e2 == nil and v2 == "ABC", "2-octet long form must still decode") + + -- hl reflects the non-minimal header, and the next offset accounts for it + local o = assert(asn1.get_object(ldap_hex("04 81 03 41 42 43"))) + assert(o.hl == 3 and o.len == 3 and o.offset == 3, "hl/len/offset " .. + o.hl .. "/" .. o.len .. "/" .. o.offset) + local n = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(n == 6, "next offset must be 6, got " .. tostring(n)) + + -- nested inside a parent whose length accounts for the longer header + local _, arr, e3 = asn1.decode(ldap_hex("30 06 04 81 03 41 42 43")) + assert(e3 == nil, "non-minimal child: " .. tostring(e3)) + assert(type(arr) == "table" and #arr == 1 and arr[1] == "ABC", "non-minimal child value") + + -- end to end: BindResponse matchedDN carries a 1-octet long-form length + local res = assert(protocol.decode_message( + ldap_hex("30 0e 02 01 01 61 09 0a 01 00 04 81 01 41 04 00"))) + assert(res.result_code == 0, "code") + assert(res.matched_dn == "A", "matched_dn " .. tostring(res.matched_dn)) + assert(res.diagnostic_msg == "", "diag") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_class.t b/t/asn1_class.t new file mode 100644 index 0000000..14b0fda --- /dev/null +++ b/t/asn1_class.t @@ -0,0 +1,408 @@ +# Test vectors: rasn v0.6.1 (tagged_boolean). + +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: decode() dispatches on tag NUMBER only -- full class x P/C matrix +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- every decoder tag x 4 classes x P/C: only universal correct-form yields a value + local UNIV, APPL, CTXT, PRIV = 0x00, 0x40, 0x80, 0xc0 + local payload = { + [2] = { prim = "01 05", cons = "03 02 01 05" }, + [4] = { prim = "03 41 42 43", cons = "05 04 03 41 42 43" }, + [10] = { prim = "01 07", cons = "03 0a 01 07" }, + [16] = { prim = "03 04 01 41", cons = "03 04 01 41" }, + [17] = { prim = "03 04 01 41", cons = "03 04 01 41" }, + } + local names = { [2]="INTEGER", [4]="OCTET_STRING", [10]="ENUMERATED", + [16]="SEQUENCE", [17]="SET" } + local cnames = { [UNIV]="UNIVERSAL", [APPL]="APPLICATION", + [CTXT]="CONTEXT", [PRIV]="PRIVATE" } + + local leaks = {} + for _, tn in ipairs({2, 4, 10, 16, 17}) do + for _, cls in ipairs({UNIV, APPL, CTXT, PRIV}) do + for _, consbit in ipairs({0x00, 0x20}) do + local id = tn + cls + consbit + local cons = consbit == 0x20 + local hex = string.format("%02x ", id) .. + (cons and payload[tn].cons or payload[tn].prim) + -- SEQUENCE/SET must be constructed; the scalar types primitive + local want_ok = (cls == UNIV) and + (((tn == 16 or tn == 17) and cons) or + ((tn == 2 or tn == 4 or tn == 10) and not cons)) + + local _, v, err = asn1.decode(ldap_hex(hex)) + local cell_ok + if want_ok then + cell_ok = (err == nil and v ~= nil) + else + -- rejection must be explicit: an error and no value, never a silent nil + cell_ok = (err ~= nil and v == nil) + end + -- non-universal tag-4 cells must never alias universal OCTET STRING + if tn == 4 and cls ~= UNIV and v == "ABC" then + cell_ok = false + end + if not cell_ok then + table.insert(leaks, string.format( + "%s %s %s (id=%02x) want_%s got value=%s err=%s", + names[tn], cnames[cls], cons and "cons" or "prim", id, + want_ok and "value" or "error", + type(v) == "table" and "" or tostring(v), + tostring(err))) + end + end + end + end + + -- cross-checked vectors (pyasn1 v0.6.4 / rasn v0.6.1 / Go encoding/asn1): + -- APPLICATION [5] and constructed context [2] must error explicitly, never silent-nil + local _, exp, e7 = asn1.decode( + ldap_hex("65 11 04 0f 51 75 69 63 6b 20 62 72 6f 77 6e 20 66 6f 78")) + assert(e7 ~= nil, "APPLICATION [5] must report an error, not a silent nil") + assert(exp == nil, "APPLICATION [5] must not yield a value") + local _, tb, e6 = asn1.decode(ldap_hex("a2 03 01 01 ff")) + assert(tb == nil, "context [2] constructed must not yield a value") + assert(e6 ~= nil, "context [2] constructed must report an error") + assert(not e6:find("INTEGER"), + "error for a context-specific tag must not blame INTEGER, got: " .. e6) + + if #leaks > 0 then + ngx.say(#leaks .. " class/form mismatches accepted:") + ngx.say(table.concat(leaks, "\n")) + else + ngx.say("ok") + end + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: tightening class dispatch must not break legal universal encodings +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Guard rail for the class fix: these must all keep working. + local _, v = asn1.decode(ldap_hex("04 03 41 42 43")) + assert(v == "ABC", "universal OCTET STRING: " .. tostring(v)) + local _, i = asn1.decode(ldap_hex("02 01 05")) + assert(i == 5, "universal INTEGER: " .. tostring(i)) + local _, e = asn1.decode(ldap_hex("0a 01 07")) + assert(e == 7, "universal ENUMERATED: " .. tostring(e)) + local _, s = asn1.decode(ldap_hex("30 03 04 01 41")) + assert(type(s) == "table" and s[1] == "A", "universal SEQUENCE") + local _, t = asn1.decode(ldap_hex("31 03 04 01 41")) + assert(type(t) == "table" and t[1] == "A", "universal SET") + + -- BER non-minimal length still accepted: real directory servers emit it + local _, nm = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(nm == "ABC", "non-minimal length must still decode: " .. tostring(nm)) + local _, nm2 = asn1.decode(ldap_hex("30 81 03 04 01 41")) + assert(type(nm2) == "table" and nm2[1] == "A", "non-minimal SEQUENCE length") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: LDAPMessage envelope and messageID must be universal types +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- baseline BindResponse still decodes + local ok_res = assert(protocol.decode_message( + ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok_res.message_id == 1, "baseline message_id") + + -- envelope tag 16 but primitive (0x10): cons bit unchecked, walks as SEQUENCE + local r1, e1 = protocol.decode_message( + ldap_hex("10 0c 02 01 01 61 07 0a 01 00 04 00 04 00")) + assert(r1 == nil, "primitive envelope 0x10 must be rejected") + assert(e1 ~= nil, "primitive envelope reports an error") + + -- messageID is INTEGER; context-specific [4] currently yields the string "A" + local r2, e2 = protocol.decode_message( + ldap_hex("30 0c 84 01 41 61 07 0a 01 00 04 00 04 00")) + assert(r2 == nil, "messageID as context [4] must be rejected, got message_id=" + .. tostring(r2 and r2.message_id)) + assert(e2 ~= nil, "context messageID reports an error") + + -- messageID as context-specific [16]: currently yields a table + local r3, e3 = protocol.decode_message( + ldap_hex("30 0b 90 00 61 07 0a 01 00 04 00 04 00")) + assert(r3 == nil, "messageID as context [16] must be rejected, got type=" + .. type(r3 and r3.message_id)) + assert(e3 ~= nil, "context [16] messageID reports an error") + + -- messageID as BOOLEAN (no decoder) currently succeeds with message_id == nil; + -- a nil/mistyped id breaks request/response correlation (RFC 4511 s4.1.1) + local r4, e4 = protocol.decode_message( + ldap_hex("30 0c 01 01 ff 61 07 0a 01 00 04 00 04 00")) + assert(r4 == nil, "BOOLEAN messageID must be rejected, got message_id=" + .. tostring(r4 and r4.message_id)) + assert(e4 ~= nil, "BOOLEAN messageID reports an error") + + -- messageID as OCTET STRING (a registered decoder): message_id must not become "A" + local r5, e5 = protocol.decode_message( + ldap_hex("30 0c 04 01 41 61 07 0a 01 00 04 00 04 00")) + assert(r5 == nil, "OCTET STRING messageID must be rejected, got message_id=" + .. tostring(r5 and r5.message_id)) + assert(e5 ~= nil, "OCTET STRING messageID reports an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: LDAPResult fields reject class-mismatched elements +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- BindResponse is parsed pre-auth; resultCode ENUMERATED, matchedDN/diag OCTET STRING + local cases = { + { "resultCode as context [4]", "30 0c 02 01 01 61 07 84 01 41 04 00 04 00" }, + { "resultCode as context [16]", "30 0b 02 01 01 61 06 90 00 04 00 04 00" }, + { "resultCode as private [17]", "30 0b 02 01 01 61 06 f1 00 04 00 04 00" }, + { "matchedDN as private [16]", "30 0c 02 01 01 61 07 0a 01 00 d0 00 04 00" }, + { "diagnosticMessage as appl [4]", "30 0d 02 01 01 61 08 0a 01 00 04 00 44 01 41" }, + } + + local leaks = {} + for _, c in ipairs(cases) do + local res, err = protocol.decode_message(ldap_hex(c[2])) + if res ~= nil or err == nil then + table.insert(leaks, string.format( + "%s accepted (result_code=%s/%s matched_dn=%s diag=%s)", + c[1], + type(res and res.result_code) == "table" and "
" + or tostring(res and res.result_code), + type(res and res.result_code), + type(res and res.matched_dn), + type(res and res.diagnostic_msg))) + end + end + + -- callers concatenate result_code into an error string, so its type must be guaranteed + local good = assert(protocol.decode_message( + ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(type(good.result_code) == "number", "result_code is a number") + assert(type(good.matched_dn) == "string", "matched_dn is a string") + assert(type(good.diagnostic_msg) == "string", "diagnostic_msg is a string") + + if #leaks > 0 then + ngx.say(#leaks .. " LDAPResult class confusions accepted:") + ngx.say(table.concat(leaks, "\n")) + else + ngx.say("ok") + end + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: SearchResultEntry -- hostile server controls the attribute type tag +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- baseline: objectName "x", attribute "s" with an empty vals SET + local base = 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(base.entry_dn == "x", "baseline entry_dn") + assert(type(base.attributes.s) == "table", "baseline vals is an array") + + -- protocol.lua:228-232 type-checks none of these; the decoded type becomes a table key + local cases = { + -- attribute type as context-specific [4]: currently keys as "s" + { "attr type as context [4]", + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 84 01 73 31 00" }, + -- attribute type as context-specific [16]: currently a TABLE key + { "attr type as context [16]", + "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 90 00 31 00" }, + -- attr type BOOLEAN decodes to nil; `attributes[nil] = vals` raises + { "attr type as BOOLEAN", + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00" }, + -- vals as context [4]: attributes.s becomes a STRING, not an array + { "vals as context [4]", + "30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 04 01 73 84 03 41 42 43" }, + -- attribute value as context [4] INSIDE a well-formed vals SET: pins asn1.decode's + -- recursive-descent class gate at a nested position (distinct from "vals as + -- context [4]" above, which hits protocol.lua's container pin instead) + { "attr value as context [4] inside vals", + "30 16 02 01 03 64 11 04 01 78 30 0c 30 0a 04 01 6d 31 05 84 03 41 42 43" }, + -- objectName as private [16]: entry_dn becomes a table + { "objectName as private [16]", + "30 10 02 01 03 64 0b d0 00 30 07 30 05 04 01 73 31 00" }, + } + + local leaks = {} + for _, c in ipairs(cases) do + -- pcall: a malformed packet must return (nil, err), never throw + local pok, res, err = pcall(protocol.decode_message, ldap_hex(c[2])) + if not pok then + table.insert(leaks, c[1] .. " RAISED a Lua error: " .. tostring(res)) + elseif res ~= nil or err == nil then + local ktypes = {} + for k, v in pairs(res.attributes or {}) do + table.insert(ktypes, type(k) .. "->" .. type(v)) + end + table.insert(leaks, string.format( + "%s accepted (entry_dn=%s attributes=[%s])", + c[1], type(res.entry_dn), table.concat(ktypes, ","))) + end + end + + if #leaks > 0 then + ngx.say(#leaks .. " SearchResultEntry class confusions:") + ngx.say(table.concat(leaks, "\n")) + else + ngx.say("ok") + end + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: SearchResultEntry container tags are not validated at all +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- parse_search_entry uses only .offset/.len, so any tag walks as a SEQUENCE + local cases = { + -- PartialAttributeList sent as a primitive OCTET STRING + { "attribute list as OCTET STRING 04", + "30 11 02 01 03 64 0c 04 01 78 04 07 30 05 04 01 73 31 00" }, + -- PartialAttribute sent as a primitive OCTET STRING + { "PartialAttribute as OCTET STRING 04", + "30 11 02 01 03 64 0c 04 01 78 30 07 04 05 04 01 73 31 00" }, + -- vals SET sent APPLICATION-class primitive + { "vals SET as application [17]", + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 04 01 73 51 00" }, + } + + local leaks = {} + for _, c in ipairs(cases) do + local res, err = protocol.decode_message(ldap_hex(c[2])) + if res ~= nil or err == nil then + table.insert(leaks, c[1] .. " accepted") + end + end + + if #leaks > 0 then + ngx.say(#leaks .. " container tag confusions accepted:") + ngx.say(table.concat(leaks, "\n")) + else + ngx.say("ok") + end + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: SearchResultReference URIs must be universal OCTET STRINGs +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- baseline + local base = assert(protocol.decode_message( + ldap_hex("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78"))) + assert(base.uris[1] == "ldap://x", "baseline uri") + + -- RFC 4511: URIs are universal OCTET STRINGs; a class-confused URI steers a referral + local r1, e1 = protocol.decode_message( + ldap_hex("30 0f 02 01 02 73 0a 84 08 6c 64 61 70 3a 2f 2f 78")) + assert(r1 == nil, "context [4] URI must be rejected, got " + .. tostring(r1 and r1.uris[1])) + assert(e1 ~= nil, "context [4] URI reports an error") + + -- context [16] members currently come back as tables inside uris + local r2, e2 = protocol.decode_message(ldap_hex("30 09 02 01 02 73 04 90 00 90 00")) + assert(r2 == nil, "context [16] URI must be rejected, got " + .. type(r2 and r2.uris and r2.uris[1])) + assert(e2 ~= nil, "context [16] URI reports an error") + + -- second-position wrong-class URI: the gate must hold after a valid first member + local r3, e3 = protocol.decode_message(ldap_hex("30 0b 02 01 02 73 06 04 01 61 84 01 62")) + assert(r3 == nil, "second-position context [4] URI must be rejected") + assert(e3 ~= nil, "second-position context [4] URI reports an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_constructed.t b/t/asn1_constructed.t new file mode 100644 index 0000000..6368b70 --- /dev/null +++ b/t/asn1_constructed.t @@ -0,0 +1,138 @@ +# Test vectors: original; spec X.690 §8.7.1/§8.9.1/§8.11.1. + +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: primitive universal SEQUENCE/SET must be rejected, not walked +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Baseline: the constructed forms decode as containers. + local _, ok_seq = assert(asn1.decode(ldap_hex("30 03 02 01 05"))) + assert(type(ok_seq) == "table" and ok_seq[1] == 5, "constructed SEQUENCE decodes") + local _, ok_set = assert(asn1.decode(ldap_hex("31 03 02 01 05"))) + assert(type(ok_set) == "table" and ok_set[1] == 5, "constructed SET decodes") + + -- X.690 s8.9.1/s8.11.1: SEQUENCE/SET always constructed; 0x10/0x11 are illegal + local _, v1, e1 = asn1.decode(ldap_hex("10 03 02 01 05")) + assert(v1 == nil, "primitive SEQUENCE (0x10) yields no value, got " .. tostring(v1)) + assert(e1 ~= nil, "primitive SEQUENCE (0x10) reports an error") + + local _, v2, e2 = asn1.decode(ldap_hex("11 03 02 01 05")) + assert(v2 == nil, "primitive SET (0x11) yields no value, got " .. tostring(v2)) + assert(e2 ~= nil, "primitive SET (0x11) reports an error") + + -- empty primitive container: while loop never runs, returns {} silently + local _, v3, e3 = asn1.decode(ldap_hex("10 00")) + assert(v3 == nil, "empty primitive SEQUENCE yields no value") + assert(e3 ~= nil, "empty primitive SEQUENCE reports an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 2: the constructed check must not tighten accepted BER length leniency +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- guard rail: constructed-bit check must not reject BER non-minimal lengths + local _, v1 = assert(asn1.decode(ldap_hex("30 81 03 02 01 05"))) + assert(type(v1) == "table" and v1[1] == 5, "non-minimal-length SEQUENCE still decodes") + + local _, v2 = assert(asn1.decode(ldap_hex("31 81 03 04 01 41"))) + assert(type(v2) == "table" and v2[1] == "A", "non-minimal-length SET still decodes") + + local _, v3 = assert(asn1.decode(ldap_hex("04 81 03 41 42 43"))) + assert(v3 == "ABC", "non-minimal-length OCTET STRING still decodes, got " .. tostring(v3)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 3: primitive containers reached through decode_message must be rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- SearchResultEntry: dn "a", attribute "cn" with one value "x" + local function entry(pal, pa, set) + return "30 15 02 01 02 64 10 04 01 61 " .. pal .. " 0b " .. pa + .. " 09 04 02 63 6e " .. set .. " 03 04 01 78" + end + + local ok = assert(protocol.decode_message(ldap_hex(entry("30", "30", "31")))) + assert(ok.entry_dn == "a", "baseline entry_dn") + assert(ok.attributes.cn[1] == "x", "baseline attribute value") + + -- each flips one container to primitive; PAL/PA bypass decode_children + local cases = { + { "10", "30", "31", "PartialAttributeList primitive (0x10)" }, + { "30", "10", "31", "PartialAttribute primitive (0x10)" }, + { "30", "30", "11", "vals SET OF primitive (0x11)" }, + { "10", "10", "11", "every container primitive" }, + } + for _, c in ipairs(cases) do + local res, err = protocol.decode_message(ldap_hex(entry(c[1], c[2], c[3]))) + assert(res == nil, c[4] .. " must be rejected, got cn=" + .. tostring(res and res.attributes + and res.attributes.cn + and res.attributes.cn[1])) + assert(err ~= nil, c[4] .. " reports an error") + end + + -- the envelope itself: a primitive-encoded LDAPMessage must be rejected + local bad, berr = protocol.decode_message(ldap_hex("10 0c 02 01 01 61 07 0a 01 00 04 00 04 00")) + assert(bad == nil, "primitive LDAPMessage envelope must be rejected") + assert(berr ~= nil, "primitive LDAPMessage envelope must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_depth.t b/t/asn1_depth.t new file mode 100644 index 0000000..66ee41a --- /dev/null +++ b/t/asn1_depth.t @@ -0,0 +1,281 @@ +# Test vectors: pyasn1 v0.6.4 https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py + +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: unbounded nesting is refused with an error, never a crash +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + -- hand-rolled BER writer, independent of the library's encoder + local function tlv(tagbyte, content) + local n = #content + local len + if n < 128 then + len = string.char(n) + elseif n < 256 then + len = string.char(0x81, n) + elseif n < 65536 then + len = string.char(0x82, math.floor(n / 256), n % 256) + else + len = string.char(0x83, math.floor(n / 65536), + math.floor(n / 256) % 256, n % 256) + end + return string.char(tagbyte) .. len .. content + end + local function nest(tagbyte, n, inner) + local s = inner or "" + for _ = 1, n do s = tlv(tagbyte, s) end + return s + end + + for _, tb in ipairs({0x30, 0x31}) do + local label = string.format("tag 0x%02x", tb) + + -- comfortably inside the budget: must decode + local _, v, err = asn1.decode(nest(tb, 100)) + assert(err == nil, label .. " nest 100 errored: " .. tostring(err)) + assert(type(v) == "table", label .. " nest 100 value type") + + -- far outside it: must be a clean error, not a stack overflow + for _, deep in ipairs({102, 500, 2000}) do + local off, dv, derr = asn1.decode(nest(tb, deep)) + assert(dv == nil, label .. " nest " .. deep .. " returned a value") + assert(off == nil, label .. " nest " .. deep .. " returned an offset") + assert(derr == "max decode depth exceeded", + label .. " nest " .. deep .. " err = " .. tostring(derr)) + end + end + + -- context-specific [16] is not a SEQUENCE: refused on class grounds + local _, bv, berr = asn1.decode(nest(0xb0, 100)) + assert(bv == nil, "context [16] must not be walked as a container") + assert(berr ~= nil, "context [16] must report an error") + + -- alternating SEQ/SET must count the same as either alone + local mixed = "" + for i = 1, 500 do mixed = tlv(i % 2 == 0 and 0x30 or 0x31, mixed) end + local _, mv, merr = asn1.decode(mixed) + assert(mv == nil and merr == "max decode depth exceeded", + "alternating SEQ/SET nesting err = " .. tostring(merr)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: the enforced nesting bound matches MAX_DECODE_DEPTH +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function tlv(tagbyte, content) + local n = #content + local len + if n < 128 then len = string.char(n) + elseif n < 256 then len = string.char(0x81, n) + else len = string.char(0x82, math.floor(n / 256), n % 256) end + return string.char(tagbyte) .. len .. content + end + local function nest(tagbyte, n, inner) + local s = inner or "" + for _ = 1, n do s = tlv(tagbyte, s) end + return s + end + + for _, tb in ipairs({0x30, 0x31}) do + local label = string.format("tag 0x%02x", tb) + + local _, ok_v, ok_err = asn1.decode(nest(tb, 101)) + assert(ok_err == nil, label .. " 101 levels must decode: " .. tostring(ok_err)) + assert(type(ok_v) == "table", label .. " 101 levels value") + + local off, v, err = asn1.decode(nest(tb, 102)) + assert(v == nil, label .. " 102 levels must not yield a value") + assert(off == nil, label .. " 102 levels must not yield an offset") + assert(err == "max decode depth exceeded", + label .. " 102 levels err = " .. tostring(err)) + end + + -- same bound from a leaf: 101 containers + OCTET STRING = 102 levels, refused + local _, okv, okerr = asn1.decode(nest(0x30, 100, string.char(0x04, 0x01, 0x41))) + assert(okerr == nil and type(okv) == "table", + "100 containers + leaf must decode: " .. tostring(okerr)) + local _, lv, lerr = asn1.decode(nest(0x30, 101, string.char(0x04, 0x01, 0x41))) + assert(lv == nil and lerr == "max decode depth exceeded", + "101 containers + leaf err = " .. tostring(lerr)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: the depth budget is per path, not cumulative across siblings +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function tlv(tagbyte, content) + local n = #content + local len + if n < 128 then len = string.char(n) + elseif n < 256 then len = string.char(0x81, n) + elseif n < 65536 then len = string.char(0x82, math.floor(n / 256), n % 256) + else len = string.char(0x83, math.floor(n / 65536), + math.floor(n / 256) % 256, n % 256) end + return string.char(tagbyte) .. len .. content + end + local function nest(tagbyte, n, inner) + local s = inner or "" + for _ = 1, n do s = tlv(tagbyte, s) end + return s + end + + local siblings = "" + for _ = 1, 50 do siblings = siblings .. nest(0x30, 99) end + local _, v, err = asn1.decode(tlv(0x30, siblings)) + assert(err == nil, "wide-and-deep-but-legal errored: " .. tostring(err)) + assert(type(v) == "table", "wide-and-deep value type") + assert(#v == 50, "branch count " .. #v) + assert(type(v[1]) == "table" and type(v[50]) == "table", "branches are tables") + + -- a single over-budget branch is still caught next to good siblings + local mixed = nest(0x30, 10) .. nest(0x30, 200) .. nest(0x30, 10) + local _, bv, berr = asn1.decode(tlv(0x30, mixed)) + assert(bv == nil, "over-budget branch must not yield a value") + assert(berr == "max decode depth exceeded", "err = " .. tostring(berr)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: structural empties decode as empty arrays and keep their positions +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local off, v, err = asn1.decode(ldap_hex("30 00")) + assert(err == nil, "empty SEQUENCE errored: " .. tostring(err)) + assert(type(v) == "table" and #v == 0, "empty SEQUENCE is an empty array") + assert(off == 2, "empty SEQUENCE next offset " .. tostring(off)) + + -- 31 00 : empty SET + local off2, v2, err2 = asn1.decode(ldap_hex("31 00")) + assert(err2 == nil, "empty SET errored: " .. tostring(err2)) + assert(type(v2) == "table" and #v2 == 0, "empty SET is an empty array") + assert(off2 == 2, "empty SET next offset " .. tostring(off2)) + + -- nesting of empties: SEQUENCE { SEQUENCE {} } + local _, v3 = asn1.decode(ldap_hex("30 02 30 00")) + assert(#v3 == 1, "SEQ{empty SEQ} count " .. #v3) + assert(type(v3[1]) == "table" and #v3[1] == 0, "inner empty preserved") + + -- empty members must occupy array slots: SEQ { SEQ{}, SET{}, SEQ{SET{}} } + local _, v4 = asn1.decode(ldap_hex("30 08 30 00 31 00 30 02 31 00")) + assert(#v4 == 3, "mixed empties count " .. #v4) + assert(type(v4[1]) == "table" and #v4[1] == 0, "slot 1") + assert(type(v4[2]) == "table" and #v4[2] == 0, "slot 2") + assert(type(v4[3]) == "table" and #v4[3] == 1, "slot 3 holds one child") + assert(type(v4[3][1]) == "table" and #v4[3][1] == 0, "slot 3 inner empty") + + -- an empty container remains addressable at the deepest legal level + local deep = ldap_hex("30 00") + for _ = 1, 99 do + local n = #deep + local len + if n < 128 then len = string.char(n) + elseif n < 256 then len = string.char(0x81, n) + else len = string.char(0x82, math.floor(n / 256), n % 256) end + deep = string.char(0x30) .. len .. deep + end + local _, dv, derr = asn1.decode(deep) + assert(derr == nil, "100 levels ending in an empty SEQ errored: " .. tostring(derr)) + local t, levels = dv, 0 + while type(t) == "table" and t[1] ~= nil do levels = levels + 1; t = t[1] end + assert(levels == 99, "walked levels " .. levels) + assert(type(t) == "table" and #t == 0, "innermost is the empty SEQUENCE") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: a wide, shallow document decodes completely and in linear time +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + local n = 100000 + local body = string.rep("\4\1X", n) + local der = string.char(0x30, 0x83, + math.floor(#body / 65536), + math.floor(#body / 256) % 256, + #body % 256) .. body + + local t0 = ngx.now() + local off, v, err = asn1.decode(der) + ngx.update_time() + local elapsed = ngx.now() - t0 + + assert(err == nil, "wide document errored: " .. tostring(err)) + assert(type(v) == "table", "wide document value type") + assert(#v == n, "member count " .. #v .. ", expected " .. n) + assert(v[1] == "X" and v[n] == "X", "first/last member") + assert(off == #der, "consumed " .. tostring(off) .. " of " .. #der) + assert(elapsed < 2, "took " .. elapsed .. "s -- decode_children is not linear") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_encode.t b/t/asn1_encode.t new file mode 100644 index 0000000..61f410e --- /dev/null +++ b/t/asn1_encode.t @@ -0,0 +1,349 @@ +# Test vectors: rasn v0.6.1 https://github.com/librasn/rasn/blob/v0.6.1/src/ber/de.rs + +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: encode() reports an explicit error for values it cannot encode +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + local function must_fail(what, val, tag) + local ok, v, err = pcall(asn1.encode, val, tag) + if not ok then return end -- raised: loud enough + assert(v == nil, what .. ": encoded a value it should have rejected") + assert(err ~= nil, what .. ": returned a silent nil with no error") + end + + -- no tag, and the Lua type maps to no tag (asn1.lua:167-169) + must_fail("nil value", nil, nil) + must_fail("table value", {}, nil) + must_fail("function value", print, nil) + + -- explicit tag that has no encoder entry + must_fail("TAG.NULL", "x", asn1.TAG.NULL) + must_fail("TAG.EOC", "x", asn1.TAG.EOC) + must_fail("unknown tag 99", "x", 99) + + -- the supported paths still encode + assert(asn1.encode("abc") == "\4\3abc", "octet string") + assert(asn1.encode(5) == "\2\1\5", "integer") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: encode(INTEGER) must not silently truncate or saturate +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end + + -- ASN1_INTEGER_set takes a C long and LuaJIT would truncate/saturate, emitting a + -- DIFFERENT integer -- integer_encodable rejects non-integral values and |v| > 2^53 + -- in Lua before the FFI call, so the C-long hazard is prevented upstream. The + -- encoder must return (nil, err) for these -- never raise, never substitute. + local function must_fail(what, val) + local v, err = asn1.encode(val, asn1.TAG.INTEGER) + -- hex(v) must tolerate nil v: assert message is built eagerly + assert(v == nil, what .. ": encoded as " .. hex(v or "") .. " instead of erroring") + assert(err ~= nil, what .. ": silent nil") + end + + must_fail("3.7 (fraction truncated to 3)", 3.7) + must_fail("-3.7 (fraction truncated to -3)", -3.7) + must_fail("0.5 (fraction truncated to 0)", 0.5) + must_fail("2^63 (out of range)", 2^63) + must_fail("1e100 (clamped to LONG_MAX)", 1e100) + must_fail("1e300 (clamped to LONG_MAX)", 1e300) + must_fail("-1e300 (negative out-of-range)", -1e300) + must_fail("math.huge (clamped to LONG_MAX)", math.huge) + must_fail("NaN (becomes 0)", 0/0) + must_fail("2^53+2 (just past the exactness boundary)", 2^53 + 2) + + -- values that genuinely fit a long must keep working + for _, n in ipairs({0, 1, -1, 127, -128, 128, 65536, 2147483647, 2^31, -2^31, 2^53}) do + local enc = assert(asn1.encode(n, asn1.TAG.INTEGER), "encode " .. n) + local _, v, err = asn1.decode(enc) + assert(err == nil, "decode " .. n .. ": " .. tostring(err)) + assert(v == n, "round trip " .. n .. " -> " .. tostring(v)) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: encode -> decode round trip across the length-form boundaries +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + local lens = {0, 1, 2, 126, 127, 128, 129, 254, 255, 256, 257, 1000, 65535, 65536} + + for _, n in ipairs(lens) do + local s = string.rep("A", n) + local enc = assert(asn1.encode(s, asn1.TAG.OCTET_STRING), "OS encode n=" .. n) + local off, v, err = asn1.decode(enc) + assert(err == nil, "OS n=" .. n .. " err " .. tostring(err)) + assert(v == s, "OS n=" .. n .. " value mismatch (got " .. + (v and #v or -1) .. " bytes)") + assert(off == #enc, "OS n=" .. n .. " consumed " .. tostring(off) .. + " of " .. #enc) + end + + -- binary-safe: NUL, high bytes, and a byte that looks like a tag + for _, s in ipairs({"", "\0", "A\0B", "\255\254\0\1", "\4\3abc", string.rep("\0", 300)}) do + local enc = assert(asn1.encode(s, asn1.TAG.OCTET_STRING)) + local _, v = asn1.decode(enc) + assert(v == s, "binary round trip failed for " .. #s .. " bytes") + end + + -- constructed containers built with put_object round trip too + for _, kids in ipairs({0, 1, 42, 43, 85, 86, 200}) do + local body = string.rep("\4\1X", kids) -- 3 bytes each + for _, tag in ipairs({asn1.TAG.SEQUENCE, asn1.TAG.SET}) do + local enc = assert(asn1.encode(body, tag), "container encode") + local off, v, err = asn1.decode(enc) + assert(err == nil, "container err " .. tostring(err)) + assert(type(v) == "table", "container is not a table") + assert(#v == kids, "container kids " .. #v .. " ~= " .. kids) + assert(off == #enc, "container consumed " .. tostring(off) .. "/" .. #enc) + end + end + + -- ENUMERATED round trip (result codes, search scope/deref) + for _, n in ipairs({0, 1, 2, 3, 10, 32, 49, 127, 128, 255}) do + local enc = assert(asn1.encode(n, asn1.TAG.ENUMERATED)) + local _, v, err = asn1.decode(enc) + assert(err == nil and v == n, "ENUM " .. n .. " -> " .. tostring(v)) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: BOOLEAN encodes boolean-ness, not Lua truthiness, and survives a round trip +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local function hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end + + local function must_not_be_true(what, val) + local ok, v = pcall(asn1.encode, val, asn1.TAG.BOOLEAN) + if not ok then return end -- rejecting is fine + if v == nil then return end -- so is nil + assert(v ~= "\1\1\255", what .. " encoded as BOOLEAN TRUE (" .. hex(v) .. ")") + end + must_not_be_true("the number 0", 0) + must_not_be_true("the empty string", "") + must_not_be_true("the string 'false'", "false") + + assert(asn1.encode(true, asn1.TAG.BOOLEAN) == "\1\1\255", "true -> ff") + assert(asn1.encode(false, asn1.TAG.BOOLEAN) == "\1\1\0", "false -> 00") + + -- Round trip: a BOOLEAN the library encoded must not decode to a silent nil + for _, b in ipairs({true, false}) do + local enc = assert(asn1.encode(b, asn1.TAG.BOOLEAN)) + local off, v, err = asn1.decode(enc) + assert(off == #enc or err ~= nil, "boolean offset") + assert(v == b or err ~= nil, + "encode(" .. tostring(b) .. ") decoded to a silent nil") + end + + -- protocol consequence: types_only=0 must not encode typesOnly=TRUE + local req = assert(protocol.search_request("dc=x", 2, 0, 0, 0, 0, "(cn=a)", {})) + assert(not req:find("\1\1\255", 1, true), + "search_request(types_only=0) encoded typesOnly=TRUE: " .. hex(req)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: put_object never emits a header whose length disagrees with its payload +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end + + local function check(what, ...) + local ok, out, err = pcall(asn1.put_object, ...) + if not ok then return end -- raised: acceptable + if out == nil then + assert(err ~= nil, what .. ": silent nil from put_object") + return + end + local obj = asn1.get_object(out) + assert(obj, what .. ": emitted unparseable bytes " .. hex(out)) + assert(obj.hl + obj.len == #out, + what .. ": header declares " .. obj.len .. " content bytes but " .. + (#out - obj.hl) .. " follow (" .. hex(out) .. ")") + end + + check("number payload", 0, asn1.CLASS.CONTEXT_SPECIFIC, 0, 12345) + check("boolean payload", 0, asn1.CLASS.CONTEXT_SPECIFIC, 0, true) + check("table payload", 0, asn1.CLASS.CONTEXT_SPECIFIC, 0, {}) + + -- negative length is already rejected, and must stay rejected + local out, err = asn1.put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, nil, -1) + assert(out == nil and err ~= nil, "negative length rejected") + + -- string and header-only payloads stay correct + for _, n in ipairs({0, 1, 127, 128, 255, 256, 65535, 65536}) do + local enc = assert(asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, + string.rep("A", n))) + local obj = assert(asn1.get_object(enc), "n=" .. n) + assert(obj.len == n and obj.hl + n == #enc, "n=" .. n .. " header") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: simple_bind_request must not silently mis-encode a non-string dn/password +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local function hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end + + local function bind_fields(msg) + local env = assert(asn1.get_object(msg, 0), "envelope") + local op = assert(asn1.get_object(msg, env.offset + 3), "protocolOp") + assert(op.class == asn1.CLASS.APPLICATION and op.tag == 0, "not a BindRequest") + local stop = op.offset + op.len + local ver = assert(asn1.get_object(msg, op.offset, stop), "version") + local name = assert(asn1.get_object(msg, ver.offset + ver.len, stop), "name") + local auth = assert(asn1.get_object(msg, name.offset + name.len, stop), "auth") + return name, auth, stop + end + + -- baseline + local ok_msg = assert(protocol.simple_bind_request("cn=admin,dc=x", "s3cret")) + local name, auth, stop = bind_fields(ok_msg) + assert(name.tag == asn1.TAG.OCTET_STRING and name.class == asn1.CLASS.UNIVERSAL, "name tag") + assert(auth.offset + auth.len == stop, "auth covers the rest of the BindRequest") + + -- numeric password: put_object emits a zero-length (unauthenticated) bind + stray bytes + local numeric_pw = assert(protocol.simple_bind_request("cn=admin,dc=x", 12345)) + local _, a2, stop2 = bind_fields(numeric_pw) + assert(a2.len > 0, + "numeric password produced a ZERO-LENGTH (unauthenticated) simple bind: " .. + hex(numeric_pw)) + assert(a2.offset + a2.len == stop2, + "numeric password left " .. (stop2 - a2.offset - a2.len) .. + " stray bytes after the auth element: " .. hex(numeric_pw)) + + -- numeric dn: encode() dispatches on Lua type, emitting INTEGER not OCTET STRING + local ok3, numeric_dn = pcall(protocol.simple_bind_request, 12345, "s3cret") + if ok3 and numeric_dn then + local n3 = bind_fields(numeric_dn) + assert(n3.tag == asn1.TAG.OCTET_STRING, + "numeric dn encoded with tag " .. n3.tag .. + " (expected 4/OCTET STRING): " .. hex(numeric_dn)) + end + + -- a wrong-shape dn must be reported, not raise a bare Lua error + local ok4, res, err4 = pcall(protocol.simple_bind_request, {}, "s3cret") + assert(ok4, "table dn raised a raw Lua error: " .. tostring(res)) + assert(res == nil and err4 ~= nil, "table dn should return nil, err") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: the message ID wraps back to 1 past RFC 4511 maxInt +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local upvalue = require("upvalue") + local message_id_of = require("ldap_msgid") + + -- the counter is module-private; reach it through the upvalue + -- chain simple_bind_request -> ldap_message -> ldapMessageId + local ldap_message = assert( + upvalue.get(protocol.simple_bind_request, "ldap_message"), + "ldap_message upvalue not found") + assert(upvalue.set(ldap_message, "ldapMessageId", 2147483647)) + + local at_max = assert(protocol.simple_bind_request("cn=x", "pw")) + assert(message_id_of(at_max) == 2147483647, + "the request at maxInt itself is legal") + local wrapped = assert(protocol.simple_bind_request("cn=x", "pw")) + local id = message_id_of(wrapped) + assert(id == 1, "id must wrap to 1 past maxInt, got " .. tostring(id)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_encode_leak.t b/t/asn1_encode_leak.t new file mode 100644 index 0000000..1191f02 --- /dev/null +++ b/t/asn1_encode_leak.t @@ -0,0 +1,159 @@ +# Test vectors: original — FFI heap ownership, no upstream analogue. + +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: the i2d_* encoders must not leak the buffer OpenSSL allocates for them +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + -- i2d with NULL *pp makes OpenSSL malloc a buffer the encoder must free itself + + local function rss_kb() + local f = assert(io.open("/proc/self/status")) + local kb = 0 + for line in f:lines() do + local v = line:match("^VmRSS:%s+(%d+) kB") + if v then kb = tonumber(v) end + end + f:close() + return kb + end + + local function growth_mb(n, fn) + for i = 1, 100 do fn(i) end -- warm up JIT + allocator + collectgarbage("collect"); collectgarbage("collect") + local before = rss_kb() + for i = 1, n do fn(i) end + collectgarbage("collect"); collectgarbage("collect") + return (rss_kb() - before) / 1024 + end + + local SZ = 4000 + local val = string.rep("A", SZ) + + -- thresholds sit under each encoder's expected leak; small encoders need more iters + + -- Control: the SEQUENCE encoder (ASN1_put_object) allocates nothing + local control = growth_mb(20000, function() + return asn1.encode(val, asn1.TAG.SEQUENCE) + end) + assert(control < 8, + ("control (put_object) path itself grew %.1f MB -- " .. + "measurement is unreliable"):format(control)) + + -- ~76 MB if every i2d buffer leaked; a correct encoder stays flat + local octet = growth_mb(20000, function() + return asn1.encode(val, asn1.TAG.OCTET_STRING) + end) + assert(octet < 8, + ("i2d_ASN1_OCTET_STRING leaked: RSS grew %.1f MB over " .. + "20000 encodes (control %.1f MB)"):format(octet, control)) + + -- INTEGER/ENUMERATED leak ~11 bytes/call; ~15 MB at 500000 iters, 4 MB threshold + local int = growth_mb(500000, function(i) + return asn1.encode(i, asn1.TAG.INTEGER) + end) + assert(int < 4, + ("i2d_ASN1_INTEGER leaked: RSS grew %.1f MB over " .. + "500000 encodes"):format(int)) + + local enum = growth_mb(500000, function(i) + return asn1.encode(i % 128, asn1.TAG.ENUMERATED) + end) + assert(enum < 4, + ("i2d_ASN1_ENUMERATED leaked: RSS grew %.1f MB over " .. + "500000 encodes"):format(enum)) + + -- Freeing must not change a single output byte. + assert(asn1.encode("A", asn1.TAG.OCTET_STRING) == "\4\1A") + assert(asn1.encode("", asn1.TAG.OCTET_STRING) == "\4\0") + assert(asn1.encode(5, asn1.TAG.INTEGER) == "\2\1\5") + assert(asn1.encode("\0\255\0", asn1.TAG.OCTET_STRING) == "\4\3\0\255\0") + + ngx.say("ok") + } + } +--- request +GET /t +--- timeout: 60 +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: the leak is reachable from the public request builders +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + + -- bind/search requests build messageIDs and strings via the leaking encoders + + local function rss_kb() + local f = assert(io.open("/proc/self/status")) + local kb = 0 + for line in f:lines() do + local v = line:match("^VmRSS:%s+(%d+) kB") + if v then kb = tonumber(v) end + end + f:close() + return kb + end + + local function growth_mb(n, fn) + for i = 1, 50 do assert(fn(i)) end + collectgarbage("collect"); collectgarbage("collect") + local before = rss_kb() + for i = 1, n do assert(fn(i)) end + collectgarbage("collect"); collectgarbage("collect") + return (rss_kb() - before) / 1024 + end + + local dn = "cn=" .. string.rep("u", 1000) .. ",dc=example,dc=com" + + local bind = growth_mb(20000, function(i) + return protocol.simple_bind_request(dn, "secret", i) + end) + assert(bind < 8, + ("simple_bind_request leaked: RSS grew %.1f MB over " .. + "20000 requests"):format(bind)) + + local search = growth_mb(20000, function(i) + return protocol.search_request(dn, 2, 0, 0, 0, false, + "(objectClass=" .. string.rep("x", 500) .. ")", + { "cn", "mail", "uid" }, i) + end) + assert(search < 8, + ("search_request leaked: RSS grew %.1f MB over " .. + "20000 requests"):format(search)) + + ngx.say("ok") + } + } +--- request +GET /t +--- timeout: 60 +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_ffi.t b/t/asn1_ffi.t new file mode 100644 index 0000000..10e19b5 --- /dev/null +++ b/t/asn1_ffi.t @@ -0,0 +1,176 @@ +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: put_object header length must agree with the bytes it returns +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + -- Invariant: put_object returns exactly one TLV; reparsing consumes it all + local function self_consistent(res, what) + assert(type(res) == "string", what .. ": expected a string") + local o = assert(asn1.get_object(res), what .. ": result does not parse") + assert(o.hl + o.len == #res, + string.format("%s: header declares %d content bytes after a %d-byte " + .. "header but %d bytes were returned", + what, o.len, o.hl, #res)) + end + + -- Sanity: string payloads hold the invariant today. + for _, n in ipairs({0, 1, 127, 128, 300}) do + self_consistent(asn1.put_object(asn1.TAG.OCTET_STRING, asn1.CLASS.UNIVERSAL, + 0, string.rep("A", n)), + "string payload n=" .. n) + end + + -- a non-string payload is treated as length 0 but still appended: header disagrees + local res, err = asn1.put_object(asn1.TAG.OCTET_STRING, asn1.CLASS.UNIVERSAL, 0, 12345) + if res ~= nil then + self_consistent(res, "number payload 12345") + else + assert(err ~= nil, "rejecting a non-string payload must report an error") + end + + -- length above INT_MAX is silently clamped by FFI; the len < 0 guard misses it + for _, n in ipairs({2147483648, 4294967296, 2 ^ 53}) do + local r2, e2 = asn1.put_object(asn1.TAG.OCTET_STRING, asn1.CLASS.UNIVERSAL, 0, nil, n) + assert(r2 == nil, "length " .. string.format("%.0f", n) + .. " exceeds the int the C API accepts and must be rejected, " + .. "not silently clamped") + assert(e2 ~= nil, "over-large length must report an error") + end + + -- Non-integer lengths land in the same `int length` parameter. + local r3 = asn1.put_object(asn1.TAG.OCTET_STRING, asn1.CLASS.UNIVERSAL, 0, nil, 3.7) + assert(r3 == nil, "a fractional length must be rejected, not truncated to 3") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: high-tag-form encodings of low tags must not alias the short form +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- high-tag-number form aliases low tags (1f 04 == 04); RFC 4511 defines no tag >= 31 + local canonical = select(2, asn1.decode(ldap_hex("04 03 41 42 43"))) + assert(canonical == "ABC", "canonical OCTET STRING still decodes") + + for _, alias in ipairs({"1f 04 03 41 42 43", + "1f 80 04 03 41 42 43", + "1f 80 80 80 04 03 41 42 43"}) do + local o, gerr = asn1.get_object(ldap_hex(alias)) + assert(o == nil, "high-tag form '" .. alias .. "' must be rejected, not reported as tag " + .. tostring(o and o.tag)) + assert(gerr ~= nil, "high-tag form '" .. alias .. "' must report an error") + + local _, v, derr = asn1.decode(ldap_hex(alias)) + assert(v == nil, "high-tag form '" .. alias .. "' must not decode to " .. tostring(v)) + assert(derr ~= nil, "high-tag form '" .. alias .. "' must report a decode error") + end + + -- the alias reaches the LDAP message layer (envelope 30 -> 3f 80 10, op 61 -> 7f 01) + local ok_res = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok_res.result_code == 0, "canonical BindResponse still parses") + + local bad1, e1 = protocol.decode_message(ldap_hex("3f 80 10 0c 02 01 01 61 07 0a 01 00 04 00 04 00")) + assert(bad1 == nil and e1 ~= nil, + "an LDAPMessage envelope in high-tag form must be rejected") + + local bad2, e2 = protocol.decode_message(ldap_hex("30 0d 02 01 01 7f 01 07 0a 01 00 04 00 04 00")) + assert(bad2 == nil and e2 ~= nil, + "a protocolOp tag in high-tag form must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: FFI marshalling invariants that currently hold (regression guard) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- (a) get_object return contract: reachable values 0x00/0x20/0x21/0x80|x; 0x01 test load-bearing + local o0 = assert(asn1.get_object(ldap_hex("04 03 41 42 43"))) + assert(o0.cons == false, "ret 0x00 -> primitive") + local o20 = assert(asn1.get_object(ldap_hex("30 03 02 01 05"))) + assert(o20.cons == true, "ret 0x20 -> constructed") + -- ret 0x21: constructed indefinite length, forbidden by RFC 4511 s5.1 + assert(asn1.get_object(ldap_hex("30 80 02 01 05 00 00")) == nil, + "ret 0x21 (indefinite length) must be rejected by the 0x01 bit") + -- ret 0x80: TOO_LONG. OpenSSL still writes the shared buffers; don't trust them + assert(asn1.get_object(ldap_hex("30 05 02 01")) == nil, "ret 0x80 must be rejected") + assert(asn1.get_object(ldap_hex("1f")) == nil, "truncated high-tag header must be rejected") + + -- (b) shared module-level buffers must be snapshotted so nested calls don't clobber + local leaf = ldap_hex("31 81 84") .. string.rep("\4\1X", 44) + local der = ldap_hex("30 81 87") .. leaf + local outer = assert(asn1.get_object(der)) + assert(outer.tag == 16 and outer.len == 135 and outer.hl == 3 and outer.offset == 3, + "outer header parsed") + local inner = assert(asn1.get_object(der, outer.offset, outer.offset + outer.len)) + local deep = assert(asn1.get_object(der, inner.offset, inner.offset + inner.len)) + assert(inner.tag == 17 and inner.hl == 3 and inner.len == 132, "inner header parsed") + assert(deep.tag == 4 and deep.hl == 2 and deep.len == 1, "leaf header parsed") + assert(outer.tag == 16 and outer.len == 135 and outer.hl == 3 and outer.offset == 3 + and outer.cons == true, + "nested get_object calls must not disturb an outer result") + assert(outer ~= inner and inner ~= deep, "each call returns a distinct table") + + -- (c) offset/hl/len must be plain Lua numbers, not cdata + assert(type(outer.len) == "number", "len is a Lua number") + assert(type(outer.offset) == "number", "offset is a Lua number") + assert(type(outer.hl) == "number", "hl is a Lua number") + + -- (d) hdrbuf is 16 bytes; put_object worst case is 11 (high-tag path), not 6 + local worst = asn1.put_object(2147483647, asn1.CLASS.PRIVATE, 1, nil, 2147483647) + assert(#worst == 11, + "worst-case ASN1_put_object header is 11 bytes, got " .. #worst) + assert(#worst <= 16, "worst-case header must fit hdrbuf") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_integer.t b/t/asn1_integer.t new file mode 100644 index 0000000..3bd7208 --- /dev/null +++ b/t/asn1_integer.t @@ -0,0 +1,285 @@ +# pyasn1 v0.6.4 IntegerDecoderTestCase: https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py +# Go go1.26.3 int64TestData: https://github.com/golang/go/blob/go1.26.3/src/encoding/asn1/asn1_test.go + +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: INTEGER/ENUMERATED inside the exactly-representable range decode correctly +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local cases = { + {"02 01 00", 0}, + {"02 01 01", 1}, + {"02 01 ff", -1}, + {"02 01 7f", 127}, + {"02 02 00 80", 128}, + {"02 01 80", -128}, + {"02 02 00 ff", 255}, + {"02 02 01 00", 256}, + {"02 02 ff 7f", -129}, + {"02 04 7f ff ff ff", 2147483647}, -- 2^31-1 + {"02 05 00 80 00 00 00", 2147483648}, -- 2^31 + {"02 04 80 00 00 00", -2147483648}, + {"02 05 00 ff ff ff ff", 4294967295}, -- 2^32-1 + {"02 05 01 00 00 00 00", 4294967296}, -- 2^32 + {"02 07 1f ff ff ff ff ff ff", 9007199254740991}, -- 2^53-1 + {"02 07 20 00 00 00 00 00 00", 9007199254740992}, -- 2^53 + {"02 07 e0 00 00 00 00 00 00", -9007199254740992}, -- -2^53, negative boundary + -- ENUMERATED must agree with INTEGER over the same range + {"0a 01 00", 0}, + {"0a 01 ff", -1}, + {"0a 01 31", 49}, -- invalidCredentials + {"0a 01 20", 32}, -- noSuchObject + {"0a 04 7f ff ff ff", 2147483647}, + {"0a 05 00 ff ff ff ff", 4294967295}, + {"0a 05 01 00 00 00 00", 4294967296}, + } + for _, c in ipairs(cases) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err == nil, c[1] .. " unexpected error: " .. tostring(err)) + assert(v == c[2], c[1] .. " decoded " .. tostring(v) .. ", want " .. tostring(c[2])) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: no non-zero encoding may decode to 0 (resultCode success cannot be forged) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + for _, tag in ipairs({2, 10}) do + for b = 0, 255 do + local _, v = asn1.decode(string.char(tag, 1, b)) + if v == 0 then + assert(b == 0, string.format("%02x 01 %02x decoded to 0", tag, b)) + end + end + for b1 = 0, 255 do + for b2 = 0, 255 do + local _, v = asn1.decode(string.char(tag, 2, b1, b2)) + assert(v ~= 0, string.format("%02x 02 %02x %02x decoded to 0", tag, b1, b2)) + end + end + end + + -- LDAPMessage layer: overflowing resultCodes may never surface as 0 (success). + local hostile = { + "30 14 02 01 01 61 0f 0a 09 00 ff ff ff ff ff ff ff ff 04 00 04 00", -- 2^64-1 + "30 14 02 01 01 61 0f 0a 09 01 00 00 00 00 00 00 00 00 04 00 04 00", -- 2^64 + "30 14 02 01 01 61 0f 0a 09 ff 7f ff ff ff ff ff ff ff 04 00 04 00", -- -(2^63+1) + "30 14 02 01 01 61 0f 0a 09 fe 00 00 00 00 00 00 00 00 04 00 04 00", -- -2^65 + } + for _, pkt in ipairs(hostile) do + local res = protocol.decode_message(ldap_hex(pkt)) + if res then + assert(res.result_code ~= 0, "overflow forged a success resultCode: " .. pkt) + end + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: an INTEGER too large to represent exactly must error, not become -1 +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local overflow = { + {"02 09 00 80 00 00 00 00 00 00 00", "2^63"}, + {"02 09 00 80 00 00 00 00 00 00 01", "2^63+1"}, + {"02 09 00 ff ff ff ff ff ff ff ff", "2^64-1"}, + {"02 09 01 00 00 00 00 00 00 00 00", "2^64"}, + {"02 09 ff 7f ff ff ff ff ff ff ff", "-(2^63+1)"}, + {"02 09 fe 00 00 00 00 00 00 00 00", "-2^65"}, + {"02 0c 01 00 00 00 00 00 00 00 00 00 00 00", "2^88"}, + {"02 08 7f ff ff ff ff ff ff ff", "2^63-1 (maxint64, 8-octet: exact_number path, not the len>8 guard)"}, + {"02 09 80 00 00 00 00 00 00 00 00", "9-octet negative (Go encoding/asn1 int64 overflow vector)"}, + } + for _, c in ipairs(overflow) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err ~= nil, + c[2] .. " (" .. c[1] .. ") must be rejected, got " .. tostring(v)) + assert(v == nil, c[2] .. " must not yield a value alongside the error") + end + + -- and the sentinel must stay distinguishable from a genuine -1 + local _, real = asn1.decode(ldap_hex("02 01 ff")) + assert(real == -1, "genuine -1 must still decode as -1, got " .. tostring(real)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: an ENUMERATED too large to represent must error, and must never flip sign +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local overflow = { + {"0a 09 00 80 00 00 00 00 00 00 00", "2^63"}, + {"0a 09 00 ff ff ff ff ff ff ff ff", "2^64-1"}, + {"0a 09 01 00 00 00 00 00 00 00 00", "2^64"}, + {"0a 09 ff 7f ff ff ff ff ff ff ff", "-(2^63+1)"}, + {"0a 09 ff 00 00 00 00 00 00 00 00", "-2^64"}, + {"0a 09 fe 00 00 00 00 00 00 00 00", "-2^65"}, + {"0a 0c 01 00 00 00 00 00 00 00 00 00 00 00", "2^88"}, + {"0a 07 20 00 00 00 00 00 01", "2^53+1 (7-octet: ENUMERATED exact_number path, not the len>8 guard)"}, + } + for _, c in ipairs(overflow) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err ~= nil, + c[2] .. " (" .. c[1] .. ") must be rejected, got " .. tostring(v)) + assert(v == nil, c[2] .. " must not yield a value alongside the error") + end + + -- sign inversion: -2^65 is negative; decoder must never report positive. + local _, neg = asn1.decode(ldap_hex("0a 09 fe 00 00 00 00 00 00 00 00")) + assert(neg == nil or neg < 0, + "-2^65 decoded as positive " .. tostring(neg)) + + -- 4294967295 (< 2^53) must still decode; ENUM counterpart of TEST 3's -1 guard. + local _, legit = asn1.decode(ldap_hex("0a 05 00 ff ff ff ff")) + assert(legit == 4294967295, "genuine 4294967295 broke: " .. tostring(legit)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: distinct integers above 2^53 must not collapse onto the same value +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local pairs_ = { + {"02 07 20 00 00 00 00 00 00", "02 07 20 00 00 00 00 00 01", "2^53 vs 2^53+1"}, + {"02 08 7f ff ff ff ff ff ff fe", "02 08 7f ff ff ff ff ff ff ff", "2^63-2 vs 2^63-1"}, + {"0a 07 20 00 00 00 00 00 00", "0a 07 20 00 00 00 00 00 01", "ENUM 2^53 vs 2^53+1"}, + {"02 07 df ff ff ff ff ff ff", "02 07 e0 00 00 00 00 00 00", "-(2^53+1) vs -2^53"}, + } + for _, c in ipairs(pairs_) do + local _, a, aerr = asn1.decode(ldap_hex(c[1])) + local _, b, berr = asn1.decode(ldap_hex(c[2])) + assert(not (aerr == nil and berr == nil and a == b), + c[3] .. " both decoded to " .. tostring(a)) + end + + -- 2^53+1 is 9007199254740993; returning 9007199254740992 is silently wrong. + local _, v, err = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) + assert(err ~= nil or v ~= 9007199254740992, + "2^53+1 silently decoded as 2^53") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: overflowing messageID/resultCode is rejected at the LDAPMessage layer +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local bad = { + {"30 14 02 09 00 ff ff ff ff ff ff ff ff 61 07 0a 01 00 04 00 04 00", "messageID 2^64-1"}, + {"30 12 02 07 20 00 00 00 00 00 01 61 07 0a 01 00 04 00 04 00", "messageID 2^53+1"}, + {"30 14 02 01 01 61 0f 0a 09 00 ff ff ff ff ff ff ff ff 04 00 04 00", "resultCode 2^64-1 (must never surface as resultCode -1)"}, + {"30 14 02 01 01 61 0f 0a 09 fe 00 00 00 00 00 00 00 00 04 00 04 00", "resultCode -2^65"}, + } + 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 code=" .. + tostring(res and res.result_code) .. " msgid=" .. + tostring(res and res.message_id)) + end + + -- distinct wire messageIDs (2^53+1 vs 2^53) must never correlate to the same in-process id + local r1 = protocol.decode_message(ldap_hex("30 12 02 07 20 00 00 00 00 00 01 61 07 0a 01 00 04 00 04 00")) + local r2 = protocol.decode_message(ldap_hex("30 12 02 07 20 00 00 00 00 00 00 61 07 0a 01 00 04 00 04 00")) + assert(not (r1 and r2 and r1.message_id == r2.message_id), + "distinct wire messageIDs collided on " .. tostring(r1 and r1.message_id)) + + -- a well-formed message still decodes + local ok_res = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok_res.result_code == 0 and ok_res.message_id == 1, "baseline intact") + + -- RFC 4511 maxInt messageID still decodes (Go go1.26.3 encoding/asn1 INTEGER-boundary vector) + local max_res = assert(protocol.decode_message(ldap_hex("30 0f 02 04 7f ff ff ff 61 07 0a 01 00 04 00 04 00"))) + assert(max_res.message_id == 2147483647, "maxInt messageID " .. tostring(max_res.message_id)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_nil_member.t b/t/asn1_nil_member.t new file mode 100644 index 0000000..f6e0a78 --- /dev/null +++ b/t/asn1_nil_member.t @@ -0,0 +1,208 @@ +# pyasn1 v0.6.4 Sequence/SetDecoderTestCase DefMode: https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py +# Vectors also cross-checked against rasn v0.6.1 and Go encoding/asn1. + +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: an undecodable member must error, never shrink the array +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- SET { "A", BOOLEAN TRUE }: BOOLEAN has no decoder; slot silently dropped -> {"A"}. + local off, v, err = asn1.decode(ldap_hex("31 06 04 01 41 01 01 ff")) + assert(err ~= nil, "undecodable SET member must report an error") + assert(v == nil, "no value when a member cannot be decoded") + assert(off == nil, "no offset alongside the error") + + -- rasn v0.6.1 sequence() vector: the undecodable member is an IA5String (0x16) + local off2, v2, err2 = asn1.decode(ldap_hex("30 0a 16 05 53 6d 69 74 68 01 01 ff")) + assert(err2 ~= nil, "SEQUENCE with undecodable members must report an error, got value=" .. + tostring(v2) .. " n=" .. (type(v2) == "table" and #v2 or -1)) + assert(v2 == nil, "no partial value alongside the error") + assert(off2 == nil, "no offset alongside the error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: position is not silently reindexed, and all-undecodable is not "empty" +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Undecodable member FIRST: currently yields {"A"} at index 1 (member 2's value). + local off, v, err = asn1.decode(ldap_hex("31 06 01 01 ff 04 01 41")) + assert(err ~= nil, "leading undecodable member must report an error") + assert(v == nil, "no value when a member cannot be decoded") + assert(off == nil, "no offset alongside the error") + + -- pyasn1 v0.6.4: undecodable MIDDLE member (the suite's only middle-position case) + local off2, v2, err2 = asn1.decode(ldap_hex("30 09 02 01 01 01 01 ff 02 01 02")) + assert(err2 ~= nil, "undecodable middle member must error, not re-index the array") + assert(v2 == nil, "no partial array alongside the error") + assert(off2 == nil, "no offset alongside the error") + + -- SET with only an undecodable member currently decodes to {}, like a genuine empty SET. + local off3, v3, err3 = asn1.decode(ldap_hex("31 03 01 01 ff")) + assert(err3 ~= nil, "SET of one undecodable member must error") + assert(v3 == nil, "no value for an all-undecodable SET") + assert(off3 == nil, "no offset alongside the error") + + -- ...while a genuinely empty SET stays a clean empty array. + local _, v4, err4 = asn1.decode(ldap_hex("31 00")) + assert(err4 == nil, "empty SET is still valid: " .. tostring(err4)) + assert(type(v4) == "table" and #v4 == 0, "empty SET decodes to {}") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: NULL members and EOC filler inside a definite-length container +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- SEQUENCE { OCTET STRING "A", NULL }: the NULL slot vanishes -> {"A"} + local off, v, err = asn1.decode(ldap_hex("30 05 04 01 41 05 00")) + assert(err ~= nil, "NULL member must report an error, not vanish") + assert(v == nil, "no value when a NULL member is dropped") + assert(off == nil, "no offset alongside the error") + + -- Stray EOC filler in a definite-length container (30 02); tag-0 TLV that vanishes -> {}. + local off2, v2, err2 = asn1.decode(ldap_hex("30 02 00 00")) + assert(err2 ~= nil, "EOC filler in a definite-length SEQUENCE must error") + assert(v2 == nil, "no value for EOC filler") + assert(off2 == nil, "no offset alongside the error") + + -- pyasn1 v0.6.4 DefMode: NULL first with two decodable members after, SEQUENCE and SET + local _, v3, err3 = asn1.decode( + ldap_hex("30 12 05 00 04 0b 71 75 69 63 6b 20 62 72 6f 77 6e 02 01 01")) + assert(err3 ~= nil, "NULL-led SEQUENCE must error rather than silently shrink; got n=" .. + (type(v3) == "table" and #v3 or -1)) + assert(v3 == nil, "no partial array alongside the error") + local _, v4, err4 = asn1.decode( + ldap_hex("31 12 05 00 04 0b 71 75 69 63 6b 20 62 72 6f 77 6e 02 01 01")) + assert(err4 ~= nil, "NULL-led SET must error rather than silently shrink") + assert(v4 == nil, "no partial array alongside the error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: an attribute value must not silently vanish from a SearchResultEntry +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- Via protocol.lua: SearchResultEntry vals { "a", NULL } (msgID 1, dc=x, cn). + local pkt = ldap_hex([[30 1a 02 01 01 64 15 + 04 04 64 63 3d 78 + 30 0d 30 0b 04 02 63 6e + 31 05 04 01 61 05 00]]) + local res, err = protocol.decode_message(pkt) + assert(res == nil, "entry with an undecodable attribute value is rejected") + assert(err ~= nil, "undecodable attribute value reports an error") + + -- Same shape with a BOOLEAN instead of NULL in the vals SET. + local pkt2 = ldap_hex([[30 1b 02 01 01 64 16 + 04 04 64 63 3d 78 + 30 0e 30 0c 04 02 63 6e + 31 06 04 01 61 01 01 ff]]) + local res2, err2 = protocol.decode_message(pkt2) + assert(res2 == nil and err2 ~= nil, "BOOLEAN in vals SET reports an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: guard -- well-formed containers keep their full arity and stay error-free +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- Every member has a decoder: nothing may be lost and nothing may error. + local _, v, err = asn1.decode(ldap_hex("31 06 04 01 41 04 01 42")) + assert(err == nil, "well-formed SET must not error: " .. tostring(err)) + assert(#v == 2 and v[1] == "A" and v[2] == "B", "both members present") + + -- Mixed member types inside a SEQUENCE: INTEGER, OCTET STRING, ENUMERATED + local _, v2, err2 = asn1.decode(ldap_hex("30 09 02 01 07 04 01 41 0a 01 00")) + assert(err2 == nil, "mixed SEQUENCE must not error: " .. tostring(err2)) + assert(#v2 == 3 and v2[1] == 7 and v2[2] == "A" and v2[3] == 0, "three members present") + + -- A real multi-valued entry still round-trips with full arity. + local res = assert(protocol.decode_message(ldap_hex([[30 49 02 01 02 64 44 + 04 11 64 63 3d 65 78 61 6d 70 6c 65 2c 64 63 3d 63 6f 6d + 30 2f + 30 1c 04 0b 6f 62 6a 65 63 74 43 6c 61 73 73 + 31 0d 04 03 74 6f 70 04 06 64 6f 6d 61 69 6e + 30 0f 04 02 64 63 + 31 09 04 07 65 78 61 6d 70 6c 65]]))) + assert(#res.attributes.objectClass == 2, "objectClass keeps 2 values") + assert(#res.attributes.dc == 1, "dc keeps 1 value") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_oracle.t b/t/asn1_oracle.t new file mode 100644 index 0000000..a8010dc --- /dev/null +++ b/t/asn1_oracle.t @@ -0,0 +1,194 @@ +# Go go1.26.3 encoding/asn1 (differential oracle): https://github.com/golang/go/blob/go1.26.3/src/encoding/asn1/asn1_test.go +# pyasn1 v0.6.4: https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py + +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: INTEGER is exact or rejected, never silently rounded or wrapped +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- 2^53+1 = 9007199254740993; ASN1_INTEGER_get double-rounds to 9007199254740992. + local _, v1, e1 = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) + assert(e1 ~= nil, "2^53+1 must be rejected, got " .. tostring(v1)) + assert(v1 ~= 9007199254740992, "2^53+1 must not round to 2^53") + + -- -(2^53+1) + local _, v2, e2 = asn1.decode(ldap_hex("02 07 df ff ff ff ff ff ff")) + assert(e2 ~= nil, "-(2^53+1) must be rejected, got " .. tostring(v2)) + + -- 2^63-1 = 9223372036854775807, rounds up to ...808 as a double + local _, v3, e3 = asn1.decode(ldap_hex("02 08 7f ff ff ff ff ff ff ff")) + assert(e3 ~= nil, "2^63-1 must be rejected, got " .. tostring(v3)) + + local _, v4, e4 = asn1.decode(ldap_hex("02 09 00 ff ff ff ff ff ff ff ff")) + assert(v4 ~= -1, "2^64-1 must not decode as -1") + assert(e4 ~= nil, "2^64-1 must be rejected, got " .. tostring(v4)) + + -- 20-byte INTEGER also overflows to -1 today + local _, v5, e5 = asn1.decode(ldap_hex("02 14 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff")) + assert(v5 ~= -1, "160-bit INTEGER must not decode as -1") + assert(e5 ~= nil, "160-bit INTEGER must be rejected") + + -- Exactly-representable values keep working; last is RFC 4511 s4.1.1 maxInt (messageID bound). + local _, ok1 = asn1.decode(ldap_hex("02 01 ff")) + assert(ok1 == -1, "genuine -1 still decodes, got " .. tostring(ok1)) + local _, ok2 = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 00")) + assert(ok2 == 9007199254740992, "2^53 exact, got " .. tostring(ok2)) + local _, ok3 = asn1.decode(ldap_hex("02 05 01 00 00 00 00")) + assert(ok3 == 4294967296, "2^32 exact, got " .. tostring(ok3)) + local _, ok4 = asn1.decode(ldap_hex("02 04 7f ff ff ff")) + assert(ok4 == 2147483647, "maxInt messageID exact, got " .. tostring(ok4)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: high-tag-number identifier octets are not an alias for a low tag +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local aliases = { + "1f 04 03 41 42 43", -- high-tag form for tag 4 + "1f 80 04 03 41 42 43", -- + one 0x80 padding octet + "1f 80 80 04 03 41 42 43", -- + two + "1f 80 80 80 04 03 41 42 43", -- + three + } + for _, hex in ipairs(aliases) do + local _, v, err = asn1.decode(ldap_hex(hex)) + assert(v ~= "ABC", "alias " .. hex .. " must not decode as OCTET STRING ABC") + assert(err ~= nil, "alias " .. hex .. " must report an error") + end + + -- same trick on SEQUENCE: 3f 10 aliases 30 + local _, sv, serr = asn1.decode(ldap_hex("3f 10 03 02 01 05")) + assert(type(sv) ~= "table", "3f 10 must not decode as a SEQUENCE") + assert(serr ~= nil, "3f 10 must report an error") + + -- and with a class bit set: 9f 04 aliases context [4] + local _, cv, cerr = asn1.decode(ldap_hex("9f 04 03 41 42 43")) + assert(cv ~= "ABC", "9f 04 must not decode as OCTET STRING ABC") + assert(cerr ~= nil, "9f 04 must report an error") + + -- canonical encodings are untouched + local _, good = asn1.decode(ldap_hex("04 03 41 42 43")) + assert(good == "ABC", "canonical OCTET STRING still decodes") + local _, goodseq = asn1.decode(ldap_hex("30 03 02 01 05")) + assert(type(goodseq) == "table" and goodseq[1] == 5, "canonical SEQUENCE still decodes") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: a non-canonical tag cannot impersonate the LDAPMessage envelope +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local bad, err = protocol.decode_message( + ldap_hex("3f 10 0c 02 01 01 61 07 0a 01 00 04 00 04 00")) + assert(bad == nil, "high-tag-aliased envelope must be rejected") + assert(err ~= nil, "high-tag-aliased envelope must report an error") + + -- the canonical form of the same message still parses + local ok = assert(protocol.decode_message( + ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok.result_code == 0, "canonical BindResponse still parses") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: differential agreements that must keep holding +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, l1 = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(l1 == "ABC", "81-form length still accepted") + local _, l2 = asn1.decode(ldap_hex("04 82 00 03 41 42 43")) + assert(l2 == "ABC", "82-form length still accepted") + local _, l3 = asn1.decode(ldap_hex("04 84 00 00 00 03 41 42 43")) + assert(l3 == "ABC", "84-form length still accepted") + local _, l4 = asn1.decode(ldap_hex("30 81 05 04 03 41 42 43")) + assert(type(l4) == "table" and l4[1] == "ABC", "non-minimal SEQUENCE length accepted") + + -- indefinite length stays rejected; RFC 4511 s5.1 restricts LDAP to definite-length (Go agrees). + local _, i1, ierr = asn1.decode(ldap_hex("30 80 04 03 41 42 43 00 00")) + assert(i1 == nil and ierr ~= nil, "indefinite length rejected") + + local _, c1, cerr = asn1.decode(ldap_hex("24 07 04 02 41 42 04 01 43")) + assert(c1 == nil and cerr ~= nil, "constructed OCTET STRING rejected") + + -- binary payloads round-trip byte-exactly (matches the oracle). + local _, b1 = asn1.decode(ldap_hex("04 04 ff fe 00 80")) + assert(b1 == "\255\254\0\128", "high bytes and NUL preserved") + + local function der_len(n) + if n < 128 then return string.char(n) end + local b = "" + while n > 0 do b = string.char(n % 256) .. b; n = math.floor(n / 256) end + return string.char(0x80 + #b) .. b + end + local deep = "" + for _ = 1, 400 do deep = "\48" .. der_len(#deep) .. deep end + local pok, _, dv, derr = pcall(asn1.decode, deep) + assert(pok, "deep nesting must not raise") + assert(dv == nil and derr ~= nil, "deep nesting rejected with an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_vectors.t b/t/asn1_vectors.t new file mode 100644 index 0000000..7251c24 --- /dev/null +++ b/t/asn1_vectors.t @@ -0,0 +1,128 @@ +# Vectors cross-checked against pyasn1 v0.6.4, rasn v0.6.1, and Go encoding/asn1. + +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: tags with no decoder must error, never return a silent nil +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Well-formed universal TLVs whose tag has no decoder entry must error, not return nil. + local cases = { + { "01 01 ff", "BOOLEAN true" }, + { "01 01 00", "BOOLEAN false" }, + { "01 01 01 00 78 32 32", "BOOLEAN with trailing bytes" }, + { "05 00", "NULL" }, + { "06 01 55", "OID 2.5" }, + { "06 04 55 02 c0 00", "OID 2.5.2.8192" }, + { "03 02 07 00", "BIT STRING" }, + { "1a 05 4a 6f 6e 65 73", "VisibleString \"Jones\"" }, + { "16 05 53 6d 69 74 68", "IA5String \"Smith\"" }, + } + for _, c in ipairs(cases) do + local off, v, err = asn1.decode(ldap_hex(c[1])) + assert(err ~= nil, c[2] .. " must report an explicit error, got err=nil value=" .. + tostring(v) .. " off=" .. tostring(off)) + assert(v == nil, c[2] .. " must not yield a value") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: ported vectors that must KEEP working (BER leniency preserved) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Guard rail for the hardening work: these must not become errors. + + local _, os1, e1 = asn1.decode( + ldap_hex("04 0f 51 75 69 63 6b 20 62 72 6f 77 6e 20 66 6f 78")) + assert(e1 == nil and os1 == "Quick brown fox", "primitive OCTET STRING") + + local _, os2, e2 = asn1.decode(ldap_hex("04 06 01 02 03 04 05 06")) + assert(e2 == nil and os2 == "\1\2\3\4\5\6", "binary OCTET STRING round-trips") + + local _, os3, e3 = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(e3 == nil and os3 == "ABC", "non-minimal length must remain accepted") + + local _, os4, e4 = asn1.decode(ldap_hex("04 88 00 00 00 00 00 00 00 05") .. "hello") + assert(e4 == nil and os4 == "hello", "8-byte long-form length must remain accepted") + + local ints = { + { "02 03 00 80 00", 32768 }, + { "02 02 7f ff", 32767 }, + { "02 02 01 00", 256 }, + { "02 01 7f", 127 }, + { "02 01 ff", -1 }, + { "02 03 ff 7f ff", -32769 }, + } + for _, c in ipairs(ints) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err == nil and v == c[2], + "rasn integer vector " .. c[1] .. " -> " .. tostring(v)) + end + + local go_ints = { + { "02 01 00", 0 }, + { "02 02 00 80", 128 }, + { "02 01 80", -128 }, + { "02 02 ff 7f", -129 }, + } + for _, c in ipairs(go_ints) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err == nil and v == c[2], + "go int64 vector " .. c[1] .. " -> " .. tostring(v)) + end + + local _, p12 = asn1.decode(ldap_hex("02 01 0c")) + local _, n12 = asn1.decode(ldap_hex("02 01 f4")) + assert(p12 == 12 and n12 == -12, "pyasn1 12 / -12") + + local _, chunked, ec = asn1.decode( + ldap_hex("24 17 04 04 51 75 69 63 04 04 6b 20 62 72 04 04 6f 77 6e 20 04 03 66 6f 78")) + assert(chunked == nil and ec ~= nil, "constructed OCTET STRING stays rejected") + + local deep = ldap_hex("05 00") + for _ = 1, 150 do deep = asn1.put_object(16, asn1.CLASS.UNIVERSAL, 1, deep) end + local _, dv, de = asn1.decode(deep) + assert(dv == nil and de ~= nil, "deep nesting is bounded") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/certs/localhost_slapd_cert.pem b/t/certs/localhost_slapd_cert.pem index 5a5e473..dcced7b 100644 --- a/t/certs/localhost_slapd_cert.pem +++ b/t/certs/localhost_slapd_cert.pem @@ -1,25 +1,20 @@ -----BEGIN CERTIFICATE----- -MIIETDCCAjSgAwIBAgIUUCJNMXFCzskAeej6LY/s3bibIwYwDQYJKoZIhvcNAQEM -BQAwGjEYMBYGA1UEAxMPRXhhbXBsZSBDb21wYW55MB4XDTIyMDcyNzA1NDgyOFoX -DTIzMDcyNzA1NDgyOFowLjESMBAGA1UEAxMJbG9jYWxob3N0MRgwFgYDVQQKEw9F -eGFtcGxlIENvbXBhbnkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCx -E5zfta69uPsQVDiV0OwWHDGxTBYNzmp5zsVwOF3bOH+hyB4M+qFxPEuH84/Ib4GJ -dLM67qZth1azHudKy/QGPFkoeFUW1JhB9QGyjh/URwxTy05bCe5w7Ee1rMV/GWu6 -fxMfIE3o5U0XuW1IKQFaZVdNuQlvG4VjL59BfnEF+YXb1QDBkIpvf59q+UuZgit8 -CrO1dDYeJ/xO3N9v2CS2u6si9/XWgIwayw67tmb7cbTu/srBC99w97IMP5/Vkeu6 -fkg2jTuvCRARzMQJ11krDmtGeYum9SSCdyTLxK1u7w33DuhQ3HE/PfHJj9QV1MKI -eruVjEvawJsRiWQG0Ai7AgMBAAGjdjB0MAwGA1UdEwEB/wQCMAAwEwYDVR0lBAww -CgYIKwYBBQUHAwEwDwYDVR0PAQH/BAUDAwegADAdBgNVHQ4EFgQUcGOrPCoztq5Z -7mjgGtaCkPkmDWowHwYDVR0jBBgwFoAUyRIYo24nwp3I/WhkgKxtM983HggwDQYJ -KoZIhvcNAQEMBQADggIBABb+txfXNSa0s46ofaGtDSDrocjOiepSrLX6JpCx51Tz -Osou3NyZGytaW1Mo7p5Z/Tbz7HpRxHLiUBQikm+/a9YCXUl0Y1kKOknMxqQbg4U7 -qPfP7NEv/9bu0PAF5mJtKRsiIO/R6WoRv4vBMr4BQVjI7q2Z7y1cq5/7bLBIQivm -Hd6gNyoaYsrBdt+vh/QlemEnBhjVd1ak7emKruAbizdztuiIaK5h4m37QBbfEYaM -5J5o81HfOcRXcs6UAbEpxfMPjD5Bz61BAwsQPxrOXUs6R6vcog2lmg99KDEesZ2c -l2fucgXrev5M2878yYJ50Km/BjqVJ5BnHF/kn5xx5MTKl6KBj2oXVE0S9W4zm+H6 -TtZcxey+pUcVmRY9r4zNsgPXdreBLkgaSDYLXC/rZk5F6FlWkGe8Kt4DkfRXofBe -J1Usa3CYj1AyzinUL45WNOcc7t7njt5mTzbvP1facGYhWPGLGbu7fj9LWLpVFUPK -qt4QBNDvwsjXGIwh/AFwGvt/zpOohrxXIms6AgfSye2DXHB+H0c5Cn94w5BOaaOX -pXkpRMJmglfHZqFnjMb9q9huhuaNJyeXukZM5WSSWF0v8QhKr5GqvX1uEMAbZ3PV -2T+tA+XJnZk3PgW7jBnloh162/FvN/rMtyvvKTtAN4BKt18O96Tj0JR6yXKpDbby +MIIDTjCCAjagAwIBAgIUHWUlQClV/D7SWtYz9dLb/jrcyH0wDQYJKoZIhvcNAQEL +BQAwNDEYMBYGA1UECgwPRXhhbXBsZSBDb21wYW55MRgwFgYDVQQDDA9FeGFtcGxl +IENvbXBhbnkwHhcNMjYwNzE3MDczNDMzWhcNMzYwNzE0MDczNDMzWjAuMRgwFgYD +VQQKDA9FeGFtcGxlIENvbXBhbnkxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAKwwv+UdDvQt0/V7SZdO6HGqCUnk88Tr +XWN8mWInqdf1t8Wjmn4RTHaF3zTeLniDaj7PZdPgPyMgJ+I/hc7fK6cUxBNb6XWK +5mwSp4KDH1Rh66ccn+iNIBwrZ7v9wYbtIbY1C5J6IvFCpB/jU+Y12ul6esUYsPof +KZ4y1inCjreo09JSRreorzxImIZzL61WDSR9d9srS7FjObxoei8Q3ChqFDb8x5NW +ynE3Maj6vzCPIolKPX9gVH3OfA7lLaQkJDFyjddnZTErszwoYniiRsYeWClYWu21 +fHoRV+/w4B6S3weSDtc2ZOVYyUMo56FxFOM3llJ6taPjkEIpX1iH3ecCAwEAAaNe +MFwwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBRvFaQfO3Hq +IBi5LFt3yIgvgfAQBjAfBgNVHSMEGDAWgBT6nZ2VYXI5ez5JyIDBQOHUgeOh8zAN +BgkqhkiG9w0BAQsFAAOCAQEAythP9N9mOX5hFWTuofl1ROvGiJj1q2Vy32ZWdQSI +pQ2WB9ECfd1jusVQcCQw11JNTfVPmIevkVcRYi4wO0cwg+cas1t/8RfD9fLf4JIH +3fvOS9/wBEqESjF4KPW3DkBOIUKVE3KaJ+v6IcrFW+cjjSyIrdn8Eom8NIvAGV6D +LlbPTVmXGrdx7niCDhIEpRxIAbUSrkEoavQQVPmTFNSmdjN3LcesN5Cej13txlDt +BaHsh47kOjAjshDMiP95jQSBzONjerolhkz3E/Tdp5gGsuJ97XC1xbo6HKx0VLt2 +yZbKNa1E5d9GPEP/ez7bC1H09Hbk3byKQSVrkCPOJjEHuA== -----END CERTIFICATE----- diff --git a/t/certs/localhost_slapd_key.pem b/t/certs/localhost_slapd_key.pem index b3af609..30528e4 100644 --- a/t/certs/localhost_slapd_key.pem +++ b/t/certs/localhost_slapd_key.pem @@ -1,134 +1,28 @@ -Public Key Info: - Public Key Algorithm: RSA - Key Security Level: Medium (2048 bits) - -modulus: - 00:b1:13:9c:df:b5:ae:bd:b8:fb:10:54:38:95:d0:ec - 16:1c:31:b1:4c:16:0d:ce:6a:79:ce:c5:70:38:5d:db - 38:7f:a1:c8:1e:0c:fa:a1:71:3c:4b:87:f3:8f:c8:6f - 81:89:74:b3:3a:ee:a6:6d:87:56:b3:1e:e7:4a:cb:f4 - 06:3c:59:28:78:55:16:d4:98:41:f5:01:b2:8e:1f:d4 - 47:0c:53:cb:4e:5b:09:ee:70:ec:47:b5:ac:c5:7f:19 - 6b:ba:7f:13:1f:20:4d:e8:e5:4d:17:b9:6d:48:29:01 - 5a:65:57:4d:b9:09:6f:1b:85:63:2f:9f:41:7e:71:05 - f9:85:db:d5:00:c1:90:8a:6f:7f:9f:6a:f9:4b:99:82 - 2b:7c:0a:b3:b5:74:36:1e:27:fc:4e:dc:df:6f:d8:24 - b6:bb:ab:22:f7:f5:d6:80:8c:1a:cb:0e:bb:b6:66:fb - 71:b4:ee:fe:ca:c1:0b:df:70:f7:b2:0c:3f:9f:d5:91 - eb:ba:7e:48:36:8d:3b:af:09:10:11:cc:c4:09:d7:59 - 2b:0e:6b:46:79:8b:a6:f5:24:82:77:24:cb:c4:ad:6e - ef:0d:f7:0e:e8:50:dc:71:3f:3d:f1:c9:8f:d4:15:d4 - c2:88:7a:bb:95:8c:4b:da:c0:9b:11:89:64:06:d0:08 - bb: - -public exponent: - 01:00:01: - -private exponent: - 60:c0:36:96:84:ce:55:1b:1d:12:6e:f1:fb:e9:8b:15 - 09:92:9d:2c:d5:5f:f5:c8:77:85:62:9b:4e:30:f9:f6 - 84:c6:00:71:6a:e6:06:0f:b8:c2:0c:26:28:09:7b:e3 - 6b:17:38:56:9a:ce:94:49:be:35:60:4d:3f:b0:f0:43 - f7:f5:3f:07:80:76:58:f2:58:17:66:36:09:31:9a:ea - b6:f1:91:c3:de:3a:2e:ed:c4:2b:ea:37:dc:30:f5:d2 - c6:b3:67:df:39:e7:57:b8:f1:c6:64:aa:31:23:36:7a - 0d:a5:05:f2:74:15:21:14:60:7d:44:a6:a4:4f:5c:d3 - 6f:c7:be:65:07:6c:ac:4f:99:ce:03:3d:ff:15:45:47 - 1e:20:b1:f3:5b:79:df:f9:4c:c1:d9:41:bc:1a:a8:fc - d0:46:24:bd:49:c4:70:bd:9f:93:13:6d:1c:19:0e:ba - a9:9a:91:eb:e2:42:6a:d7:3c:06:95:c0:f5:0e:99:99 - 38:fa:d4:c4:a5:e8:c3:33:0a:e3:92:c8:81:a6:e4:9f - ac:88:f9:0b:57:f9:22:f8:c0:07:93:9f:46:f6:21:2c - 4e:34:b5:4f:59:78:69:09:f4:91:3b:5d:33:1f:68:54 - 23:ce:0d:8e:89:76:2f:23:21:a5:3f:4f:01:96:b9:01 - - -prime1: - 00:c4:91:20:fd:82:a3:64:64:05:86:27:4f:eb:83:86 - 8b:5a:1c:84:56:71:b8:91:70:71:9d:7b:0e:09:99:3e - 48:94:fa:fa:42:f0:a6:25:42:94:74:af:4c:cd:f6:fa - 8f:fb:bd:67:30:ca:15:25:f7:a4:b5:74:4c:3f:87:32 - d1:e6:d2:01:bc:78:ed:7e:48:7d:d6:14:84:80:fe:fc - e0:25:c5:b9:75:3b:d6:a1:94:65:a4:f7:4c:e4:9d:5b - d0:29:a8:48:9a:d6:19:a9:ea:50:11:8c:9e:b5:08:83 - 07:88:17:d9:b2:d3:fb:c2:08:b7:5e:9e:6c:60:bf:4f - 25: - -prime2: - 00:e6:9d:e0:4d:c8:fa:f6:7c:72:cb:80:6e:1b:16:2e - 35:f4:ce:45:30:b3:77:dc:e1:51:13:7b:df:6e:47:54 - 13:25:85:e9:ff:7f:57:be:ca:35:75:ce:d8:7d:c8:b0 - e5:e5:6c:a5:f7:d8:e4:54:2b:f2:ea:9f:37:b3:ed:44 - 0c:a7:60:39:36:2b:c6:00:f7:d5:8f:77:e0:37:bf:9b - 9d:d4:e8:ff:f2:1d:e8:9c:df:3f:5d:22:02:00:9f:ea - 9c:26:a5:3a:08:bd:bc:95:71:15:f2:32:8b:36:69:51 - d1:ca:d6:4a:3b:52:04:32:29:03:70:56:58:df:37:e2 - 5f: - -coefficient: - 15:a3:7f:d2:1a:f7:90:10:86:19:44:34:30:a4:84:c3 - 50:38:8c:99:ed:47:e1:27:cf:9f:af:27:51:7f:c9:6e - c4:dd:23:69:e6:01:f3:f2:c1:75:a6:9b:77:39:92:ad - 65:e2:28:68:26:a9:20:4a:f8:85:16:73:cd:9a:f6:cc - d1:ee:de:dc:a2:ce:c7:0b:01:d7:2a:29:bb:76:a3:b3 - a5:3c:ae:d9:e6:68:51:10:df:04:17:8a:1b:2d:02:3e - 62:c0:2f:d9:a3:98:d8:41:53:48:fa:bb:d0:dd:8e:2f - 8e:4d:2e:c7:6a:de:28:29:29:91:08:6e:95:20:9f:00 - - -exp1: - 31:81:c3:e7:55:91:c5:65:13:a7:18:1b:9e:db:7f:75 - 75:7a:9d:32:10:6e:45:e3:26:1a:5d:b5:c9:61:19:38 - ba:9d:03:8e:fc:81:3b:fd:2a:da:c0:93:fd:83:e8:d3 - 7e:b9:d3:55:8c:70:0b:21:f6:0f:e5:7c:96:bb:7c:67 - 35:55:4b:2e:a6:de:59:e1:f4:1f:89:07:5e:5d:da:5e - b1:e4:bc:b2:f4:21:38:8c:e1:94:cc:dc:46:f0:03:01 - c8:9c:23:bd:2b:93:47:22:46:8c:44:f8:6b:eb:fa:e4 - 58:b8:79:11:fb:25:fb:56:aa:a8:60:0a:37:cb:b7:29 - - -exp2: - 50:e3:71:48:77:45:27:6d:91:2a:35:da:e8:df:47:c8 - 1f:1c:b6:82:15:80:e6:55:95:85:7a:fe:6c:84:d2:45 - 80:f4:ce:95:92:49:e9:9e:ad:4f:ac:04:9d:61:e6:42 - 4c:cd:66:0d:5f:e2:fe:6f:07:de:29:88:75:30:b4:9c - a7:9c:85:94:ad:97:de:c1:0f:04:2a:6c:d7:c0:fa:49 - 4a:e3:8a:da:96:88:ff:75:02:99:9d:13:0c:bb:0a:a4 - 48:9d:cd:94:41:50:c3:2e:0e:1f:8c:80:ed:cd:d5:27 - fb:b8:5c:03:20:8a:5e:39:aa:7e:1d:9b:40:78:2e:8b - - - -Public Key PIN: - pin-sha256:qNEd5eVQGbhFBh4fRmnpI2wEHSa2FSxbze10CUtORnA= -Public Key ID: - sha256:a8d11de5e55019b845061e1f4669e9236c041d26b6152c5bcded74094b4e4670 - sha1:7063ab3c2a33b6ae59ee68e01ad68290f9260d6a - ------BEGIN RSA PRIVATE KEY----- -MIIEogIBAAKCAQEAsROc37Wuvbj7EFQ4ldDsFhwxsUwWDc5qec7FcDhd2zh/ocge -DPqhcTxLh/OPyG+BiXSzOu6mbYdWsx7nSsv0BjxZKHhVFtSYQfUBso4f1EcMU8tO -WwnucOxHtazFfxlrun8THyBN6OVNF7ltSCkBWmVXTbkJbxuFYy+fQX5xBfmF29UA -wZCKb3+favlLmYIrfAqztXQ2Hif8Ttzfb9gktrurIvf11oCMGssOu7Zm+3G07v7K -wQvfcPeyDD+f1ZHrun5INo07rwkQEczECddZKw5rRnmLpvUkgncky8Stbu8N9w7o -UNxxPz3xyY/UFdTCiHq7lYxL2sCbEYlkBtAIuwIDAQABAoIBAGDANpaEzlUbHRJu -8fvpixUJkp0s1V/1yHeFYptOMPn2hMYAcWrmBg+4wgwmKAl742sXOFaazpRJvjVg -TT+w8EP39T8HgHZY8lgXZjYJMZrqtvGRw946Lu3EK+o33DD10sazZ98551e48cZk -qjEjNnoNpQXydBUhFGB9RKakT1zTb8e+ZQdsrE+ZzgM9/xVFRx4gsfNbed/5TMHZ -QbwaqPzQRiS9ScRwvZ+TE20cGQ66qZqR6+JCatc8BpXA9Q6ZmTj61MSl6MMzCuOS -yIGm5J+siPkLV/ki+MAHk59G9iEsTjS1T1l4aQn0kTtdMx9oVCPODY6Jdi8jIaU/ -TwGWuQECgYEAxJEg/YKjZGQFhidP64OGi1ochFZxuJFwcZ17DgmZPkiU+vpC8KYl -QpR0r0zN9vqP+71nMMoVJfektXRMP4cy0ebSAbx47X5IfdYUhID+/OAlxbl1O9ah -lGWk90zknVvQKahImtYZqepQEYyetQiDB4gX2bLT+8IIt16ebGC/TyUCgYEA5p3g -Tcj69nxyy4BuGxYuNfTORTCzd9zhURN7325HVBMlhen/f1e+yjV1zth9yLDl5Wyl -99jkVCvy6p83s+1EDKdgOTYrxgD31Y934De/m53U6P/yHeic3z9dIgIAn+qcJqU6 -CL28lXEV8jKLNmlR0crWSjtSBDIpA3BWWN834l8CgYAxgcPnVZHFZROnGBue2391 -dXqdMhBuReMmGl21yWEZOLqdA478gTv9KtrAk/2D6NN+udNVjHALIfYP5XyWu3xn -NVVLLqbeWeH0H4kHXl3aXrHkvLL0ITiM4ZTM3EbwAwHInCO9K5NHIkaMRPhr6/rk -WLh5Efsl+1aqqGAKN8u3KQKBgFDjcUh3RSdtkSo12ujfR8gfHLaCFYDmVZWFev5s -hNJFgPTOlZJJ6Z6tT6wEnWHmQkzNZg1f4v5vB94piHUwtJynnIWUrZfewQ8EKmzX -wPpJSuOK2paI/3UCmZ0TDLsKpEidzZRBUMMuDh+MgO3N1Sf7uFwDIIpeOap+HZtA -eC6LAoGAFaN/0hr3kBCGGUQ0MKSEw1A4jJntR+Enz5+vJ1F/yW7E3SNp5gHz8sF1 -ppt3OZKtZeIoaCapIEr4hRZzzZr2zNHu3tyizscLAdcqKbt2o7OlPK7Z5mhREN8E -F4obLQI+YsAv2aOY2EFTSPq70N2OL45NLsdq3igpKZEIbpUgnwA= ------END RSA PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCsML/lHQ70LdP1 +e0mXTuhxqglJ5PPE611jfJliJ6nX9bfFo5p+EUx2hd803i54g2o+z2XT4D8jICfi +P4XO3yunFMQTW+l1iuZsEqeCgx9UYeunHJ/ojSAcK2e7/cGG7SG2NQuSeiLxQqQf +41PmNdrpenrFGLD6HymeMtYpwo63qNPSUka3qK88SJiGcy+tVg0kfXfbK0uxYzm8 +aHovENwoahQ2/MeTVspxNzGo+r8wjyKJSj1/YFR9znwO5S2kJCQxco3XZ2UxK7M8 +KGJ4okbGHlgpWFrttXx6EVfv8OAekt8Hkg7XNmTlWMlDKOehcRTjN5ZSerWj45BC +KV9Yh93nAgMBAAECggEAFZfVcKtotSlME9Q+oclk7+AVYx1hjne/kdrNt1fm/iyT +u6atGJMdxh06pPwZ0sYUHEVQ7TWJZWPCbKZvpsYoyL6bahwUFkfxZmsec4jy9FeF +QEMgMH8GIFI3I4WKb9ur5xgW1+sWF7A2OcN9wXhOeAoCfFYA15Tx2KNiZQE/eXtv +Kz0AMUUnpxGRwu3xKAjZrzIcc5jAqT/KRM9YtPxylQjeM0Eh4zDyCsRhgOZGsRMa +vwH2VCVfO+rAO8rQP/zAhIIx4II+Pc/3C3KQ/DPqdyG+9GrEBIq80RiR8I2naBeX +k4sMvRkKRJMZIqh01/SxXAKVo4HpDEmcuD0faNFciQKBgQDC9kRbSc1VMmftMilo +O6pZTX0zTDXWTzCdvTadvs4AbDyddgcigs+iGM1a02q3LwGJ+AGEkibCXZFk67iB +WGft3ixpUMVha1u1wzRu9/39P3EXda4pZxNRenFuERgjcINEz2spdCVjPrXaStaT +FUYTb5WrnZiBwLIH/s+G5p3qmQKBgQDiGWXDPPkODwKkjehvXstH7/016WzoZu8T +xzNPGqSGTxA5Fwuxq4AoRsURHwzv1sKqDefadJfc/+v5G7J9JGDJ/uZQzFdTnz5H +SIcqIUATK0854AOz7DAGpw4qSIR6xqs7/RrJKUKlne82H1lGyETxc0KsY8D+8XRw +y/n2qV3cfwKBgFL/fEJQvPVULCIyhKY3IGI8NtyryQ+fTtcYQjQNkq1jZrqyEH+E +qNgdLu9HqdqqTEFsL1k8zvtX0hngr/+An14Ig2eiVyUOC1Dp9Vx4fsxdQcv28Vn2 +46aANeHhrSEJORkGJFzVcUU64Tg5O/gJyndjvZf903sJicEVnUuUyg2hAoGAQ6u0 +UHYMWM/XS6cJfWPS1coXcC5YIUrFnZbOXYus2GILifrCzj91URi1XMV9Wr9dbgZR +cYnZ9hRG7T0D8/6SVYLMLjyqmmWb/zvO8KYZBmO9B6ZrlUtIqIURcUhZFmIl2AOO +I80MnIMjmIBTeSLxt4520x/cILHl1ujF/LR+WkMCgYEAiDnWvb816t/NRTOzv8yV +TEwMn7IcbWxAt5Au34a16THENdeOrXmlvWg8TsKrBeXI16RnL673skKAQcacRO9u +RXpF7zVoHgfrD2vDVIbphATTEVij4nWQ6tarPOQXkhdWT6qTZ5dLyHDSkMfTeW6y +kUFk7c8WTiJh5kSxyXlDiXY= +-----END PRIVATE KEY----- diff --git a/t/certs/mycacert.crt b/t/certs/mycacert.crt index a3ed7eb..a159ad4 100644 --- a/t/certs/mycacert.crt +++ b/t/certs/mycacert.crt @@ -1,29 +1,20 @@ -----BEGIN CERTIFICATE----- -MIIFBTCCAu2gAwIBAgIUT2D5Mob/JewB42FWd4dIKrXRNe4wDQYJKoZIhvcNAQEM -BQAwGjEYMBYGA1UEAxMPRXhhbXBsZSBDb21wYW55MB4XDTIyMDcyNzAzMzY0N1oX -DTMyMDcyNDAzMzY0N1owGjEYMBYGA1UEAxMPRXhhbXBsZSBDb21wYW55MIICIjAN -BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1XUUTHFgj45MZ+UBDN16oohaiHcH -5osqUS8BMnpCqCIlToAItL2KM8RCwiDJ7ebrqVcTZ2NYYXE+nWurV5t0RgVHP59v -YRKSwdyMbOjQdb6MShmbT23yfPrRabfK04dMy2O9WEtLqLEM9xQzyMxw9TUfzjev -icBKa6z9lQn1qpPP7Wm1UxXvxydmegcnMaH0Flp7PWNahWtEQHuLAVsS676zleMw -RqrW4CYTU5pYrE4Cz5YRCJPRFf3mA1FObbAf9CiOZJvOwoKBnhOxo5ifqQFi+r9A -efWOCbq1skAxsM/YPfLhVuqPO3zWx+hx9KxEuGULxLW7skdS7dBzcjxJhuDxWhBL -pwD+gJd3Hsxb919qQSss8auqtl8KkX8SnSX6CHgaceLbnxB2YZM16CTDJ46c8A9b -LrAWaSr2vXCet4tDHYQ22/GUacKDVUI2qwI5ynV2ViPi0d/NaQSfHiB3Ww0J0o8c -L6zv1seVB0cQCy1nu8Wqf6bV4xfTJJmIvsclrKANOFn/2cGXRSrhdyPQu2kbDPXP -DhbBrsx3g7ZEG7FoQqrRPEW41ICjT3kqmrqQvq5D6KEkrSUcWg2GuFvpW59GrD3W -wBq3d05od6+XXX7ibym3Qzf1115IHlUuHC/rVG6pZTZnK6i8Mj6aW4N36reFMVCb -vOJ9D5sNu0MSHccCAwEAAaNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNVHQ8BAf8E -BQMDBwQAMB0GA1UdDgQWBBTJEhijbifCncj9aGSArG0z3zceCDANBgkqhkiG9w0B -AQwFAAOCAgEASSM8qFuWy30fVcaJJtut0D4CEEZ0qY3f6BbiuV0GIKIxpKhpp7az -W4wv3I85eyrBK7WF2GuzRXZysva/wEumszMgHbq/gwtobIjcsVJsmyepR8R7/A8u -KxKLtEC4PTXCje4T5UckvITYhJZoWntmZgeVtUehzXlNvJEugBUih/TD43/Qodvq -S9pCIz686GRsHR5/SBWa4Leh+SWuMpEC+gRjXJmWiDm53lQK5ROJ5m4pATL1Tp2t -tTHWs/PcU1mHf8bpUn/Yt29rzoAD+irW9PW6My3Al/tkP3SFaVl5lC21tl3scSOc -2nfOikaPKsevexb6k7v5B+Or2fBNe/AEgtCI8ZPec8Cp9dZ3UY2KBZqS30zJ2JZ2 -ypV1K73pY5A9E3eaAma00xu9BtiFLVhAZ5bwRFU36d4IOx2DLP7vKad1qdaIgFq4 -APheg8L9Wf1baklWsQXcwiJwlD1svaXsyPSeH54hMeIEhtylwZM/SojgCE99VrW3 -QRQnqyVxtPyg52RonFp/0To0N68hYRgHVXS4DprzuLOGYri5XXVJcy0DY1QaJn/m -bUomRAPZX+e5Gh9TD8L4+klE7Mtr4/B/1H34IlEuVeEZwB156Ph6uDAe1eFZAvdf -jz0hhfIOeKZbvOWILQILf+yX7hacFEertdJ46UexaPKIcCpGTPl6bjg= +MIIDSTCCAjGgAwIBAgIUcE81FtyKlhiyR/fqcFGMqYOVDsQwDQYJKoZIhvcNAQEL +BQAwNDEYMBYGA1UECgwPRXhhbXBsZSBDb21wYW55MRgwFgYDVQQDDA9FeGFtcGxl +IENvbXBhbnkwHhcNMjYwNzE3MDczNDMzWhcNMzYwNzE0MDczNDMzWjA0MRgwFgYD +VQQKDA9FeGFtcGxlIENvbXBhbnkxGDAWBgNVBAMMD0V4YW1wbGUgQ29tcGFueTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANEWZUfdLWQ/jQ5oLzGWVjex +iXvJ2dpXuQhGudih4okInaNoglu2jkTExcFM2YEzK97rW93DukTg7UEnsc9BMYk6 +Txbvba1xjCeZjPR5Bh027Wfc4l6pQ4la/la2J6+kc+KpaLJYBG3YGCxAO4BtSbiy +4ddrqwd3Ug+QxVP6isGhAHdqQFIGsrM7NZkKlbkAZhFgUAx6X9KNmZQ13CbOROUO +6X96Z2kvuFlmfHS6fjlT0XCfhCoDMyOyMhSe6qTHsANgFUmdVl+As74kw9sVrSh9 +boMlbTixQA0cPYDHLY98vZqLYkPNMAwI/1AXPaY2LsR72JLQuQlsEDzlscnKr90C +AwEAAaNTMFEwHQYDVR0OBBYEFPqdnZVhcjl7PknIgMFA4dSB46HzMB8GA1UdIwQY +MBaAFPqdnZVhcjl7PknIgMFA4dSB46HzMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAL+ttmoTUHn1Be3wyxFV1TeWH2AS5RkHm69Zd1Mg2+Cq1KSG +PM3+Rlx8KGDkhF42TKyEcyD4P3SH8wqVERTBPeI9miXrArbin1/xa5y0p3+3uFyL +Q68eENPRVa/iYz3cMV+tRDpnLJ2a23/9X3+Dx+jFtqYtKEHs+VjQMnH+AaoaxYiG +H+UYCH9XJnTXcvnF1bEW5gLDAa6M7R9B3yvbe7FQKjEOA+avfH9eaMr7PFUsQavO +j1HJiZekzg8GQFRBakX1z8YceGfjE/aQZBq5G3BQvuD7jkHaXzCX6eQbNLTJBqim +ZbA+kY15+namvOHPldT4wYkw0G1d8IBiL2EGpqw= -----END CERTIFICATE----- diff --git a/t/client.t b/t/client.t index 51ab0cd..fd088da 100644 --- a/t/client.t +++ b/t/client.t @@ -152,3 +152,311 @@ 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") + + -- pool a connection established without certificate verification + local a = ldap_client:new("localhost", 1636, { ldaps = true, ssl_verify = false }) + assert(a:connect()) + 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(b:connect()) + 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(c:connect()) + 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 a = ldap_client:new("localhost", 1389, { start_tls = true, ssl_verify = false }) + assert(a:connect()) + 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(b:connect()) + assert(b.socket:getreusedtimes() == 0, + "an unverified pooled STARTTLS connection must not serve ssl_verify=true") + assert(b: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 + client.pinned = true + + 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: a single-shot bind never returns its socket to the shared 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") + + -- control: an unbound single-shot search does re-enter the pool + 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=*)")) + local b = ldap_client:new("127.0.0.1", 1389) + assert(b:connect()) + assert(b.socket:getreusedtimes() > 0, "unbound search should hit the pool") + assert(b:set_keepalive()) + + -- a single-shot admin bind checks out the pooled socket, binds on + -- it, and must close it on release instead of pooling it again + local c = ldap_client:new("127.0.0.1", 1389) + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + + -- a new anonymous client must get a fresh connection; its search + -- must not inherit the admin identity + local d = ldap_client:new("127.0.0.1", 1389) + assert(d:connect()) + assert(d.socket:getreusedtimes() == 0, + "admin-bound socket leaked into the pool") + local res, serr = d:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + assert(res, "anonymous search: " .. tostring(serr)) + assert(#res == 1, "one entry") + assert(d:set_keepalive()) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 12: ldap_authenticate never pools a socket that carried a 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", + } + + -- a failed bind closes its socket instead of pooling it; the pool + -- is observable as the default host:port pool + local ok = ldap.ldap_authenticate("user01", "wrong-password", conf) + assert(ok == false, "a wrong password must be rejected (false), got " .. tostring(ok)) + local probe = ngx.socket.tcp() + assert(probe:connect("127.0.0.1", 1389)) + assert(probe:getreusedtimes() == 0, + "failed-bind socket leaked into the pool") + probe:close() + + -- a successful bind leaves the socket holding the user's identity, + -- so it too must be closed, never pooled + local ok2, err2 = ldap.ldap_authenticate("user01", "password1", conf) + assert(ok2, "authenticate failed: " .. tostring(err2)) + local probe2 = ngx.socket.tcp() + assert(probe2:connect("127.0.0.1", 1389)) + assert(probe2:getreusedtimes() == 0, + "user-bound socket leaked into the pool") + 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 + client.pinned = true + + 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 + client.pinned = true + + 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] diff --git a/t/compat_ldap.t b/t/compat_ldap.t new file mode 100644 index 0000000..0d0830f --- /dev/null +++ b/t/compat_ldap.t @@ -0,0 +1,230 @@ +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 = "localhost", + 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 = "localhost", + 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: verify_ldap_host=true is honoured as a legacy alias for tls_verify +--- 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 = "localhost", + ldap_port = 1636, + ldaps = true, + verify_ldap_host = true, + base_dn = "ou=users,dc=example,dc=org", + attribute = "cn", + }) + assert(ok == nil, "legacy verify_ldap_host must enforce verification: " .. + "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 = "localhost", + 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)) + + -- whatever state the first call left behind (it must not pool its + -- bound socket), a verifying call needs a fresh handshake, which + -- fails: no trusted certificate is configured + local ok2, err2 = ldap.ldap_authenticate("user01", "password1", { + ldap_host = "localhost", + 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 diff --git a/t/decode.t b/t/decode.t new file mode 100644 index 0000000..4f42ffc --- /dev/null +++ b/t/decode.t @@ -0,0 +1,191 @@ +# Hand-built RFC 4511 message shapes. https://www.rfc-editor.org/rfc/rfc4511.txt + +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: BindResponse success (LDAPResult shape) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + local res = assert(protocol.decode_message(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 " .. res.protocol_op) + assert(res.result_code == 0, "code") + assert(res.matched_dn == "", "matched_dn") + assert(res.diagnostic_msg == "", "diag") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: SearchResultEntry, multi-valued attribute stays an array +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + local pkt = ldap_hex([[30 49 02 01 02 64 44 + 04 11 64 63 3d 65 78 61 6d 70 6c 65 2c 64 63 3d 63 6f 6d + 30 2f + 30 1c 04 0b 6f 62 6a 65 63 74 43 6c 61 73 73 + 31 0d 04 03 74 6f 70 04 06 64 6f 6d 61 69 6e + 30 0f 04 02 64 63 + 31 09 04 07 65 78 61 6d 70 6c 65]]) + local res = assert(protocol.decode_message(pkt)) + assert(res.protocol_op == protocol.APP_NO.SearchResultEntry, "op") + assert(res.entry_dn == "dc=example,dc=com", "entry_dn " .. tostring(res.entry_dn)) + assert(type(res.attributes.objectClass) == "table", "objectClass is array") + assert(#res.attributes.objectClass == 2, "objectClass count") + assert(res.attributes.objectClass[1] == "top", "oc[1]") + assert(res.attributes.objectClass[2] == "domain", "oc[2]") + assert(#res.attributes.dc == 1 and res.attributes.dc[1] == "example", "dc single value still array") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: binary attribute value with embedded NUL is not truncated +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + local res = assert(protocol.decode_message(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"))) + assert(res.entry_dn == "x", "dn") + assert(#res.attributes.s == 1, "one value") + assert(#res.attributes.s[1] == 3, "value length " .. #res.attributes.s[1]) -- 3, not 1 + assert(res.attributes.s[1] == "\1\0\2", "value bytes") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: empty vals SET and empty attribute list +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + local res = 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(type(res.attributes.s) == "table", "s is table") + assert(#res.attributes.s == 0, "s empty") + -- entry with EMPTY attribute list: attrs 30 00 + local res2 = assert(protocol.decode_message(ldap_hex("30 0a 02 01 03 64 05 04 01 78 30 00"))) + assert(res2.entry_dn == "x", "dn2") + assert(next(res2.attributes) == nil, "no attributes") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: SearchResultReference decodes URIs (op 19) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + local res = assert(protocol.decode_message(ldap_hex("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78"))) + assert(res.protocol_op == protocol.APP_NO.SearchResultReference, "op " .. res.protocol_op) + assert(#res.uris == 1, "uri count") + assert(res.uris[1] == "ldap://x", "uri") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: SearchResultDone with nonzero code; malformed envelope errors +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + local res = assert(protocol.decode_message(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") + assert(res.result_code == 32, "code " .. res.result_code) + -- not an LDAPMessage SEQUENCE + local bad, err = protocol.decode_message(ldap_hex("04 01 41")) + assert(bad == nil and err ~= nil, "malformed rejected") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: a field may not overrun the enclosing operation's boundary +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local bad, err = protocol.decode_message(ldap_hex("30 0d 02 01 01 61 02 0a 02 01 00 04 00 04 00")) + assert(bad == nil, "resultCode overrunning the op is rejected") + assert(err ~= nil, "resultCode overrunning the op reports an error") + + -- ref declares 2 content bytes; the 10-byte URI TLV lies outside it + local bad2, err2 = protocol.decode_message(ldap_hex("30 0f 02 01 02 73 02 04 08 6c 64 61 70 3a 2f 2f 78")) + assert(bad2 == nil and err2 ~= nil, "URI overrunning the op is rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode_hostile.t b/t/decode_hostile.t new file mode 100644 index 0000000..d2c9e00 --- /dev/null +++ b/t/decode_hostile.t @@ -0,0 +1,373 @@ +# Vectors cross-checked against pyasn1 v0.6.4 and rasn v0.6.1. +# pyasn1 v0.6.4: https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py + +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: bytes after the LDAPMessage envelope must be rejected, not ignored +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local r1, e1 = protocol.decode_message(ldap_hex( + "30 0c 02 01 02 61 07 0a 01 00 04 00 04 00 de ad be ef")) + assert(r1 == nil, "trailing garbage after envelope must be rejected") + assert(e1 ~= nil, "trailing garbage after envelope must report an error") + + -- Two LDAPMessages in one buffer: forged success consumed, genuine reply dropped. + local r2, e2 = protocol.decode_message(ldap_hex( + "30 0c 02 01 02 61 07 0a 01 00 04 00 04 00" .. + "30 0c 02 01 01 61 07 0a 01 31 04 00 04 00")) + assert(r2 == nil, "appended second LDAPMessage must be rejected") + assert(e2 ~= nil, "appended second LDAPMessage must report an error") + + -- The exact-fit packet is unaffected. + local ok = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok.result_code == 0, "well-formed packet still decodes") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: a second APPLICATION protocolOp inside the envelope must be rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local r, e = protocol.decode_message(ldap_hex( + "30 15 02 01 01 61 07 0a 01 31 04 00 04 00 61 07 0a 01 00 04 00 04 00")) + assert(r == nil, "second APPLICATION protocolOp must be rejected") + assert(e ~= nil, "second APPLICATION protocolOp must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: attribute values are always an array, never a bare string +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local pkt = ldap_hex("30 21 02 01 03 64 1c 04 01 78 30 17 30 15" .. + "04 08 6d 65 6d 62 65 72 4f 66" .. + "04 09 63 6e 3d 61 64 6d 69 6e 73") + local r, e = protocol.decode_message(pkt) + assert(r == nil, "non-SET vals must be rejected") + assert(e ~= nil, "non-SET vals must report an error") + + -- Well-formed SET OF still yields an array. + local good = assert(protocol.decode_message(ldap_hex( + "30 23 02 01 03 64 1e 04 01 78 30 19 30 17" .. + "04 08 6d 65 6d 62 65 72 4f 66" .. + "31 0b 04 09 63 6e 3d 61 64 6d 69 6e 73"))) + assert(type(good.attributes.memberOf) == "table", "vals is an array") + assert(good.attributes.memberOf[1] == "cn=admins", "vals content") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: a duplicate attribute type is decoded, not treated as a protocol error +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- PartialAttributeList: memberOf={"cn=admins"} followed by memberOf={}. + local pkt = ldap_hex("30 31 02 01 03 64 2c 04 01 78 30 27" .. + "30 17 04 08 6d 65 6d 62 65 72 4f 66 31 0b 04 09 63 6e 3d 61 64 6d 69 6e 73" .. + "30 0c 04 08 6d 65 6d 62 65 72 4f 66 31 00") + local r, e = protocol.decode_message(pkt) + assert(r ~= nil, "duplicate attribute type must decode: " .. tostring(e)) + assert(type(r.attributes.memberOf) == "table", + "memberOf must still be an array, got " .. type(r.attributes.memberOf)) + for k in pairs(r.attributes) do + assert(type(k) == "string", "attribute key is a " .. type(k)) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: an undecodable SET/SEQUENCE member must error, not shrink the array +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local cases = { + { "vals with EOC member", + "30 20 02 01 03 64 1b 04 01 78 30 16 30 14" .. + "04 08 6d 65 6d 62 65 72 4f 66 31 08 04 01 61 00 00 04 01 62" }, + { "vals with BOOLEAN member", + "30 21 02 01 03 64 1c 04 01 78 30 17 30 15" .. + "04 08 6d 65 6d 62 65 72 4f 66 31 09 04 01 61 01 01 ff 04 01 62" }, + { "SearchResRef with BOOLEAN member", + "30 0e 02 01 02 73 09 04 01 61 01 01 ff 04 01 62" }, + { "SearchResRef with EOC member", + "30 0d 02 01 02 73 08 04 01 61 00 00 04 01 62" }, + } + for _, c in ipairs(cases) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + assert(r == nil, c[1] .. " must be rejected, got a result") + assert(e ~= nil, c[1] .. " must report an error") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: constructed OCTET STRING (0x24) is rejected at every nesting level +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local cases = { + { "matchedDN", "30 13 02 01 01 61 0e 0a 01 31 24 07 04 02 41 42 04 01 43 04 00" }, + { "diagnosticMsg", "30 10 02 01 01 61 0b 0a 01 31 04 00 24 04 04 02 41 42" }, + { "objectName", "30 10 02 01 03 64 0b 24 07 04 02 41 42 04 01 43 30 00" }, + { "attribute value", "30 17 02 01 03 64 12 04 01 78 30 0d 30 0b 04 01 6d 31 06 24 04 04 02 41 42" }, + { "SearchResRef URI","30 0a 02 01 02 73 05 24 03 04 01 61" }, + } + for _, c in ipairs(cases) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + assert(r == nil, c[1] .. ": constructed OCTET STRING must be rejected") + assert(e ~= nil, c[1] .. ": constructed OCTET STRING must report an error") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: PartialAttributeList / PartialAttribute containers must be SEQUENCEs +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local cases = { + { "PartialAttributeList as SET", + "30 14 02 01 03 64 0f 04 01 78 31 0a 30 08 04 01 6d 31 03 04 01 76" }, + { "PartialAttribute as CONTEXT [16]", + "30 14 02 01 03 64 0f 04 01 78 30 0a b0 08 04 01 6d 31 03 04 01 76" }, + { "primitive APPLICATION protocolOp", + "30 0c 02 01 01 41 07 0a 01 00 04 00 04 00" }, + } + for _, c in ipairs(cases) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + assert(r == nil, c[1] .. " must be rejected, got a result") + assert(e ~= nil, c[1] .. " must report an error") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 8: truncation and byte-flip sweep never raises a Lua exception +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local pkt = ldap_hex("30 12 02 01 01 61 0d 0a 01 00 04 04 64 63 3d 78 04 02 68 69") + assert(#pkt == 20, "packet length " .. #pkt) + local base = assert(protocol.decode_message(pkt)) + assert(base.result_code == 0 and base.matched_dn == "dc=x", "baseline") + + -- Every proper prefix must fail cleanly. + for k = 0, #pkt - 1 do + local pok, r, e = pcall(protocol.decode_message, pkt:sub(1, k)) + assert(pok, "truncation at " .. k .. " raised: " .. tostring(r)) + assert(r == nil, "truncation at " .. k .. " decoded to a result") + assert(e ~= nil, "truncation at " .. k .. " returned nil with no error") + end + + -- Single-byte corruption must never escape as an exception. + local flips = { 0x00, 0x01, 0x24, 0x30, 0x7f, 0x80, 0x84, 0xff } + for i = 1, #pkt do + for _, b in ipairs(flips) do + local m = pkt:sub(1, i - 1) .. string.char(b) .. pkt:sub(i + 1) + local pok, r, e = pcall(protocol.decode_message, m) + assert(pok, string.format("byte %d -> %02x raised: %s", i, b, tostring(r))) + assert(r ~= nil or e ~= nil, + string.format("byte %d -> %02x returned nil with no error", i, b)) + end + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 9: BER leniency and hard limits are preserved +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local _, v = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(v == "ABC", "non-minimal length must still decode, got " .. tostring(v)) + local _, v2 = asn1.decode(ldap_hex("04 83 00 00 03 41 42 43")) + assert(v2 == "ABC", "3-octet non-minimal length must still decode, got " .. tostring(v2)) + -- matchedDN "dc=x" and diagnosticMessage "hi" with long-form lengths + local ok = assert(protocol.decode_message(ldap_hex( + "30 14 02 01 01 61 0f 0a 01 00 04 81 04 64 63 3d 78 04 81 02 68 69"))) + assert(ok.result_code == 0, "non-minimal LDAPMessage result_code") + assert(ok.matched_dn == "dc=x" and ok.diagnostic_msg == "hi", + "non-minimal LDAPMessage fields") + -- long-form envelope and protocolOp headers + local ok2 = assert(protocol.decode_message(ldap_hex( + "30 81 13 02 01 01 61 81 0d 0a 01 00 04 04 64 63 3d 78 04 02 68 69"))) + assert(ok2.result_code == 0 and ok2.matched_dn == "dc=x", + "long-form envelope/protocolOp headers decode") + + local rejected = { + { "indefinite length", "30 80 02 01 01 61 07 0a 01 00 04 00 04 00 00 00" }, + { "length 84 ffffffff", "30 84 ff ff ff ff 02 01 01" }, + { "length 88 (8 octets)", "30 88 ff ff ff ff ff ff ff ff 02 01 01" }, + { "empty buffer", "" }, + { "envelope of length 0", "30 00" }, + } + for _, c in ipairs(rejected) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + assert(r == nil and e ~= nil, c[1] .. " must be rejected") + end + + local function enclen(n) + if n < 128 then return string.char(n) end + local out = "" + while n > 0 do out = string.char(n % 256) .. out; n = math.floor(n / 256) end + return string.char(0x80 + #out) .. out + end + local deep = ldap_hex("04 01 41") + for _ = 1, 500 do deep = "\48" .. enclen(#deep) .. deep end + local pok, _, dv, derr = pcall(asn1.decode, deep) + assert(pok, "deep nesting raised: " .. tostring(dv)) + assert(dv == nil and derr ~= nil, "deep nesting must be refused") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 10: regression guard -- genuinely empty SETs stay empty, not errors +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- typesOnly zero-member vals SET (31 00) must stay empty, not become an error + local res = 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(type(res.attributes.s) == "table", "s is an array") + assert(#res.attributes.s == 0, "s is empty, not an error") + + -- And a well-formed multi-valued SET still returns every member. + local res2 = assert(protocol.decode_message(ldap_hex( + "30 1e 02 01 03 64 19 04 01 78 30 14 30 12 04 08 6d 65 6d 62 65 72 4f 66" .. + " 31 06 04 01 61 04 01 62"))) + assert(#res2.attributes.memberOf == 2, "both members returned") + assert(res2.attributes.memberOf[1] == "a", "member 1") + assert(res2.attributes.memberOf[2] == "b", "member 2") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode_op_dispatch.t b/t/decode_op_dispatch.t new file mode 100644 index 0000000..503ed9f --- /dev/null +++ b/t/decode_op_dispatch.t @@ -0,0 +1,106 @@ +# Test vectors: original, RFC 4511 protocolOp tags — https://www.rfc-editor.org/rfc/rfc4511.txt + +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: a protocolOp that is not a decodable response must error, never fabricate an LDAPResult +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- Each body is a valid LDAPResult; only the protocolOp tag varies -- non-response ops must be refused + local cases = { + -- [APPLICATION 30] constructed: unassigned in RFC 4511 + {"unassigned op 30", "30 0c 02 01 01 7e 07 0a 01 00 04 00 04 00"}, + -- request-side PDUs a server must never send (UnbindRequest is NULL, RFC 4511 s4.3) + {"BindRequest op 0", "30 0c 02 01 01 60 07 0a 01 00 04 00 04 00"}, + {"UnbindRequest op 2", "30 0c 02 01 01 62 07 0a 01 00 04 00 04 00"}, + {"SearchRequest op 3", "30 0c 02 01 01 63 07 0a 01 00 04 00 04 00"}, + {"ExtendedRequest op 23", "30 0c 02 01 01 77 07 0a 01 00 04 00 04 00"}, + -- primitive [APPLICATION 1]: every protocolOp is a SEQUENCE, so this is malformed + {"primitive BindResponse", "30 0c 02 01 01 41 07 0a 01 00 04 00 04 00"}, + } + + for _, c in ipairs(cases) do + local ok, res, err = pcall(protocol.decode_message, ldap_hex(c[2])) + assert(ok, c[1] .. ": decode_message raised instead of returning an error: " .. tostring(res)) + assert(res == nil, + c[1] .. ": must be rejected, but decoded to result_code=" .. + tostring(type(res) == "table" and res.result_code or "?")) + assert(type(err) == "string" and #err > 0, c[1] .. ": missing error message") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 2: the allowlist must not overreach -- all six response ops the library supports still decode +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local APP = protocol.APP_NO + local ldap_hex = require("ldap_hex") + + local ldap_result = { + {"BindResponse", APP.BindResponse, "30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"}, + {"SearchResultDone", APP.SearchResultDone, "30 0c 02 01 02 65 07 0a 01 00 04 00 04 00"}, + {"ModifyResponse", APP.ModifyResponse, "30 0c 02 01 02 67 07 0a 01 00 04 00 04 00"}, + {"ExtendedResponse", APP.ExtendedResponse, "30 0c 02 01 01 78 07 0a 01 00 04 00 04 00"}, + } + for _, c in ipairs(ldap_result) do + local res, err = protocol.decode_message(ldap_hex(c[3])) + assert(res, c[1] .. ": must still decode, got: " .. tostring(err)) + assert(res.protocol_op == c[2], c[1] .. ": op " .. tostring(res.protocol_op)) + assert(res.result_code == 0, c[1] .. ": result_code " .. tostring(res.result_code)) + assert(res.matched_dn == "" and res.diagnostic_msg == "", c[1] .. ": LDAPResult fields") + end + + local e, eerr = protocol.decode_message(ldap_hex("30 0a 02 01 03 64 05 04 01 78 30 00")) + assert(e, "SearchResultEntry must still decode, got: " .. tostring(eerr)) + assert(e.protocol_op == APP.SearchResultEntry and e.entry_dn == "x", "entry") + -- a real entry carries no LDAPResult, so none may be invented for it + assert(e.result_code == nil, "SearchResultEntry must not carry a result_code") + + local r, rerr = protocol.decode_message(ldap_hex("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78")) + assert(r, "SearchResultReference must still decode, got: " .. tostring(rerr)) + assert(r.protocol_op == APP.SearchResultReference, "ref op") + assert(r.uris and r.uris[1] == "ldap://x", "ref uri") + assert(r.result_code == nil, "SearchResultReference must not carry a result_code") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode_protocol.t b/t/decode_protocol.t new file mode 100644 index 0000000..0930d09 --- /dev/null +++ b/t/decode_protocol.t @@ -0,0 +1,97 @@ +# Test vectors: original, hand-built from RFC 4511 grammar — https://www.rfc-editor.org/rfc/rfc4511.txt + +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: LDAPResult fields must have the right ASN.1 types +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- a wrong ASN.1 type in the resultCode slot is rejected strictly: nil result AND an + -- error, never a mistyped result table (an OCTET STRING resultCode becomes "\0", + -- compares ~= 0, so a successful bind would read as failure) + local cases = { + { "NULL", "30 0b 02 01 01 61 06 05 00 04 00 04 00" }, + { "BOOLEAN", "30 0c 02 01 01 61 07 01 01 ff 04 00 04 00" }, + { "OCTET STRING", "30 0c 02 01 01 61 07 04 01 00 04 00 04 00" }, + { "SEQUENCE", "30 0b 02 01 01 61 06 30 00 04 00 04 00" }, + } + for _, c in ipairs(cases) do + local res, err = protocol.decode_message(ldap_hex(c[2])) + assert(res == nil, c[1] .. " resultCode must not produce a result table") + assert(err ~= nil, c[1] .. " resultCode must report an error") + end + + -- matchedDN slot holds an INTEGER: matched_dn becomes the number 5 + local bad3, err3 = protocol.decode_message(ldap_hex("30 0d 02 01 01 61 08 0a 01 00 02 01 05 04 00")) + assert(bad3 == nil and err3 ~= nil, "non-string matchedDN must be rejected") + + -- consequence guard: a decoded result_code must survive the client error formatter, + -- which string-concatenates it into "Unknown error occurred (code: ...)" + local good = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(type(good.result_code) == "number", "result_code is a number") + local built = pcall(function() + return "Unknown error occurred (code: " .. good.result_code .. ")" + end) + assert(built, "client error formatter throws on this resultCode") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: a repeated attribute type still decodes to a well-formed attribute map +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- PartialAttributeList carries "uid" twice: {"a"} then {"b"} + local pkt = ldap_hex([[30 22 02 01 03 64 1d + 04 01 78 + 30 18 + 30 0a 04 03 75 69 64 31 03 04 01 61 + 30 0a 04 03 75 69 64 31 03 04 01 62]]) + local res, err = protocol.decode_message(pkt) + assert(res ~= nil, "duplicate attribute type must decode: " .. tostring(err)) + assert(res.entry_dn == "x", "entry_dn") + local uid = res.attributes.uid + assert(type(uid) == "table", "uid must be an array, got " .. type(uid)) + assert(#uid == 1 and (uid[1] == "a" or uid[1] == "b"), + "uid must hold one of the values actually sent") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode_rfc4511.t b/t/decode_rfc4511.t new file mode 100644 index 0000000..90bb5eb --- /dev/null +++ b/t/decode_rfc4511.t @@ -0,0 +1,351 @@ +# Test vectors: Go go1.26.3 encoding/asn1 (INTEGER boundary) — https://github.com/golang/go/blob/go1.26.3/src/encoding/asn1/asn1_test.go + +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: every response type in APP_NO decodes per RFC 4511 +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- BindResponse [APPLICATION 1] success (RFC 4511 s4.2.2) + local r = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(r.message_id == 1 and r.protocol_op == protocol.APP_NO.BindResponse, "bind op") + assert(r.result_code == 0, "bind code") + + -- SearchResultDone [APPLICATION 5] (s4.5.2) + r = assert(protocol.decode_message(ldap_hex("30 0c 02 01 02 65 07 0a 01 00 04 00 04 00"))) + assert(r.protocol_op == protocol.APP_NO.SearchResultDone, "done op " .. r.protocol_op) + assert(r.result_code == 0, "done code") + + -- ModifyResponse [APPLICATION 7] (s4.6) + r = assert(protocol.decode_message(ldap_hex("30 0c 02 01 02 67 07 0a 01 00 04 00 04 00"))) + assert(r.protocol_op == protocol.APP_NO.ModifyResponse, "modify op " .. r.protocol_op) + assert(r.result_code == 0, "modify code") + + -- ExtendedResponse [APPLICATION 24] (s4.12) -- StartTLS reply shape + r = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 78 07 0a 01 00 04 00 04 00"))) + assert(r.protocol_op == protocol.APP_NO.ExtendedResponse, "ext op " .. r.protocol_op) + assert(r.result_code == 0, "ext code") + + -- SearchResultReference [APPLICATION 19] (s4.5.2) + r = assert(protocol.decode_message(ldap_hex("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78"))) + assert(r.protocol_op == protocol.APP_NO.SearchResultReference, "ref op") + assert(#r.uris == 1 and r.uris[1] == "ldap://x", "ref uri") + + r = assert(protocol.decode_message(ldap_hex([[30 2c 02 01 01 61 27 0a 01 31 04 00 + 04 20 38 30 30 39 30 33 30 38 3a 20 4c 64 61 70 45 72 72 3a 20 + 44 53 49 44 2d 30 43 30 39 30 34 34 45]]))) + assert(r.result_code == 49, "code 49 " .. tostring(r.result_code)) + assert(r.diagnostic_msg == "80090308: LdapErr: DSID-0C09044E", "diag " .. tostring(r.diagnostic_msg)) + + r = assert(protocol.decode_message(ldap_hex("30 0c 02 01 00 78 07 0a 01 34 04 00 04 00"))) + assert(r.message_id == 0, "unsolicited messageID must be preserved as 0") + assert(r.protocol_op == protocol.APP_NO.ExtendedResponse and r.result_code == 52, "notice of disconnection") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: Active-Directory-shaped entry: binary objectSid/objectGUID, non-UTF-8 values +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local pkt = ldap_hex([[ + 30 81 cc + 02 01 03 + 64 81 c6 + 04 26 43 4e 3d 4a 61 6e 65 20 44 6f 65 2c 43 4e 3d 55 73 65 72 + 73 2c 44 43 3d 65 78 61 6d 70 6c 65 2c 44 43 3d 63 6f 6d + 30 81 9b + 30 2b 04 09 6f 62 6a 65 63 74 53 69 64 + 31 1e 04 1c 01 05 00 00 00 00 00 05 15 00 00 00 dc f4 + dc 3b 83 3d 2b 46 82 8b a6 28 00 02 00 00 + 30 20 04 0a 6f 62 6a 65 63 74 47 55 49 44 + 31 12 04 10 bc c9 3a d0 00 e7 b1 4f b3 91 7d 6f 5c 00 + 1e a9 + 30 10 04 02 73 6e + 31 0a 04 08 4d fc 6c 6c 65 72 ff fe + 30 38 04 0b 6f 62 6a 65 63 74 43 6c 61 73 73 + 31 29 04 03 74 6f 70 + 04 06 70 65 72 73 6f 6e + 04 14 6f 72 67 61 6e 69 7a 61 74 69 6f 6e 61 6c + 50 65 72 73 6f 6e + 04 04 75 73 65 72]]) + assert(#pkt == 207, "fixture length " .. #pkt) + + local r = assert(protocol.decode_message(pkt)) + assert(r.protocol_op == protocol.APP_NO.SearchResultEntry, "op") + assert(r.entry_dn == "CN=Jane Doe,CN=Users,DC=example,DC=com", "dn " .. tostring(r.entry_dn)) + + local sid = r.attributes.objectSid[1] + assert(#sid == 28, "objectSid truncated at a NUL: " .. #sid) + assert(sid == ldap_hex("01 05 00 00 00 00 00 05 15 00 00 00 dc f4 dc 3b 83 3d 2b 46 82 8b a6 28 00 02 00 00"), + "objectSid bytes") + -- the SID must survive byte-for-byte: S-1-5-21-1004336348-1177238915-682003330-512 + assert(sid:byte(1) == 1 and sid:byte(2) == 5, "SID revision/subauth count") + + local guid = r.attributes.objectGUID[1] + assert(#guid == 16, "objectGUID truncated at a NUL: " .. #guid) + assert(guid == ldap_hex("bc c9 3a d0 00 e7 b1 4f b3 91 7d 6f 5c 00 1e a9"), "objectGUID bytes") + + -- invalid UTF-8 must pass through untouched, not be replaced or rejected + local sn = r.attributes.sn[1] + assert(#sn == 8, "sn length " .. #sn) + assert(sn == ldap_hex("4d fc 6c 6c 65 72 ff fe"), "non-UTF-8 sn bytes") + + assert(#r.attributes.objectClass == 4, "objectClass count " .. #r.attributes.objectClass) + assert(r.attributes.objectClass[4] == "user", "objectClass[4]") + + local root = assert(protocol.decode_message(ldap_hex([[30 4d 02 01 03 64 48 04 00 30 44 + 30 1b 04 14 73 75 70 70 6f 72 74 65 64 4c 44 41 50 56 65 72 73 69 6f 6e + 31 03 04 01 33 + 30 25 04 0e 6e 61 6d 69 6e 67 43 6f 6e 74 65 78 74 73 + 31 13 04 11 44 43 3d 65 78 61 6d 70 6c 65 2c 44 43 3d 63 6f 6d]]))) + assert(root.entry_dn == "", "root DSE dn must be the empty string, got " .. tostring(root.entry_dn)) + assert(root.attributes.namingContexts[1] == "DC=example,DC=com", "namingContexts") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: optional trailing LDAPResult components are tolerated; only envelope-level trailing bytes are rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local r = assert(protocol.decode_message(ldap_hex([[30 1b 02 01 01 61 16 + 0a 01 0a 04 00 04 00 + a3 0d 04 0b 6c 64 61 70 3a 2f 2f 72 2f 78 79]])), + "BindResponse with referral [3] must parse") + assert(r.result_code == 10, "referral result code " .. tostring(r.result_code)) + assert(r.matched_dn == "" and r.diagnostic_msg == "", "mandatory fields intact") + + -- SearchResultDone carrying an AD-style referral URI + r = assert(protocol.decode_message(ldap_hex([[30 34 02 01 02 65 2f + 0a 01 0a 04 00 04 00 + a3 24 04 22 6c 64 61 70 3a 2f 2f 44 6f 6d 61 69 6e 44 6e 73 5a 6f + 6e 65 73 2e 65 78 61 6d 70 6c 65 2e 63 6f 6d 2f 64 63]])), + "SearchResultDone with referral [3] must parse") + assert(r.protocol_op == protocol.APP_NO.SearchResultDone and r.result_code == 10, "done+referral") + + r = assert(protocol.decode_message(ldap_hex("30 12 02 01 01 61 0d 0a 01 0e 04 00 04 00 87 04 de ad be ef")), + "BindResponse with serverSaslCreds [7] must parse") + assert(r.result_code == 14, "saslBindInProgress " .. tostring(r.result_code)) + + r = assert(protocol.decode_message(ldap_hex([[30 29 02 01 01 78 24 + 0a 01 00 04 00 04 00 + 8a 16 31 2e 33 2e 36 2e 31 2e 34 2e 31 2e 31 34 36 36 2e 32 30 30 33 37 + 8b 03 41 42 43]])), + "ExtendedResponse with responseName/responseValue must parse") + assert(r.protocol_op == protocol.APP_NO.ExtendedResponse and r.result_code == 0, "ext w/ optionals") + + r = assert(protocol.decode_message(ldap_hex("30 81 0c 02 01 01 61 07 0a 01 00 04 00 04 00")), + "non-minimal envelope length must still decode") + assert(r.result_code == 0, "non-minimal envelope result") + + -- non-minimal protocolOp length: BER length leniency is the deliberate AD/slapd + -- interop contract (decode_hostile.t pins the same leniency with long-form op + -- headers and non-empty fields, so the two files cross-reference, not duplicate) + r = assert(protocol.decode_message(ldap_hex("30 81 0d 02 01 01 61 81 07 0a 01 00 04 00 04 00")), + "non-minimal protocolOp length must still decode") + assert(r.protocol_op == protocol.APP_NO.BindResponse, "non-minimal op") + assert(r.result_code == 0, "non-minimal op result_code") + assert(r.matched_dn == "" and r.diagnostic_msg == "", "non-minimal op fields") + + local bad, err = protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00 de ad be ef")) + assert(bad == nil, "trailing bytes after the envelope must be rejected") + assert(err ~= nil, "trailing bytes must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: an undecodable SearchResultEntry field must return an error, never raise +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- protocol.lua keys the attribute map on the decoded atype, so a nil or mistyped + -- atype formerly raised "table index is nil" -- every hostile case must return + -- (nil, err), never raise. AttributeDescription must be an LDAPString and + -- objectName an LDAPDN (RFC 4511 s4.1.4). + local cases = { + { "BOOLEAN attribute type", + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00" }, + { "NULL attribute type", + "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 05 00 31 00" }, + -- non-string AttributeDescription (INTEGER 5) would key attributes by number + { "INTEGER attribute type", + "30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 02 01 05 31 03 04 01 41" }, + -- constructed SEQUENCE attribute type: the key must never become a table + { "SEQUENCE attribute type", + "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 30 00 31 00" }, + -- objectName that is not an LDAPDN must be an error, not entry_dn == 5 + { "INTEGER objectName", + "30 0a 02 01 03 64 05 02 01 05 30 00" }, + -- an unreadable objectName must be an error, never a silent nil entry_dn + { "NULL objectName", + "30 0f 02 01 03 64 0a 05 00 30 06 30 04 04 00 31 00" }, + } + for _, c in ipairs(cases) do + local pok, res, err = pcall(protocol.decode_message, ldap_hex(c[2])) + assert(pok, c[1] .. " raised instead of returning an error: " .. tostring(res)) + assert(res == nil, c[1] .. " must not yield a result") + assert(err ~= nil, c[1] .. " must report an error") + end + + -- positive control: the same well-formed shape still decodes + local good = 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(good.entry_dn == "x", "well-formed entry_dn") + assert(type(good.attributes.s) == "table" and #good.attributes.s == 0, "empty vals array") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: SearchResultReference must not silently drop an undecodable URI +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- one vector per member category; each must reject with no partial uris list + local cases = { + -- corruption AFTER valid members: BOOLEAN between two real URIs + { "BOOLEAN middle member", [[30 1c 02 01 02 73 17 + 04 08 6c 64 61 70 3a 2f 2f 78 + 01 01 ff + 04 08 6c 64 61 70 3a 2f 2f 79]] }, + -- no-registered-decoder category: NULL member after a valid URI + { "NULL member after a URI", "30 0a 02 01 02 73 05 04 01 78 05 00" }, + -- decodable-but-non-string category: a nested SEQUENCE would land a table in + -- uris[1]; URI (RFC 4511 s4.1.10) is an OCTET STRING on the wire + { "nested SEQUENCE member", "30 09 02 01 02 73 04 30 02 04 00" }, + } + for _, c in ipairs(cases) do + local bad, err = protocol.decode_message(ldap_hex(c[2])) + assert(bad == nil, c[1] .. " must be rejected; got " .. + tostring(bad and #bad.uris) .. " uris") + assert(err ~= nil, c[1] .. " must report an error") + end + + -- a well-formed multi-URI reference still decodes in full + local r = assert(protocol.decode_message(ldap_hex([[30 23 02 01 02 73 1e + 04 08 6c 64 61 70 3a 2f 2f 78 + 04 08 6c 64 61 70 3a 2f 2f 79 + 04 08 6c 64 61 70 3a 2f 2f 7a]]))) + assert(#r.uris == 3, "3 uris, got " .. #r.uris) + assert(r.uris[3] == "ldap://z", "third uri") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: PartialAttributeList containers must actually be SEQUENCEs +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- attributes slot is an OCTET STRING (04) holding a PartialAttribute + local bad, err = protocol.decode_message(ldap_hex([[30 18 02 01 03 64 13 + 04 01 78 + 04 0e 30 0c 04 02 63 6e 31 06 04 04 4a 61 6e 65]])) + assert(bad == nil, "attributes must be a SEQUENCE; OCTET STRING yielded cn=" .. + tostring(bad and bad.attributes and bad.attributes.cn and bad.attributes.cn[1])) + assert(err ~= nil, "non-SEQUENCE attributes list must report an error") + + -- attributes slot is context-specific [16] (b0): same tag number as SEQUENCE, different class + local bad2, err2 = protocol.decode_message(ldap_hex([[30 14 02 01 03 64 0f + 04 01 78 + b0 0a 30 08 04 02 63 6e 31 02 04 00]])) + assert(bad2 == nil, "context-specific [16] is not a SEQUENCE") + assert(err2 ~= nil, "context-specific [16] attributes list must report an error") + + -- an individual PartialAttribute encoded as an OCTET STRING + local bad3, err3 = protocol.decode_message(ldap_hex([[30 18 02 01 03 64 13 + 04 01 78 + 30 0e 04 0c 04 02 63 6e 31 06 04 04 4a 61 6e 65]])) + assert(bad3 == nil, "PartialAttribute must be a SEQUENCE; got cn=" .. + tostring(bad3 and bad3.attributes and bad3.attributes.cn and bad3.attributes.cn[1])) + assert(err3 ~= nil, "non-SEQUENCE PartialAttribute must report an error") + + -- the well-formed equivalent still decodes + local r = assert(protocol.decode_message(ldap_hex([[30 16 02 01 03 64 11 + 04 01 78 + 30 0c 30 0a 04 02 63 6e 31 04 04 02 4a 44]]))) + assert(r.attributes.cn[1] == "JD", "well-formed entry still decodes") + + 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_hex.lua b/t/lib/ldap_hex.lua new file mode 100644 index 0000000..c7db34a --- /dev/null +++ b/t/lib/ldap_hex.lua @@ -0,0 +1,14 @@ +-- Decode a whitespace-tolerant hex string into raw bytes, e.g. +-- h("30 0c 02 01 01") -> "\48\12\2\1\1". Whitespace is stripped first. +return function(s) + local hex = s:gsub("%s+", "") + if hex:find("%X") then + error("ldap_hex: non-hex character in input", 2) + end + if #hex % 2 ~= 0 then + error("ldap_hex: odd number of hex digits", 2) + end + return (hex:gsub("%x%x", function(b) + return string.char(tonumber(b, 16)) + end)) +end diff --git a/t/lib/ldap_msgid.lua b/t/lib/ldap_msgid.lua new file mode 100644 index 0000000..073a3e8 --- /dev/null +++ b/t/lib/ldap_msgid.lua @@ -0,0 +1,8 @@ +-- Decode the messageID INTEGER out of an encoded LDAPMessage packet. +local asn1 = require("resty.ldap.asn1") + +return function(packet) + local env = assert(asn1.get_object(packet, 0)) + local _, id = asn1.decode(packet, env.offset, env.offset + env.len) + return id +end diff --git a/t/lib/upvalue.lua b/t/lib/upvalue.lua new file mode 100644 index 0000000..96385a9 --- /dev/null +++ b/t/lib/upvalue.lua @@ -0,0 +1,29 @@ +-- Reach module-private locals from tests: read (get) or replace (set) a +-- function's named upvalue via the debug library. +local function find(fn, name) + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then return nil end + if n == name then return i, v end + i = i + 1 + end +end + +local _M = {} + +function _M.get(fn, name) + local _, v = find(fn, name) + return v +end + +function _M.set(fn, name, value) + local i = find(fn, name) + if not i then + return nil, "upvalue not found: " .. name + end + debug.setupvalue(fn, i, value) + return true +end + +return _M 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 e61f6ea..2c3a21c 100644 --- a/t/search.t +++ b/t/search.t @@ -115,7 +115,8 @@ GET /t ngx.exit(401) end - assert(#res == 1, "result length is not equal to 1") + -- 2 entries: the image seeds ou=users and ou=groups below the base + assert(#res == 2, "result length is not equal to 2") assert(res[1].entry_dn == "ou=users,dc=example,dc=org", "result 1 entry_dn is not equal to ou=users,dc=example,dc=org") } } @@ -369,7 +370,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") } } @@ -531,7 +533,8 @@ GET /t ngx.exit(401) end - assert(#res == 5, "result length is not equal to 5") + -- 7 entries: 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 @@ -585,6 +588,9 @@ GET /t local client = ldap_client:new("127.0.0.1", 1389) + -- pin the session so the modify runs on the bound connection + assert(client:connect()) + -- auth local res, err = client:simple_bind("cn=admin,dc=example,dc=org", "adminpassword") if not res then @@ -606,6 +612,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 @@ -659,6 +667,9 @@ GET /t local client = ldap_client:new("127.0.0.1", 1389) + -- pin the session so the modify runs on the bound connection + assert(client:connect()) + -- auth local res, err = client:simple_bind("cn=admin,dc=example,dc=org", "adminpassword") if not res then @@ -680,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..14bc0b9 --- /dev/null +++ b/t/search_ad.t @@ -0,0 +1,78 @@ +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:connect()) + 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:connect()) + 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..e081adb --- /dev/null +++ b/t/session.t @@ -0,0 +1,195 @@ +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 pinned connection +--- 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 keepalive pool with a single-shot op, so the pinned + -- checkout below is observable via getreusedtimes() (a brand-new + -- connection always reports 0, pooled ones report >= 1) + assert(c:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)")) + + assert(c:connect()) + local pinned_sock = c.socket + + local ok, err = c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword") + assert(ok, "bind: " .. tostring(err)) + + 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 socket must have been reused (bind + search on the same conn) + assert(rawequal(c.socket, pinned_sock), "socket changed mid-session") + assert(c.socket:getreusedtimes() >= 1, "connection was not pinned/reused") + + assert(c:set_keepalive()) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: single-shot (no session) still works unchanged +--- 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") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: close() unpins the session and the next op reconnects +--- 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:connect()) + assert(c.pinned, "connect pins the session") + local pinned_sock = c.socket + + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + assert(rawequal(c.socket, pinned_sock), "socket changed mid-session") + + assert(c:close()) + assert(c.pinned == nil, "close unpins") + assert(c.socket == nil, "close drops the socket") + + -- a later op checks out its own connection, still unpinned + 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(c.pinned == nil, "single-shot op stays unpinned") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: a hard socket error unpins the session 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:connect()) + + -- kill the pinned connection underneath the client + c.socket:close() + + local ok, err = c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword") + assert(not ok, "bind on a dead socket must fail") + assert(err ~= nil, "bind on a dead socket reports an error") + + -- the failure must release the pin, not strand the client on it + assert(c.pinned == nil, "hard error left the session pinned") + 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") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +attempt to send data on a closed socket + +=== TEST 5: releasing a bound session closes the socket instead of pooling 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") + + -- prime the pool so the pinned checkout below is observably reused + 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=*)")) + + assert(c:connect()) + assert(c.socket:getreusedtimes() >= 1, "pinned checkout should hit the pool") + assert(c:simple_bind("cn=admin,dc=example,dc=org", "adminpassword")) + assert(c:set_keepalive()) -- bound: must close, not pool + + -- a new anonymous client must get a fresh connection; its search + -- must not inherit the admin identity + local d = ldap_client:new("127.0.0.1", 1389) + assert(d:connect()) + assert(d.socket:getreusedtimes() == 0, + "admin-bound socket leaked into the pool") + local res, serr = d:search("dc=example,dc=org", + protocol.SEARCH_SCOPE_BASE_OBJECT, nil, nil, nil, nil, "(objectClass=*)") + assert(res, "anonymous search: " .. tostring(serr)) + assert(#res == 1 and res[1].entry_dn == "dc=example,dc=org", "base entry") + 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 -}