From 68621d57763ec06c5b0e901f85103722c63efd55 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 14:22:33 +0800 Subject: [PATCH 01/27] feat(asn1): add pure-Lua BER decode primitives Restore get_object/decode for the pure-Lua LDAP response path (RFC api7/rfcs#116, slice 1): NUL-safe octet-string slicing, header-length based structural walk, indefinite-length rejection, and a recursion depth guard. Adds t/asn1.t (12 assertions). --- lib/resty/ldap/asn1.lua | 118 ++++++++++++++++++++++++++++++++++++ t/asn1.t | 130 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 t/asn1.t diff --git a/lib/resty/ldap/asn1.lua b/lib/resty/ldap/asn1.lua index a248f65..b50e42e 100644 --- a/lib/resty/ldap/asn1.lua +++ b/lib/resty/ldap/asn1.lua @@ -2,11 +2,16 @@ 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_char = string.char +local string_sub = string.sub +local table_insert = table.insert 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 [[ @@ -161,4 +166,117 @@ 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 + 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) + -- The 0x80 bit signals an encoding error (also set for the illegal + -- indefinite/too-long forms). Reject rather than trust the result. + if band(ret, 0x80) == 0x80 or band(ret, 0x01) == 0x01 then + return nil, "invalid BER: bad tag/length encoding" + 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 (fixes the hardcoded +2) + cons = band(ret, 0x20) == 0x20, + } + end +end +_M.get_object = asn1_get_object + + +local decode +do + local decoder = new_tab(0, 5) + + -- OCTET STRING: slice raw content bytes directly. Binary/NUL-safe by + -- construction (no strlen-based ffi_string, no d2i). + decoder[TAG.OCTET_STRING] = function(der, _, obj) + return string_sub(der, obj.offset + 1, obj.offset + obj.len) + end + + -- INTEGER / ENUMERATED: d2i parses a full TLV, so `offset` is the TLV start. + decoder[TAG.INTEGER] = function(der, offset, obj) + 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 + end + local v = C.ASN1_INTEGER_get(typ) + C.ASN1_INTEGER_free(typ) + return tonumber(v) + end + + decoder[TAG.ENUMERATED] = function(der, offset, obj) + 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 + end + local v = C.ASN1_ENUMERATED_get(typ) + C.ASN1_INTEGER_free(typ) -- shares the ASN1_STRING struct; INTEGER_free is correct + return tonumber(v) + end + + local function decode_children(der, _, obj, depth) + local stop = obj.offset + obj.len + local pos = obj.offset + local values = {} + while pos < stop do + local value, err + pos, value, err = decode(der, pos, depth + 1) + if err then + return nil + end + table_insert(values, value) + end + return values + end + + decoder[TAG.SEQUENCE] = decode_children + decoder[TAG.SET] = decode_children + + -- offset is 0-indexed. Returns next_offset, value, err. + function decode(der, offset, 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) + if not obj then + return nil, nil, err + end + local d = decoder[obj.tag] + local value + if d then + value = d(der, offset, obj, depth) + end + return obj.offset + obj.len, value + end +end +_M.decode = decode + + return _M diff --git a/t/asn1.t b/t/asn1.t new file mode 100644 index 0000000..bed6d9c --- /dev/null +++ b/t/asn1.t @@ -0,0 +1,130 @@ +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: 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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + + -- short form: 30 03 02 01 05 (SEQUENCE len 3) + local o = assert(asn1.get_object(h("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(h("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(h("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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + -- 04 05 41 42 00 43 44 => "AB\0CD" (5 bytes, embedded NUL) + local _, v = asn1.decode(h("04 05 41 42 00 43 44")) + assert(#v == 5, "length " .. #v) -- would be 2 under the old strlen bug + 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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + + local _, id = asn1.decode(h("02 01 03")) + assert(id == 3, "int " .. tostring(id)) + local _, code = asn1.decode(h("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(h("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 = h("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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + -- claims 5 content bytes, only 2 present + local off, v, err = asn1.decode(h("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] From 80231df2ce323bba04bc1dc66b346fe52817c233 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 15:25:25 +0800 Subject: [PATCH 02/27] feat(protocol): add pure-Lua LDAP message decoder decode_message replaces the Rust rasn.decode_ldap, reproducing its exact output shape (LDAPResult ops; SearchResultEntry with always-array attributes) so client.lua and the existing tests are unchanged. Adds real SearchResultReference (op 19) handling, ModifyResponse/SearchResultReference to APP_NO, and envelope/tag-class guards. Adds t/decode.t (18 assertions). --- lib/resty/ldap/protocol.lua | 92 ++++++++++++++++++++ t/decode.t | 169 ++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 t/decode.t diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index ef25f56..6420a15 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -2,7 +2,10 @@ 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 asn1_get_object = asn1.get_object +local asn1_decode = asn1.decode local string_char = string.char +local table_insert = table.insert local _M = {} @@ -25,6 +28,8 @@ _M.APP_NO = { SearchRequest = 3, SearchResultEntry = 4, SearchResultDone = 5, + ModifyResponse = 7, + SearchResultReference = 19, ExtendedRequest = 23, ExtendedResponse = 24 } @@ -185,4 +190,91 @@ function _M.search_request(base_obj, scope, deref_aliases, size_limit, time_limi end +-- Response decoding. Replaces the Rust `rasn.decode_ldap` with pure Lua while +-- reproducing its exact output shape so client.lua and the tests are unchanged. + +local function parse_ldap_result(packet, op, res) + local pos, code, matched_dn, diag, err + pos, code, err = asn1_decode(packet, op.offset) -- resultCode ENUMERATED + if err then return nil, err end + res.result_code = code + pos, matched_dn, err = asn1_decode(packet, pos) -- matchedDN OCTET STRING + if err then return nil, err end + res.matched_dn = matched_dn + _, diag, err = asn1_decode(packet, pos) -- diagnosticMessage OCTET STRING + if err then return nil, err end + res.diagnostic_msg = diag + return res +end + +local function parse_search_entry(packet, op, res) + local pos, entry_dn, err + pos, entry_dn, err = asn1_decode(packet, op.offset) -- objectName + if err then return nil, err end + res.entry_dn = entry_dn + + local attrs, aerr = asn1_get_object(packet, pos) -- PartialAttributeList (SEQUENCE OF) + if not attrs then return nil, aerr end + + local attributes = {} + local apos = attrs.offset + local astop = attrs.offset + attrs.len + while apos < astop do + local pa, perr = asn1_get_object(packet, apos) -- PartialAttribute SEQUENCE + if not pa then return nil, perr end + local vpos, atype, terr = asn1_decode(packet, pa.offset) -- type + if terr then return nil, terr end + local _, vals, verr = asn1_decode(packet, vpos) -- vals SET OF -> array + if verr then return nil, verr end + attributes[atype] = vals or {} -- ALWAYS an array (empty for typesOnly) + apos = pa.offset + pa.len + 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. + local uris = {} + local pos = op.offset + local stop = op.offset + op.len + while pos < stop do + local uri, err + pos, uri, err = asn1_decode(packet, pos) + if err then return nil, err end + table_insert(uris, 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 + if env.tag ~= asn1.TAG.SEQUENCE or env.class ~= asn1.CLASS.UNIVERSAL then + return nil, "invalid LDAPMessage envelope" + end + + local pos, message_id, merr = asn1_decode(packet, env.offset) -- messageID + if merr then return nil, merr end + + local op, oerr = asn1_get_object(packet, pos) -- protocolOp + if not op then return nil, oerr end + if op.class ~= asn1.CLASS.APPLICATION then + return nil, "protocolOp is not APPLICATION-tagged" + 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) + else + -- LDAPResult-shaped ops: BindResponse, SearchResultDone, ModifyResponse, ExtendedResponse + return parse_ldap_result(packet, op, res) + end +end + + return _M diff --git a/t/decode.t b/t/decode.t new file mode 100644 index 0000000..4ced918 --- /dev/null +++ b/t/decode.t @@ -0,0 +1,169 @@ +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: BindResponse success (LDAPResult shape) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + -- 30 0c 02 01 01 61 07 0a 01 00 04 00 04 00 (reference 6.1) + local res = assert(protocol.decode_message(h("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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + -- reference 4.5 worked 75-byte entry + local pkt = h([[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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + -- entry_dn "x", attribute "s" = single value bytes 01 00 02 + local res = assert(protocol.decode_message(h( + "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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + -- entry_dn "x", one attribute "s" with EMPTY vals set (typesOnly): 31 00 + local res = assert(protocol.decode_message(h( + "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(h("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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + -- msgID 2, ref with one URI "ldap://x" + local res = assert(protocol.decode_message(h("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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + -- SearchResultDone noSuchObject(32=0x20) + local res = assert(protocol.decode_message(h("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(h("04 01 41")) + assert(bad == nil and err ~= nil, "malformed rejected") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] From 86915b029c86bb0ae523127f2f496b4ed2ab9d71 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 15:35:48 +0800 Subject: [PATCH 03/27] feat(client): decode responses in pure Lua, drop rasn --- lib/resty/ldap/client.lua | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 442dbfc..a79a856 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -1,18 +1,18 @@ 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 (reference §1.2 DoS bound). 16 MiB comfortably exceeds any real entry. +local MAX_LDAP_MESSAGE_SIZE = 16 * 1024 * 1024 local _M = {} @@ -65,13 +65,10 @@ 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 + return fmt("failed to decode ldap message: %s, message: %s", + err or "unknown", to_hex(packet)) end if res.protocol_op ~= protocol.APP_NO.ExtendedResponse then @@ -181,6 +178,11 @@ local function _send_recieve(cli, request, multi_resp_hint) end local _, packet_len, packet_header = calculate_payload_length(len, 2, socket) + if packet_len > MAX_LDAP_MESSAGE_SIZE then + socket:close() + return nil, fmt("ldap message too large: %d bytes", packet_len) + end + -- Get the data of the specified length local packet, err = socket:receive(packet_len) if not packet then @@ -192,13 +194,10 @@ local function _send_recieve(cli, request, multi_resp_hint) 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 + return nil, fmt("failed to decode ldap message: %s, message: %s", + err or "unknown", to_hex(packet)) end table_insert(result, res) From 19293ad65eaa1300f377c2fe953af9ac44b5a28d Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 15:46:30 +0800 Subject: [PATCH 04/27] feat(client): add pinned-connection session --- lib/resty/ldap/client.lua | 56 ++++++++++++++++++++++++--- t/session.t | 80 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 t/session.t diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index a79a856..676042a 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -141,10 +141,13 @@ local function _init_socket(self) 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) + -- 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 + return nil, fmt("initialize socket failed: %s", err) + end end local socket = cli.socket @@ -218,8 +221,11 @@ local function _send_recieve(cli, request, multi_resp_hint) end end - -- put back into the connection pool - socket:setkeepalive(cli.socket_config.keepalive_timeout) + -- Only return the socket to the pool for single-shot ops; a pinned session + -- is released explicitly by the caller via set_keepalive()/close(). + if not cli.pinned then + socket:setkeepalive(cli.socket_config.keepalive_timeout) + end return multi_resp_hint and result or result[1] end @@ -255,6 +261,44 @@ 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 + return sock:setkeepalive(self.socket_config.keepalive_timeout) +end + + +function _M.close(self) + self.pinned = 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)) if not res then diff --git a/t/session.t b/t/session.t new file mode 100644 index 0000000..c4e1e05 --- /dev/null +++ b/t/session.t @@ -0,0 +1,80 @@ +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] From d05149e873c8e5d3f9b8112207896664942c4aaa Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 15:57:59 +0800 Subject: [PATCH 05/27] feat(filter): add RFC 4515 assertion-value escaping --- lib/resty/ldap/filter.lua | 22 +++++++++++++++++++ t/filter.t | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/lib/resty/ldap/filter.lua b/lib/resty/ldap/filter.lua index 524deb1..7ff8f39 100644 --- a/lib/resty/ldap/filter.lua +++ b/lib/resty/ldap/filter.lua @@ -190,4 +190,26 @@ function _M.compile(str) end +local ESCAPE_MAP = { + ["\0"] = "\\00", + ["*"] = "\\2a", + ["("] = "\\28", + [")"] = "\\29", + ["\\"] = "\\5c", + ["="] = "\\3d", +} + +-- 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. +-- '=' is not special per the RFC, but the grammar above is stricter and rejects +-- it raw inside a value; escaping any octet is RFC-legal, so escape it too. +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/t/filter.t b/t/filter.t index 7737fde..4092ac8 100644 --- a/t/filter.t +++ b/t/filter.t @@ -449,3 +449,48 @@ GET /t --- no_error_log [error] --- error_code: 200 + +=== TEST 100: 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 101: 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] From 82bba1a3e3db5396ded3a5bdcd6b9837b3a78a1a Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 16:07:20 +0800 Subject: [PATCH 06/27] feat: restore resty.ldap/resty.ldap.ldap for ldap-auth compat --- lib/resty/ldap.lua | 98 ++++++++++++++++++++++++ lib/resty/ldap/ldap.lua | 160 ++++++++++++++++++++++++++++++++++++++++ t/client.t | 24 ++++++ 3 files changed, 282 insertions(+) create mode 100644 lib/resty/ldap.lua create mode 100644 lib/resty/ldap/ldap.lua diff --git a/lib/resty/ldap.lua b/lib/resty/ldap.lua new file mode 100644 index 0000000..8c53b66 --- /dev/null +++ b/lib/resty/ldap.lua @@ -0,0 +1,98 @@ +local log = ngx.log +local ERR = ngx.ERR +local ldap = require "resty.ldap.ldap" + + +local tostring = tostring +local fmt = string.format +local tcp = ngx.socket.tcp + +local default_conf = { + timeout = 10000, + start_tls = false, + ldap_host = "localhost", + ldap_port = 389, + ldaps = false, + verify_ldap_host = 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 _M = {} + + +function _M.ldap_authenticate(given_username, given_password, conf) + set_conf_default_values(conf) + + local is_authenticated + local err, suppressed_err, ok, _ + + local sock = tcp() + + sock:settimeout(conf.timeout) + + local opts + + -- keep TLS connections in a separate pool to avoid reusing non-secure + -- connections and vice-versa, because STARTTLS use the same port + if conf.start_tls then + opts = { + pool = conf.ldap_host .. ":" .. conf.ldap_port .. ":starttls" + } + end + + ok, err = sock:connect(conf.ldap_host, conf.ldap_port, opts) + if not ok then + log(ERR, "failed to connect to ", conf.ldap_host, ":", + tostring(conf.ldap_port), ": ", err) + return nil, err + end + + if conf.start_tls then + -- convert connection to a STARTTLS connection only if it is a new connection + local count, err = sock:getreusedtimes() + if not count then + -- connection was closed, just return instead + return nil, err + end + + if count == 0 then + local ok, err = ldap.start_tls(sock) + if not ok then + return nil, err + end + end + end + + if conf.start_tls or conf.ldaps then + _, err = sock:sslhandshake(true, conf.ldap_host, conf.verify_ldap_host) + if err ~= nil then + return false, fmt("failed to do SSL handshake with %s:%s: %s", + conf.ldap_host, tostring(conf.ldap_port), err) + end + end + + local who = conf.attribute .. "=" .. given_username .. "," .. conf.base_dn + is_authenticated, err = ldap.bind_request(sock, who, given_password) + + ok, suppressed_err = sock:setkeepalive(conf.keepalive) + if not ok then + log(ERR, "failed to keepalive to ", conf.ldap_host, ":", + tostring(conf.ldap_port), ": ", suppressed_err) + end + + return is_authenticated, err +end + + +return _M diff --git a/lib/resty/ldap/ldap.lua b/lib/resty/ldap/ldap.lua new file mode 100644 index 0000000..0ac9498 --- /dev/null +++ b/lib/resty/ldap/ldap.lua @@ -0,0 +1,160 @@ +local asn1 = require("resty.ldap.asn1") +local protocol = require("resty.ldap.protocol") +local bunpack = require("lua_pack").unpack +local fmt = string.format +local decode_ldap = protocol.decode_message +local asn1_put_object = asn1.put_object +local asn1_encode = asn1.encode + + +local _M = {} + + +local ldapMessageId = 1 + + +local ERROR_MSG = { + [1] = "Initialization of LDAP library failed.", + [4] = "Size limit exceeded.", + [13] = "Confidentiality required", + [32] = "No such object", + [34] = "Invalid DN", + [49] = "The supplied credential is invalid." +} + + +local APPNO = { + BindRequest = 0, + BindResponse = 1, + UnbindRequest = 2, + ExtendedRequest = 23, + ExtendedResponse = 24 +} + + +local function calculate_payload_length(encStr, pos, socket) + local elen + + pos, elen = bunpack(encStr, "C", pos) + + if elen > 128 then + elen = elen - 128 + local elenCalc = 0 + local elenNext + + for i = 1, elen do + elenCalc = elenCalc * 256 + encStr = encStr .. socket:receive(1) + pos, elenNext = bunpack(encStr, "C", pos) + elenCalc = elenCalc + elenNext + end + + elen = elenCalc + end + + return pos, elen, encStr +end + + +function _M.bind_request(socket, username, password) + local ldapAuth = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, password) + local bindReq = asn1_encode(3) .. asn1_encode(username) .. ldapAuth + local ldapMsg = asn1_encode(ldapMessageId) .. + asn1_put_object(APPNO.BindRequest, asn1.CLASS.APPLICATION, 1, bindReq) + + local packet, packet_len, packet_header, _ + + packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) + + ldapMessageId = ldapMessageId + 1 + + socket:send(packet) + + packet = socket:receive(2) + + _, packet_len, packet_header = calculate_payload_length(packet, 2, socket) + + packet = socket:receive(packet_len) + + -- decode_message expects the full LDAPMessage, envelope header included + local res, err = decode_ldap(packet_header .. packet) + if err then + return false, "Invalid LDAP message encoding: " .. err + end + + if res.protocol_op ~= APPNO.BindResponse then + return false, fmt("Received incorrect Op in packet: %d, expected %d", + res.protocol_op, APPNO.BindResponse) + end + + if res.result_code ~= 0 then + local error_msg = ERROR_MSG[res.result_code] + + return false, fmt("\n Error: %s\n Details: %s", + error_msg or "Unknown error occurred (code: " .. + res.result_code .. ")", res.diagnostic_msg or "") + + else + return true + end +end + + +function _M.unbind_request(socket) + local ldapMsg, packet + + ldapMessageId = ldapMessageId + 1 + + ldapMsg = asn1_encode(ldapMessageId) .. + asn1_put_object(APPNO.UnbindRequest, asn1.CLASS.APPLICATION, 0) + packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) + + socket:send(packet) + + return true, "" +end + + +function _M.start_tls(socket) + local ldapMsg, packet, packet_len, packet_header, _ + + local method_name = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, "1.3.6.1.4.1.1466.20037") + + ldapMessageId = ldapMessageId + 1 + + ldapMsg = asn1_encode(ldapMessageId) .. + asn1_put_object(APPNO.ExtendedRequest, asn1.CLASS.APPLICATION, 1, method_name) + + packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) + socket:send(packet) + packet = socket:receive(2) + + _, packet_len, packet_header = calculate_payload_length(packet, 2, socket) + + packet = socket:receive(packet_len) + + -- decode_message expects the full LDAPMessage, envelope header included + local res, err = decode_ldap(packet_header .. packet) + if err then + return false, "Invalid LDAP message encoding: " .. err + end + + if res.protocol_op ~= APPNO.ExtendedResponse then + return false, fmt("Received incorrect Op in packet: %d, expected %d", + res.protocol_op, APPNO.ExtendedResponse) + end + + if res.result_code ~= 0 then + local error_msg = ERROR_MSG[res.result_code] + + return false, fmt("\n Error: %s\n Details: %s", + error_msg or "Unknown error occurred (code: " .. + res.result_code .. ")", res.diagnostic_msg or "") + + else + return true + end +end + + +return _M diff --git a/t/client.t b/t/client.t index 51ab0cd..68f70d0 100644 --- a/t/client.t +++ b/t/client.t @@ -152,3 +152,27 @@ GET /t --- no_error_log [error] --- error_code: 200 + + +=== TEST 100: 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] From 850031e06fe208c3ae59555efe3b1f93f1f75741 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 16:15:00 +0800 Subject: [PATCH 07/27] build: remove Rust, ship pure-Lua builtin v0.3.0 rockspec --- .cargo/config.toml | 11 --- .github/workflows/ci.yml | 3 - Cargo.toml | 17 ---- Makefile | 50 ++--------- rockspec/lua-resty-ldap-0.3.0-0.rockspec | 30 +++++++ rockspec/lua-resty-ldap-local-0.rockspec | 25 ++---- rockspec/lua-resty-ldap-main-0.rockspec | 25 ++---- src/ldap_codec/decoder.rs | 101 ----------------------- src/ldap_codec/encoder.rs | 11 --- src/ldap_codec/mod.rs | 2 - src/lib.rs | 14 ---- utils/check-rust.sh | 7 -- 12 files changed, 56 insertions(+), 240 deletions(-) delete mode 100644 .cargo/config.toml delete mode 100644 Cargo.toml create mode 100644 rockspec/lua-resty-ldap-0.3.0-0.rockspec delete mode 100644 src/ldap_codec/decoder.rs delete mode 100644 src/ldap_codec/encoder.rs delete mode 100644 src/ldap_codec/mod.rs delete mode 100644 src/lib.rs delete mode 100755 utils/check-rust.sh 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..5c74726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,9 +37,6 @@ jobs: 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 - # luarocks sudo apt -y install lua5.1 luarocks luarocks make rockspec/lua-resty-ldap-local-0.rockspec --local --tree ./deps # install deps to local 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..9292d1f 100644 --- a/Makefile +++ b/Makefile @@ -1,52 +1,18 @@ -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/ + git apply t/patch/unknown_op.patch -R + ### 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/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..eba11c8 --- /dev/null +++ b/rockspec/lua-resty-ldap-0.3.0-0.rockspec @@ -0,0 +1,30 @@ +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.ldap"] = "lib/resty/ldap/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..eb8e752 100644 --- a/rockspec/lua-resty-ldap-local-0.rockspec +++ b/rockspec/lua-resty-ldap-local-0.rockspec @@ -17,20 +17,13 @@ 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.ldap"] = "lib/resty/ldap/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..2072909 100644 --- a/rockspec/lua-resty-ldap-main-0.rockspec +++ b/rockspec/lua-resty-ldap-main-0.rockspec @@ -18,20 +18,13 @@ 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.ldap"] = "lib/resty/ldap/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/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 -} From c6a832c52093f07328fc0c456daf0dfed12e0a36 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 16:23:54 +0800 Subject: [PATCH 08/27] test: add AD-shaped fixture (login attr != RDN) + binary-attr e2e --- .github/workflows/ci.yml | 2 ++ t/fixtures/ad.ldif | 9 +++++ t/search.t | 6 ++-- t/search_ad.t | 78 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 t/fixtures/ad.ldif create mode 100644 t/search_ad.t diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c74726..8bba676 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,5 +45,7 @@ jobs: 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 + 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/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/search.t b/t/search.t index e61f6ea..bf97f6e 100644 --- a/t/search.t +++ b/t/search.t @@ -369,7 +369,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 +532,8 @@ GET /t ngx.exit(401) end - assert(#res == 5, "result length is not equal to 5") + -- 6 entries: the 5 bootstrap entries plus cn=Jane Doe from t/fixtures/ad.ldif + assert(#res == 6, "result length is not equal to 6") } } --- 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] From a819f25258ed0372820d72167af1b1639e02c702 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 16:44:31 +0800 Subject: [PATCH 09/27] fix(asn1): surface decoder errors on malformed BER --- lib/resty/ldap/asn1.lua | 13 ++++++++----- t/asn1.t | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/lib/resty/ldap/asn1.lua b/lib/resty/ldap/asn1.lua index b50e42e..5431f87 100644 --- a/lib/resty/ldap/asn1.lua +++ b/lib/resty/ldap/asn1.lua @@ -221,7 +221,7 @@ do 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 + return nil, "invalid INTEGER encoding" end local v = C.ASN1_INTEGER_get(typ) C.ASN1_INTEGER_free(typ) @@ -232,7 +232,7 @@ do 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 + return nil, "invalid ENUMERATED encoding" end local v = C.ASN1_ENUMERATED_get(typ) C.ASN1_INTEGER_free(typ) -- shares the ASN1_STRING struct; INTEGER_free is correct @@ -247,7 +247,7 @@ do local value, err pos, value, err = decode(der, pos, depth + 1) if err then - return nil + return nil, err end table_insert(values, value) end @@ -269,9 +269,12 @@ do return nil, nil, err end local d = decoder[obj.tag] - local value + local value, derr if d then - value = d(der, offset, obj, depth) + value, derr = d(der, offset, obj, depth) + if derr then + return nil, nil, derr + end end return obj.offset + obj.len, value end diff --git a/t/asn1.t b/t/asn1.t index bed6d9c..0d19014 100644 --- a/t/asn1.t +++ b/t/asn1.t @@ -128,3 +128,37 @@ GET /t 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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + + -- truncated child inside a SEQUENCE: outer len 3, inner OCTET + -- STRING claims 5 content bytes but only 1 present + local _, v, err = asn1.decode(h("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(h("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(h("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] From 8ddfff41821c12b488a48d451127838d481e6ed2 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 16:44:31 +0800 Subject: [PATCH 10/27] chore: drop stale Rust entries from .gitignore --- .gitignore | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.gitignore b/.gitignore index c51fdfb..a42679b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,2 @@ # dev t/servroot - - -# Added by cargo - -/target -/Cargo.lock From 1bbb2a80add96b71dd33186636b258ce136b57a8 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 17:52:50 +0800 Subject: [PATCH 11/27] fix(asn1): reject constructed OCTET STRING and fix length-header truncation Two issues found in review: - decode: a constructed OCTET STRING (0x24) was accepted and its inner TLV bytes returned as the value. RFC 4511 s5.1 mandates primitive-only OCTET STRINGs; a constructed one is a BER-smuggling vector against auth parsers. Reject it, mirroring the existing indefinite-length rejection. - encode: asn1.put_object read its header back with a strlen-based ffi_string, truncating any length octet containing 0x00 (content length 256 -> 82 01 00), corrupting requests at 256-byte boundaries. Read exactly the bytes written. This also makes a zero-length value encode correctly as 80 00, so protocol.simple_bind_request no longer needs its manual anonymous-bind length-byte workaround. Adds t/asn1.t TEST 6 (constructed reject) and TEST 7 (length round-trip). --- lib/resty/ldap/asn1.lua | 21 +++++++++++---- lib/resty/ldap/protocol.lua | 10 +++---- t/asn1.t | 54 +++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/lib/resty/ldap/asn1.lua b/lib/resty/ldap/asn1.lua index 5431f87..06a458a 100644 --- a/lib/resty/ldap/asn1.lua +++ b/lib/resty/ldap/asn1.lua @@ -81,20 +81,25 @@ local TAG = { _M.TAG = TAG +-- The TLV header is at most tag(1) + long-form-length(1 + up to 4) = 6 bytes. +local hdrbuf = ffi_new("unsigned char[16]") + local function asn1_put_object(tag, class, constructed, data, len) len = type(data) == "string" and #data or len or 0 if len < 0 then return nil, "invalid object length" end - local outbuf = ffi_new("unsigned char[?]", len) - ucharpp[0] = outbuf - + -- ASN1_put_object writes only the header and advances the pointer past it. + -- Read back exactly that many bytes: a strlen-based ffi_string(hdrbuf) would + -- truncate any length octet containing 0x00 (e.g. content length 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 @@ -213,6 +218,12 @@ do -- OCTET STRING: slice raw content bytes directly. Binary/NUL-safe by -- construction (no strlen-based ffi_string, no d2i). decoder[TAG.OCTET_STRING] = function(der, _, obj) + -- RFC 4511 s5.1: OCTET STRING values are primitive-only. A constructed + -- OCTET STRING (0x24) is a classic BER-smuggling vector against auth + -- parsers; reject it rather than return its inner TLV bytes as a value. + if obj.cons then + return nil, "constructed OCTET STRING not allowed" + end return string_sub(der, obj.offset + 1, obj.offset + obj.len) end diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index 6420a15..adb51d6 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -4,7 +4,6 @@ local asn1_put_object = asn1.put_object local asn1_encode = asn1.encode local asn1_get_object = asn1.get_object local asn1_decode = asn1.decode -local string_char = string.char local table_insert = table.insert @@ -53,13 +52,10 @@ end function _M.simple_bind_request(dn, password) + -- simple [0] OCTET STRING; a zero-length password (anonymous/unauthenticated + -- bind) now encodes correctly as `80 00` since asn1.put_object no longer + -- truncates the length octet. 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 local ldapMsg = ldap_message(_M.APP_NO.BindRequest, bindReq) return asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) diff --git a/t/asn1.t b/t/asn1.t index 0d19014..8c34433 100644 --- a/t/asn1.t +++ b/t/asn1.t @@ -162,3 +162,57 @@ GET /t 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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + + -- 24 07 04 02 41 42 04 01 43 : constructed OCTET STRING whose inner + -- TLVs must NOT be handed back as the value (RFC 4511 s5.1) + local _, v, err = asn1.decode(h("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(h("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] + +=== TEST 7: put_object length header is not truncated at 0x00 length octets +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + -- content lengths whose long-form length octets contain 0x00 + -- (256 -> 82 01 00, 512 -> 82 02 00) must round-trip through + -- encode -> decode; a strlen-based header read truncated these. + for _, n in ipairs({0, 1, 255, 256, 512, 768}) do + local data = string.rep("A", n) + local enc = asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, data) + local obj = assert(asn1.get_object(enc), "n=" .. n .. " failed to parse") + assert(obj.len == n, "n=" .. n .. " decoded len " .. tostring(obj.len)) + assert(#enc == obj.hl + n, "n=" .. n .. " total length mismatch") + end + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] From 1597923783c8bda9f26dae165bec233a1566e49b Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Fri, 17 Jul 2026 17:52:50 +0800 Subject: [PATCH 12/27] fix(filter): escape < and > in assertion values The LPeg grammar rejects raw < > (and =) inside a value, so escape()'s round-trip failed for those bytes; a username containing them would fail the (sAMAccountName=) search. Escaping any octet is RFC 4515-legal and round-trips, so add \3c/\3e. Valid multi-byte UTF-8 still passes raw; a lone invalid-UTF-8 byte stays fail-closed. Adds t/filter.t TEST 102. --- lib/resty/ldap/filter.lua | 11 ++++++++--- t/filter.t | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/lib/resty/ldap/filter.lua b/lib/resty/ldap/filter.lua index 7ff8f39..7bb67f9 100644 --- a/lib/resty/ldap/filter.lua +++ b/lib/resty/ldap/filter.lua @@ -197,18 +197,23 @@ local ESCAPE_MAP = { [")"] = "\\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. --- '=' is not special per the RFC, but the grammar above is stricter and rejects --- it raw inside a value; escaping any octet is RFC-legal, so escape it too. +-- 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)) + return (value:gsub("[%z%*%(%)\\=<>]", ESCAPE_MAP)) end diff --git a/t/filter.t b/t/filter.t index 4092ac8..208bf1e 100644 --- a/t/filter.t +++ b/t/filter.t @@ -494,3 +494,28 @@ GET /t ok --- no_error_log [error] + +=== TEST 102: 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] From 17c78cd713dbfa7c2f64f46e9b4ea5a6da9a8569 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Mon, 20 Jul 2026 08:53:40 +0800 Subject: [PATCH 13/27] docs: document session methods and restored compat modules --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/README.md b/README.md index 357cfe4..6c0e288 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,53 @@ 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. + +#### 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 (restored from v0.1.0). New code should use `resty.ldap.client` instead. + +```lua + local ldap = require "resty.ldap" +``` + +#### ldap_authenticate + +**syntax:** *ok, err = ldap.ldap_authenticate(username, password, conf)* + +Binds against the directory as `=,` with the given password. `ok` is `true` when authentication succeeds; otherwise `ok` is `false` or `nil` and `err` carries the error. + +`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 | Connection pool keepalive in milliseconds. | +| `start_tls` | boolean | false | Issue the StartTLS extended operation before binding. | +| `ldaps` | boolean | false | Connect using LDAP over TLS. | +| `verify_ldap_host` | boolean | false | Verify the server certificate during the TLS handshake. | +| `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. | + +### resty.ldap.ldap + +Low-level compatibility module used by `resty.ldap` (restored from v0.1.0): `bind_request`, `unbind_request` and `start_tls` over a caller-supplied cosocket. New code should use `resty.ldap.client` instead. From c1613fd3563d0c9a094f47dd2b8c31df45142b3e Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Mon, 20 Jul 2026 09:10:44 +0800 Subject: [PATCH 14/27] ci: fix workflow for retired runner, moved image, expired certs - runs-on ubuntu-20.04 was retired by GitHub Actions; use ubuntu-latest - bitnami/openldap:2.6 is gone from Docker Hub; pin bitnamilegacy/openldap:2.6.10-debian-12-r4 - replace removed apt-key with a signed-by keyring for the OpenResty repo - drop the docker install block (preinstalled on runners); install Test::Nginx via cpanm --notest instead of cpan - replace the fixed sleep with a slapd readiness probe so loading ad.ldif does not race container bootstrap - regenerate t/certs as a set (slapd cert expired 2023-07-27; new certs valid to 2036) so the ldaps ssl_verify test passes in CI --- .github/workflows/ci.yml | 29 +++--- t/certs/localhost_slapd_cert.pem | 41 ++++---- t/certs/localhost_slapd_key.pem | 162 ++++++------------------------- t/certs/mycacert.crt | 45 ++++----- 4 files changed, 77 insertions(+), 200 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bba676..e11a6bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,33 +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@v4 - 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 + sudo cpanm --notest Test::Nginx # luarocks sudo apt -y install lua5.1 luarocks @@ -43,8 +37,11 @@ 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 + for _ in $(seq 1 30); do + 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 && break + sleep 1 + done 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 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----- From 4586aa042d1f13731b3d7157e5c9e209839e0dc3 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Mon, 20 Jul 2026 11:41:25 +0800 Subject: [PATCH 15/27] chore: clean up comments and test numbering - drop internal-doc and old-implementation references from comments (plan section citation, hardcoded-+2 note, Rust decoder narration); reword the strlen regression-test notes to present tense - clarify the put_object header-buffer size comment - renumber the new filter.t/client.t tests to follow the existing sequence (100-102 -> 5-7, 100 -> 7) --- lib/resty/ldap/asn1.lua | 5 +++-- lib/resty/ldap/client.lua | 2 +- lib/resty/ldap/protocol.lua | 3 +-- t/asn1.t | 4 ++-- t/client.t | 2 +- t/filter.t | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/resty/ldap/asn1.lua b/lib/resty/ldap/asn1.lua index 06a458a..9341042 100644 --- a/lib/resty/ldap/asn1.lua +++ b/lib/resty/ldap/asn1.lua @@ -81,7 +81,8 @@ local TAG = { _M.TAG = TAG --- The TLV header is at most tag(1) + long-form-length(1 + up to 4) = 6 bytes. +-- ASN1_put_object takes an int length, so it emits at most +-- tag(1) + long-form-length(1 + 4) = 6 header bytes. local hdrbuf = ffi_new("unsigned char[16]") local function asn1_put_object(tag, class, constructed, data, len) @@ -203,7 +204,7 @@ do class = classp[0], len = tonumber(lenp[0]), offset = content_off, -- 0-indexed content start - hl = content_off - start, -- header length (fixes the hardcoded +2) + hl = content_off - start, -- header length (tag + length octets) cons = band(ret, 0x20) == 0x20, } end diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 676042a..6a76e38 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -11,7 +11,7 @@ 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 (reference §1.2 DoS bound). 16 MiB comfortably exceeds any real entry. +-- worker. 16 MiB comfortably exceeds any real entry. local MAX_LDAP_MESSAGE_SIZE = 16 * 1024 * 1024 diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index adb51d6..71a6a49 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -186,8 +186,7 @@ function _M.search_request(base_obj, scope, deref_aliases, size_limit, time_limi end --- Response decoding. Replaces the Rust `rasn.decode_ldap` with pure Lua while --- reproducing its exact output shape so client.lua and the tests are unchanged. +-- Response decoding. local function parse_ldap_result(packet, op, res) local pos, code, matched_dn, diag, err diff --git a/t/asn1.t b/t/asn1.t index 8c34433..e9eec6a 100644 --- a/t/asn1.t +++ b/t/asn1.t @@ -63,7 +63,7 @@ ok local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end -- 04 05 41 42 00 43 44 => "AB\0CD" (5 bytes, embedded NUL) local _, v = asn1.decode(h("04 05 41 42 00 43 44")) - assert(#v == 5, "length " .. #v) -- would be 2 under the old strlen bug + assert(#v == 5, "length " .. #v) -- a strlen-based read would stop at the NUL assert(v == "AB\0CD", "value") ngx.say("ok") } @@ -199,7 +199,7 @@ ok local asn1 = require("resty.ldap.asn1") -- content lengths whose long-form length octets contain 0x00 -- (256 -> 82 01 00, 512 -> 82 02 00) must round-trip through - -- encode -> decode; a strlen-based header read truncated these. + -- encode -> decode; a strlen-based header read would truncate these. for _, n in ipairs({0, 1, 255, 256, 512, 768}) do local data = string.rep("A", n) local enc = asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, data) diff --git a/t/client.t b/t/client.t index 68f70d0..60a1815 100644 --- a/t/client.t +++ b/t/client.t @@ -154,7 +154,7 @@ GET /t --- error_code: 200 -=== TEST 100: resty.ldap.ldap_authenticate binds a valid user +=== TEST 7: resty.ldap.ldap_authenticate binds a valid user --- http_config eval: $::HttpConfig --- config location /t { diff --git a/t/filter.t b/t/filter.t index 208bf1e..5626762 100644 --- a/t/filter.t +++ b/t/filter.t @@ -450,7 +450,7 @@ GET /t [error] --- error_code: 200 -=== TEST 100: escape escapes the five special octets +=== TEST 5: escape escapes the five special octets --- http_config eval: $::HttpConfig --- config location /t { @@ -472,7 +472,7 @@ ok --- no_error_log [error] -=== TEST 101: escaped user input cannot widen the filter (injection) +=== TEST 6: escaped user input cannot widen the filter (injection) --- http_config eval: $::HttpConfig --- config location /t { @@ -495,7 +495,7 @@ ok --- no_error_log [error] -=== TEST 102: escape covers < and > which the grammar rejects raw +=== TEST 7: escape covers < and > which the grammar rejects raw --- http_config eval: $::HttpConfig --- config location /t { From 08553fa8f92f2c26707782c7d323c10e49f695da Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Tue, 21 Jul 2026 10:27:19 +0800 Subject: [PATCH 16/27] feat: enhance LDAP client with error handling and connection management improvements --- Makefile | 3 +- README.md | 4 ++ lib/resty/ldap.lua | 24 +++++++++++- lib/resty/ldap/asn1.lua | 11 ++++-- lib/resty/ldap/client.lua | 65 ++++++++++++++++++++++++-------- lib/resty/ldap/ldap.lua | 41 ++++++++++++++++---- lib/resty/ldap/protocol.lua | 35 ++++++++++------- t/asn1.t | 28 ++++++++++++++ t/decode.t | 28 ++++++++++++++ t/search.t | 7 ++-- t/session.t | 75 +++++++++++++++++++++++++++++++++++++ 11 files changed, 276 insertions(+), 45 deletions(-) diff --git a/Makefile b/Makefile index 9292d1f..2711e95 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,7 @@ dev: .PHONY: test test: git apply t/patch/unknown_op.patch - prove -r t/ - git apply t/patch/unknown_op.patch -R + prove -r t/; ret=$$?; git apply t/patch/unknown_op.patch -R; exit $$ret ### help: Show Makefile rules .PHONY: help diff --git a/README.md b/README.md index 6c0e288..44b8d9d 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ To load this module: 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()* @@ -117,6 +119,8 @@ Compatibility entrypoint kept for APISIX's `ldap-auth` plugin (restored from v0. Binds against the directory as `=,` with the given password. `ok` is `true` when authentication succeeds; otherwise `ok` is `false` or `nil` and `err` carries the error. +`username` is RFC 4514-escaped before being placed in the DN, so DN metacharacters bind as literal characters instead of injecting extra RDN components. + `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 | diff --git a/lib/resty/ldap.lua b/lib/resty/ldap.lua index 8c53b66..667eaf6 100644 --- a/lib/resty/ldap.lua +++ b/lib/resty/ldap.lua @@ -28,6 +28,27 @@ local function set_conf_default_values(conf) 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 = {} @@ -82,7 +103,8 @@ function _M.ldap_authenticate(given_username, given_password, conf) end end - local who = conf.attribute .. "=" .. given_username .. "," .. conf.base_dn + local who = conf.attribute .. "=" .. escape_dn_value(given_username) .. + "," .. conf.base_dn is_authenticated, err = ldap.bind_request(sock, who, given_password) ok, suppressed_err = sock:setkeepalive(conf.keepalive) diff --git a/lib/resty/ldap/asn1.lua b/lib/resty/ldap/asn1.lua index 9341042..25e9dbd 100644 --- a/lib/resty/ldap/asn1.lua +++ b/lib/resty/ldap/asn1.lua @@ -257,7 +257,9 @@ do local values = {} while pos < stop do local value, err - pos, value, err = decode(der, pos, depth + 1) + -- bound to the parent's content, not the buffer: an overrunning + -- child would otherwise absorb a sibling's bytes without an error + pos, value, err = decode(der, pos, stop, depth + 1) if err then return nil, err end @@ -269,14 +271,15 @@ do decoder[TAG.SEQUENCE] = decode_children decoder[TAG.SET] = decode_children - -- offset is 0-indexed. Returns next_offset, value, err. - function decode(der, offset, depth) + -- 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) + local obj, err = asn1_get_object(der, offset, stop) if not obj then return nil, nil, err end diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 6a76e38..a057951 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -1,6 +1,5 @@ local bunpack = require("lua_pack").unpack local protocol = require("resty.ldap.protocol") -local to_hex = require("resty.string").to_hex local tostring = tostring local fmt = string.format @@ -18,19 +17,31 @@ local MAX_LDAP_MESSAGE_SIZE = 16 * 1024 * 1024 local _M = {} local mt = { __index = _M } +-- Returns the body length and the header bytes, or nil plus an error. Callers +-- feed the length straight to socket:receive(), so it is validated here. local function calculate_payload_length(encStr, pos, socket) local elen pos, elen = bunpack(encStr, "C", pos) + -- 0x80 is the indefinite form and 0xff is reserved; RFC 4511 s5.1 requires + -- the definite form. Neither is a 128-byte short-form length. + 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 +49,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 +71,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 @@ -67,8 +86,9 @@ local function _start_tls(sock) local packet = packet_header .. packet local res, err = decode_ldap(packet) if not res then - return fmt("failed to decode ldap message: %s, message: %s", - err or "unknown", to_hex(packet)) + -- 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 @@ -130,6 +150,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", @@ -140,6 +161,17 @@ local function _init_socket(self) self.socket = sock end +-- Drop the socket after an unrecoverable error. A pinned session must lose its +-- pin too, or later calls keep reaching for the dead socket. +local function _reset_socket(cli) + local sock = cli.socket + cli.socket = nil + cli.pinned = nil + if sock then + sock:close() + end +end + local function _send_recieve(cli, request, multi_resp_hint) -- In a pinned session the socket is already checked out; otherwise check out -- one for this single operation. @@ -155,6 +187,7 @@ 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 @@ -174,16 +207,16 @@ local function _send_recieve(cli, request, multi_resp_hint) local len, err = reader(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) - if packet_len > MAX_LDAP_MESSAGE_SIZE then - socket:close() - return nil, fmt("ldap message too large: %d bytes", packet_len) + 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 @@ -192,15 +225,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() + _reset_socket(cli) return nil, err end local packet = packet_header .. packet local res, err = decode_ldap(packet) if not res then - return nil, fmt("failed to decode ldap message: %s, message: %s", - err or "unknown", to_hex(packet)) + -- 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) diff --git a/lib/resty/ldap/ldap.lua b/lib/resty/ldap/ldap.lua index 0ac9498..f85cd75 100644 --- a/lib/resty/ldap/ldap.lua +++ b/lib/resty/ldap/ldap.lua @@ -32,19 +32,36 @@ local APPNO = { } +-- A legal length such as 84 7f ff ff ff would force a multi-GiB allocation in +-- the worker. 16 MiB comfortably exceeds any real entry. +local MAX_LDAP_MESSAGE_SIZE = 16 * 1024 * 1024 + + +-- Returns the body length and the header bytes, or nil plus an error. Both +-- callers receive() the length while still in cleartext, so validate it here. local function calculate_payload_length(encStr, pos, socket) local elen pos, elen = bunpack(encStr, "C", pos) + -- 0x80 is the indefinite form and 0xff is reserved; RFC 4511 s5.1 requires + -- the definite form. Neither is a 128-byte short-form length. + 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 @@ -52,7 +69,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 @@ -62,7 +83,7 @@ function _M.bind_request(socket, username, password) local ldapMsg = asn1_encode(ldapMessageId) .. asn1_put_object(APPNO.BindRequest, asn1.CLASS.APPLICATION, 1, bindReq) - local packet, packet_len, packet_header, _ + local packet, packet_len, packet_header, lerr packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) @@ -72,7 +93,10 @@ function _M.bind_request(socket, username, password) packet = socket:receive(2) - _, packet_len, packet_header = calculate_payload_length(packet, 2, socket) + packet_len, packet_header, lerr = calculate_payload_length(packet, 2, socket) + if not packet_len then + return false, lerr + end packet = socket:receive(packet_len) @@ -116,7 +140,7 @@ end function _M.start_tls(socket) - local ldapMsg, packet, packet_len, packet_header, _ + local ldapMsg, packet, packet_len, packet_header, lerr local method_name = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, "1.3.6.1.4.1.1466.20037") @@ -129,7 +153,10 @@ function _M.start_tls(socket) socket:send(packet) packet = socket:receive(2) - _, packet_len, packet_header = calculate_payload_length(packet, 2, socket) + packet_len, packet_header, lerr = calculate_payload_length(packet, 2, socket) + if not packet_len then + return false, lerr + end packet = socket:receive(packet_len) diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index 71a6a49..f6e83ed 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -188,41 +188,49 @@ 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. + local function parse_ldap_result(packet, op, res) - local pos, code, matched_dn, diag, err - pos, code, err = asn1_decode(packet, op.offset) -- resultCode ENUMERATED + local stop = op.offset + op.len + local _, pos, code, matched_dn, diag, err + pos, code, err = asn1_decode(packet, op.offset, stop) -- resultCode ENUMERATED if err then return nil, err end res.result_code = code - pos, matched_dn, err = asn1_decode(packet, pos) -- matchedDN OCTET STRING + pos, matched_dn, err = asn1_decode(packet, pos, stop) -- matchedDN OCTET STRING if err then return nil, err end res.matched_dn = matched_dn - _, diag, err = asn1_decode(packet, pos) -- diagnosticMessage OCTET STRING + _, diag, err = asn1_decode(packet, pos, stop) -- diagnosticMessage OCTET STRING if err then return nil, err end res.diagnostic_msg = diag return res end local function parse_search_entry(packet, op, res) + local stop = op.offset + op.len local pos, entry_dn, err - pos, entry_dn, err = asn1_decode(packet, op.offset) -- objectName + pos, entry_dn, err = asn1_decode(packet, op.offset, stop) -- objectName if err then return nil, err end res.entry_dn = entry_dn - local attrs, aerr = asn1_get_object(packet, pos) -- PartialAttributeList (SEQUENCE OF) + -- PartialAttributeList (SEQUENCE OF) + local attrs, aerr = asn1_get_object(packet, pos, stop) if not attrs then return nil, aerr end local attributes = {} local apos = attrs.offset local astop = attrs.offset + attrs.len while apos < astop do - local pa, perr = asn1_get_object(packet, apos) -- PartialAttribute SEQUENCE + -- PartialAttribute SEQUENCE + local pa, perr = asn1_get_object(packet, apos, astop) if not pa then return nil, perr end - local vpos, atype, terr = asn1_decode(packet, pa.offset) -- type + local pastop = pa.offset + pa.len + local vpos, atype, terr = asn1_decode(packet, pa.offset, pastop) -- type if terr then return nil, terr end - local _, vals, verr = asn1_decode(packet, vpos) -- vals SET OF -> array + local _, vals, verr = asn1_decode(packet, vpos, pastop) -- vals SET OF -> array if verr then return nil, verr end attributes[atype] = vals or {} -- ALWAYS an array (empty for typesOnly) - apos = pa.offset + pa.len + apos = pastop end res.attributes = attributes return res @@ -235,7 +243,7 @@ local function parse_search_reference(packet, op, res) local stop = op.offset + op.len while pos < stop do local uri, err - pos, uri, err = asn1_decode(packet, pos) + pos, uri, err = asn1_decode(packet, pos, stop) if err then return nil, err end table_insert(uris, uri) end @@ -250,10 +258,11 @@ function _M.decode_message(packet) return nil, "invalid LDAPMessage envelope" end - local pos, message_id, merr = asn1_decode(packet, env.offset) -- messageID + local envstop = env.offset + env.len + local pos, message_id, merr = asn1_decode(packet, env.offset, envstop) -- messageID if merr then return nil, merr end - local op, oerr = asn1_get_object(packet, pos) -- protocolOp + 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" diff --git a/t/asn1.t b/t/asn1.t index e9eec6a..74773e3 100644 --- a/t/asn1.t +++ b/t/asn1.t @@ -216,3 +216,31 @@ GET /t ok --- no_error_log [error] + +=== TEST 8: a child TLV may not overrun its parent's declared length +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + + -- outer SEQUENCE declares 3 content bytes, inner OCTET STRING claims + -- 5, and the buffer is long enough to satisfy it + local _, v, err = asn1.decode(h("30 03 04 05 41 42 43 44 45")) + assert(v == nil, "no value when a child overruns its parent") + assert(err ~= nil, "parent overrun reports an error") + + -- same bytes, correct outer length + local _, ok_v = asn1.decode(h("30 07 04 05 41 42 43 44 45")) + assert(type(ok_v) == "table" and ok_v[1] == "ABCDE", "well-formed still decodes") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode.t b/t/decode.t index 4ced918..26c1cc4 100644 --- a/t/decode.t +++ b/t/decode.t @@ -167,3 +167,31 @@ GET /t 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 function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + + -- BindResponse declares 2 content bytes; its resultCode TLV is 4 and + -- runs into matchedDN, decoding as a "successful" result_code 256 + local bad, err = protocol.decode_message(h("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(h("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/search.t b/t/search.t index bf97f6e..54b5050 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") } } @@ -532,8 +533,8 @@ GET /t ngx.exit(401) end - -- 6 entries: the 5 bootstrap entries plus cn=Jane Doe from t/fixtures/ad.ldif - assert(#res == 6, "result length is not equal to 6") + -- 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 diff --git a/t/session.t b/t/session.t index c4e1e05..65ee1ea 100644 --- a/t/session.t +++ b/t/session.t @@ -78,3 +78,78 @@ GET /t 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 From abc9191d2eddc79b8d4c699694b5379797116b41 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 22 Jul 2026 10:54:04 +0800 Subject: [PATCH 17/27] feat(asn1,protocol): strict BER/LDAP decoding and hardened encoding Harden the ASN.1 and LDAP message layers against malformed and hostile input, and add a pure-Lua test suite covering the new behaviour. asn1.lua: - reject non-canonical tag forms, enforce primitive/constructed form per universal type, and validate decode offsets before pointer arithmetic - refuse integers outside the exactly-representable 2^53 range on both encode and decode - reject non-string/non-boolean payloads instead of emitting a header that disagrees with the bytes; return errors rather than bare nils - free the buffer i2d_* allocates (fixes a per-encode leak in the worker) protocol.lua: - pin the RFC 4511 ASN.1 type of every decoded field - enforce a single protocolOp per LDAPMessage (plus optional controls) and an explicit response-op allowlist - type-guard the bind and search request builders client.lua / ldap.lua: - validate the BER length header, drop and unpin the socket on error, and guard bind argument types Also gitignore the local deps/ luarocks build tree. --- .gitignore | 3 + lib/resty/ldap/asn1.lua | 216 +++++++++++++---- lib/resty/ldap/client.lua | 22 +- lib/resty/ldap/ldap.lua | 11 + lib/resty/ldap/protocol.lua | 173 ++++++++++--- t/asn1.t | 68 +++--- t/asn1_bounds.t | 405 +++++++++++++++++++++++++++++++ t/asn1_class.t | 370 ++++++++++++++++++++++++++++ t/asn1_constructed.t | 133 ++++++++++ t/asn1_depth.t | 367 ++++++++++++++++++++++++++++ t/asn1_encode.t | 358 +++++++++++++++++++++++++++ t/asn1_encode_leak.t | 157 ++++++++++++ t/asn1_ffi.t | 322 +++++++++++++++++++++++++ t/asn1_int_precision.t | 160 ++++++++++++ t/asn1_integer.t | 312 ++++++++++++++++++++++++ t/asn1_nil_member.t | 178 ++++++++++++++ t/asn1_oracle.t | 304 +++++++++++++++++++++++ t/asn1_vectors.t | 409 +++++++++++++++++++++++++++++++ t/decode.t | 46 ++-- t/decode_hostile.t | 468 ++++++++++++++++++++++++++++++++++++ t/decode_member_count.t | 135 +++++++++++ t/decode_op_dispatch.t | 106 ++++++++ t/decode_protocol.t | 313 ++++++++++++++++++++++++ t/decode_rfc4511.t | 371 ++++++++++++++++++++++++++++ t/lib/ldap_hex.lua | 7 + 25 files changed, 5269 insertions(+), 145 deletions(-) create mode 100644 t/asn1_bounds.t create mode 100644 t/asn1_class.t create mode 100644 t/asn1_constructed.t create mode 100644 t/asn1_depth.t create mode 100644 t/asn1_encode.t create mode 100644 t/asn1_encode_leak.t create mode 100644 t/asn1_ffi.t create mode 100644 t/asn1_int_precision.t create mode 100644 t/asn1_integer.t create mode 100644 t/asn1_nil_member.t create mode 100644 t/asn1_oracle.t create mode 100644 t/asn1_vectors.t create mode 100644 t/decode_hostile.t create mode 100644 t/decode_member_count.t create mode 100644 t/decode_op_dispatch.t create mode 100644 t/decode_protocol.t create mode 100644 t/decode_rfc4511.t create mode 100644 t/lib/ldap_hex.lua diff --git a/.gitignore b/.gitignore index a42679b..18483c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ # dev t/servroot + +# luarocks --tree ./deps build output (see .github/workflows/ci.yml) +deps/ diff --git a/lib/resty/ldap/asn1.lua b/lib/resty/ldap/asn1.lua index 25e9dbd..190a35e 100644 --- a/lib/resty/ldap/asn1.lua +++ b/lib/resty/ldap/asn1.lua @@ -1,13 +1,14 @@ -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_char = string.char -local string_sub = string.sub -local table_insert = table.insert -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]") @@ -27,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); @@ -81,19 +85,29 @@ local TAG = { _M.TAG = TAG --- ASN1_put_object takes an int length, so it emits at most --- tag(1) + long-form-length(1 + 4) = 6 header bytes. +-- 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 - -- ASN1_put_object writes only the header and advances the pointer past it. - -- Read back exactly that many bytes: a strlen-based ffi_string(hdrbuf) would - -- truncate any length octet containing 0x00 (e.g. content length 256 -> 82 01 00). + -- 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) @@ -106,37 +120,69 @@ 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) @@ -148,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) @@ -164,9 +215,13 @@ 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 @@ -184,6 +239,11 @@ do 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 @@ -192,12 +252,24 @@ do strpp[0] = s_der + start local ret = C.ASN1_get_object(strpp, lenp, tagp, classp, stop - start) - -- The 0x80 bit signals an encoding error (also set for the illegal - -- indefinite/too-long forms). Reject rather than trust the result. + -- 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], @@ -216,20 +288,47 @@ local decode do local decoder = new_tab(0, 5) - -- OCTET STRING: slice raw content bytes directly. Binary/NUL-safe by - -- construction (no strlen-based ffi_string, no d2i). - decoder[TAG.OCTET_STRING] = function(der, _, obj) - -- RFC 4511 s5.1: OCTET STRING values are primitive-only. A constructed - -- OCTET STRING (0x24) is a classic BER-smuggling vector against auth - -- parsers; reject it rather than return its inner TLV bytes as a value. - if obj.cons then - return nil, "constructed OCTET STRING not allowed" + -- 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 - -- INTEGER / ENUMERATED: d2i parses a full TLV, so `offset` is the TLV start. + -- 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 @@ -237,33 +336,39 @@ do end local v = C.ASN1_INTEGER_get(typ) C.ASN1_INTEGER_free(typ) - return tonumber(v) + 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; INTEGER_free is correct - return tonumber(v) + 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, not the buffer: an overrunning - -- child would otherwise absorb a sibling's bytes without an error + -- 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 - table_insert(values, value) + -- decode() never returns a nil value with a nil err, so no holes + n = n + 1 + values[n] = value end return values end @@ -283,13 +388,30 @@ do 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] - local value, derr - if d then - value, derr = d(der, offset, obj, depth) - if derr then - return nil, nil, derr - end + 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 diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index a057951..4ffc91b 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -17,15 +17,13 @@ local MAX_LDAP_MESSAGE_SIZE = 16 * 1024 * 1024 local _M = {} local mt = { __index = _M } --- Returns the body length and the header bytes, or nil plus an error. Callers --- feed the length straight to socket:receive(), so it is validated here. +-- 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 is the indefinite form and 0xff is reserved; RFC 4511 s5.1 requires - -- the definite form. Neither is a 128-byte short-form length. + -- 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 @@ -161,8 +159,7 @@ local function _init_socket(self) self.socket = sock end --- Drop the socket after an unrecoverable error. A pinned session must lose its --- pin too, or later calls keep reaching for the dead socket. +-- drop the socket (and its pin) after an unrecoverable error local function _reset_socket(cli) local sock = cli.socket cli.socket = nil @@ -256,8 +253,8 @@ local function _send_recieve(cli, request, multi_resp_hint) end end - -- Only return the socket to the pool for single-shot ops; a pinned session - -- is released explicitly by the caller via set_keepalive()/close(). + -- single-shot ops return the socket to the pool; a pinned session is + -- released explicitly by the caller if not cli.pinned then socket:setkeepalive(cli.socket_config.keepalive_timeout) end @@ -335,7 +332,14 @@ 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_recieve's multi_resp_hint and send a nil request + local req, berr = protocol.simple_bind_request(dn, password) + if not req then + return false, berr + end + + local res, err = _send_recieve(self, req) if not res then return false, err end diff --git a/lib/resty/ldap/ldap.lua b/lib/resty/ldap/ldap.lua index f85cd75..604ef75 100644 --- a/lib/resty/ldap/ldap.lua +++ b/lib/resty/ldap/ldap.lua @@ -78,6 +78,17 @@ end function _M.bind_request(socket, username, password) + -- pin the LDAPString type: put_object rejects a non-string payload, so + -- catch it here rather than as a concat error on the next line + if type(username) == "number" then username = tostring(username) end + if type(password) == "number" then password = tostring(password) end + if type(username) ~= "string" then + return false, "bind username must be a string" + end + if type(password) ~= "string" then + return false, "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(username) .. ldapAuth local ldapMsg = asn1_encode(ldapMessageId) .. diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index f6e83ed..b138c57 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -4,7 +4,7 @@ local asn1_put_object = asn1.put_object local asn1_encode = asn1.encode local asn1_get_object = asn1.get_object local asn1_decode = asn1.decode -local table_insert = table.insert +local string_format = string.format local _M = {} @@ -52,11 +52,23 @@ end function _M.simple_bind_request(dn, password) - -- simple [0] OCTET STRING; a zero-length password (anonymous/unauthenticated - -- bind) now encodes correctly as `80 00` since asn1.put_object no longer - -- truncates the length octet. - local ldapAuth = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, password or "") - 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 @@ -158,12 +170,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) @@ -191,61 +218,120 @@ end -- 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 = asn1_decode(packet, op.offset, stop) -- resultCode ENUMERATED + pos, code, err = decode_typed(packet, op.offset, stop, + asn1.TAG.ENUMERATED, "resultCode") if err then return nil, err end - res.result_code = code - pos, matched_dn, err = asn1_decode(packet, pos, stop) -- matchedDN OCTET STRING + pos, matched_dn, err = decode_typed(packet, pos, stop, + asn1.TAG.OCTET_STRING, "matchedDN") if err then return nil, err end - res.matched_dn = matched_dn - _, diag, err = asn1_decode(packet, pos, stop) -- diagnosticMessage OCTET STRING + _, 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 = asn1_decode(packet, op.offset, stop) -- objectName + 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) + -- 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 + -- 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 - local vpos, atype, terr = asn1_decode(packet, pa.offset, pastop) -- type + -- 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 - local _, vals, verr = asn1_decode(packet, vpos, pastop) -- vals SET OF -> array + -- 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 or {} -- ALWAYS an array (empty for typesOnly) + 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. + -- [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 = asn1_decode(packet, pos, stop) + pos, uri, err = decode_typed(packet, pos, stop, asn1.TAG.OCTET_STRING, + "SearchResultReference URI") if err then return nil, err end - table_insert(uris, uri) + n = n + 1 + uris[n] = uri end res.uris = uris return res @@ -254,12 +340,21 @@ end function _M.decode_message(packet) local env, err = asn1_get_object(packet, 0) if not env then return nil, err end - if env.tag ~= asn1.TAG.SEQUENCE or env.class ~= asn1.CLASS.UNIVERSAL then + -- 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 - local pos, message_id, merr = asn1_decode(packet, env.offset, envstop) -- messageID + -- 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 @@ -267,6 +362,24 @@ function _M.decode_message(packet) 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 } @@ -274,10 +387,10 @@ function _M.decode_message(packet) return parse_search_entry(packet, op, res) elseif op.tag == _M.APP_NO.SearchResultReference then return parse_search_reference(packet, op, res) - else - -- LDAPResult-shaped ops: BindResponse, SearchResultDone, ModifyResponse, ExtendedResponse + elseif LDAP_RESULT_OPS[op.tag] then return parse_ldap_result(packet, op, res) end + return nil, "unsupported protocolOp " .. op.tag end diff --git a/t/asn1.t b/t/asn1.t index 74773e3..8ff4fea 100644 --- a/t/asn1.t +++ b/t/asn1.t @@ -1,3 +1,5 @@ +# Test vectors: rasn v0.6.1 (octet_string); Go go1.26.3 encoding/asn1 (int64TestData). + use Test::Nginx::Socket::Lua; log_level('info'); @@ -7,7 +9,7 @@ repeat_each(1); plan 'no_plan'; our $HttpConfig = <<'_EOC_'; - lua_package_path 'lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; resolver 127.0.0.53; _EOC_ @@ -22,10 +24,10 @@ __DATA__ location /t { content_by_lua_block { local asn1 = require("resty.ldap.asn1") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + local ldap_hex = require("ldap_hex") -- short form: 30 03 02 01 05 (SEQUENCE len 3) - local o = assert(asn1.get_object(h("30 03 02 01 05"))) + 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") @@ -35,12 +37,12 @@ __DATA__ -- long form: 30 81 82 <130 bytes> (2-extra-byte header) local body = string.rep("\0", 130) - local o2 = assert(asn1.get_object(h("30 81 82") .. body)) + 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(h("64 00"))) + 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") @@ -60,9 +62,10 @@ ok location /t { content_by_lua_block { local asn1 = require("resty.ldap.asn1") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + local ldap_hex = require("ldap_hex") + -- 04 05 41 42 00 43 44 => "AB\0CD" (5 bytes, embedded NUL) - local _, v = asn1.decode(h("04 05 41 42 00 43 44")) + 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") @@ -81,21 +84,21 @@ ok location /t { content_by_lua_block { local asn1 = require("resty.ldap.asn1") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + local ldap_hex = require("ldap_hex") - local _, id = asn1.decode(h("02 01 03")) + local _, id = asn1.decode(ldap_hex("02 01 03")) assert(id == 3, "int " .. tostring(id)) - local _, code = asn1.decode(h("0a 01 00")) + 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(h("31 0d 04 03 74 6f 70 04 06 64 6f 6d 61 69 6e")) + 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 = h("31 81 b4") .. big -- 0xb4 = 180 + 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") @@ -114,9 +117,10 @@ ok location /t { content_by_lua_block { local asn1 = require("resty.ldap.asn1") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + local ldap_hex = require("ldap_hex") + -- claims 5 content bytes, only 2 present - local off, v, err = asn1.decode(h("30 05 02 01")) + 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") @@ -135,21 +139,22 @@ ok location /t { content_by_lua_block { local asn1 = require("resty.ldap.asn1") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + local ldap_hex = require("ldap_hex") - -- truncated child inside a SEQUENCE: outer len 3, inner OCTET - -- STRING claims 5 content bytes but only 1 present - local _, v, err = asn1.decode(h("30 03 04 05 41")) + -- 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(h("02 00")) + 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(h("0a 00")) + 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") @@ -169,16 +174,15 @@ ok location /t { content_by_lua_block { local asn1 = require("resty.ldap.asn1") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + local ldap_hex = require("ldap_hex") - -- 24 07 04 02 41 42 04 01 43 : constructed OCTET STRING whose inner - -- TLVs must NOT be handed back as the value (RFC 4511 s5.1) - local _, v, err = asn1.decode(h("24 07 04 02 41 42 04 01 43")) + -- 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(h("04 02 41 42")) + local _, v2 = asn1.decode(ldap_hex("04 02 41 42")) assert(v2 == "AB", "primitive OCTET STRING still works") ngx.say("ok") @@ -197,9 +201,8 @@ ok location /t { content_by_lua_block { local asn1 = require("resty.ldap.asn1") - -- content lengths whose long-form length octets contain 0x00 - -- (256 -> 82 01 00, 512 -> 82 02 00) must round-trip through - -- encode -> decode; a strlen-based header read would truncate these. + + -- long-form length octets containing 0x00 (256, 512) must round-trip for _, n in ipairs({0, 1, 255, 256, 512, 768}) do local data = string.rep("A", n) local enc = asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, data) @@ -223,16 +226,15 @@ ok location /t { content_by_lua_block { local asn1 = require("resty.ldap.asn1") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + local ldap_hex = require("ldap_hex") - -- outer SEQUENCE declares 3 content bytes, inner OCTET STRING claims - -- 5, and the buffer is long enough to satisfy it - local _, v, err = asn1.decode(h("30 03 04 05 41 42 43 44 45")) + -- outer SEQUENCE says 3 bytes, inner OCTET STRING claims 5, buffer long enough + local _, v, err = asn1.decode(ldap_hex("30 03 04 05 41 42 43 44 45")) assert(v == nil, "no value when a child overruns its parent") assert(err ~= nil, "parent overrun reports an error") -- same bytes, correct outer length - local _, ok_v = asn1.decode(h("30 07 04 05 41 42 43 44 45")) + local _, ok_v = asn1.decode(ldap_hex("30 07 04 05 41 42 43 44 45")) assert(type(ok_v) == "table" and ok_v[1] == "ABCDE", "well-formed still decodes") ngx.say("ok") diff --git a/t/asn1_bounds.t b/t/asn1_bounds.t new file mode 100644 index 0000000..718e98d --- /dev/null +++ b/t/asn1_bounds.t @@ -0,0 +1,405 @@ +# 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") + + -- 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: decode_message returns an error, never raises, on a nil attribute type +--- 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:232 keys on atype; a nil atype raises "table index is nil" + local cases = { + -- SearchResultEntry, attr type = NULL (05 00), vals = empty SET + { "NULL type", "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 05 00 31 00" }, + -- SearchResultEntry, attr type = BOOLEAN (01 01 01), vals = empty SET + { "BOOLEAN type", "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 01 31 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 must not raise, got: " .. tostring(res)) + assert(res == nil, c[1] .. ": must not return a result") + assert(err ~= nil, c[1] .. ": must report an error") + end + + -- a well-formed entry with the same 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", "dn") + assert(type(good.attributes.s) == "table" and #good.attributes.s == 0, "empty vals") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 8: 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 9: 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..ed88dce --- /dev/null +++ b/t/asn1_class.t @@ -0,0 +1,370 @@ +# 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 got_ok = (err == nil and v ~= nil) + if got_ok ~= want_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 + + 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 + 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") + + 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" }, + { "resultCode as BOOLEAN", "30 0c 02 01 01 61 07 01 01 ff 04 00 04 00" }, + { "resultCode as NULL", "30 0b 02 01 01 61 06 05 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" }, + -- 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") + + 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..83a30f1 --- /dev/null +++ b/t/asn1_constructed.t @@ -0,0 +1,133 @@ +# 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 + + 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..6e9ce39 --- /dev/null +++ b/t/asn1_depth.t @@ -0,0 +1,367 @@ +# 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: an undecodable member must never silently shrink a SET/SEQUENCE +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, ctl, cerr = asn1.decode(ldap_hex("31 06 04 01 41 04 01 42")) + assert(cerr == nil, "control errored: " .. tostring(cerr)) + assert(#ctl == 2 and ctl[1] == "A" and ctl[2] == "B", "control array") + + -- undecodable members (e.g. BOOLEAN) must be reported, never silently dropped + local cases = { + { hex = "31 06 04 01 41 01 01 ff", why = "undecodable member last" }, + { hex = "31 06 01 01 ff 04 01 41", why = "undecodable member first" }, + { hex = "30 05 04 01 41 05 00", why = "NULL member in a SEQUENCE" }, + { hex = "30 02 00 00", why = "EOC octets as a member" }, + } + for _, c in ipairs(cases) do + local off, v, err = asn1.decode(ldap_hex(c.hex)) + assert(err ~= nil, c.why .. ": expected an error, got none") + assert(v == nil, c.why .. ": expected no value") + assert(off == nil, c.why .. ": expected no offset") + end + + -- sharpest form: a SET with one undecodable member must not look empty + local _, only, oerr = asn1.decode(ldap_hex("31 03 01 01 ff")) + assert(oerr ~= nil, "SET with one undecodable member must error") + assert(only == nil, "SET with one undecodable member must not look empty") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: SEQUENCE and SET must be constructed to be walked as containers +--- 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, err = asn1.decode(ldap_hex("10 03 04 01 41")) + assert(v == nil, "primitive SEQUENCE must not be walked as a container") + assert(err ~= nil, "primitive SEQUENCE must report an error") + + local _, v2, err2 = asn1.decode(ldap_hex("11 03 04 01 41")) + assert(v2 == nil, "primitive SET must not be walked as a container") + assert(err2 ~= nil, "primitive SET must report an error") + + local _, v3, err3 = asn1.decode(ldap_hex("10 00")) + assert(v3 == nil and err3 ~= nil, "empty primitive SEQUENCE must be rejected") + + -- constructed forms are of course still fine + local _, ok_v, ok_err = asn1.decode(ldap_hex("30 03 04 01 41")) + assert(ok_err == nil and #ok_v == 1 and ok_v[1] == "A", "constructed SEQUENCE still decodes") + + -- reachable end to end: a primitive-encoded LDAPMessage envelope + 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") + + -- ... and so is a primitive-encoded vals SET inside a SearchResultEntry + local bad2, berr2 = protocol.decode_message(ldap_hex( + "30 16 02 01 03 64 11 04 01 78 30 0c 30 0a 04 01 73 11 05 04 03 01 00 02")) + assert(bad2 == nil, "primitive vals SET must be rejected") + assert(berr2 ~= nil, "primitive vals SET must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: 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..98018d4 --- /dev/null +++ b/t/asn1_encode.t @@ -0,0 +1,358 @@ +# 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;/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 + + local function must_fail(what, val) + local ok, v, err = pcall(asn1.encode, val, asn1.TAG.INTEGER) + if not ok then return end + -- 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("1e100 (clamped to LONG_MAX)", 1e100) + must_fail("math.huge (clamped to LONG_MAX)", math.huge) + must_fail("NaN (becomes 0)", 0/0) + + -- values that genuinely fit a long must keep working + for _, n in ipairs({0, 1, -1, 127, -128, 128, 65536, 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 i2d-based encoders must not leak the buffer OpenSSL allocates +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + local function rss_kib() + local f = assert(io.open("/proc/self/statm")) + local line = f:read("*l") + f:close() + return tonumber(line:match("^%d+%s+(%d+)")) * 4 + end + + local blob = string.rep("Z", 4096) + local iters = 20000 + + collectgarbage("collect") + local before = rss_kib() + for _ = 1, iters do + local _ = asn1.encode(blob, asn1.TAG.OCTET_STRING) + end + collectgarbage("collect") + local grew = rss_kib() - before + + -- 80 MiB encoded and discarded; a non-leaking encoder stays flat + assert(grew < 24000, + "encoding " .. iters .. " x 4 KiB octet strings grew RSS by " .. + grew .. " KiB (~" .. math.floor(grew / (iters * 4) * 100) .. + "% of the bytes encoded) -- the i2d output buffer is never freed") + + -- control: the put_object-based container encoder allocates nothing + collectgarbage("collect") + local b2 = rss_kib() + for _ = 1, iters do + local _ = asn1.encode(blob, asn1.TAG.SEQUENCE) + end + collectgarbage("collect") + assert(rss_kib() - b2 < 24000, "put_object path leaked, unexpectedly") + + 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..416361e --- /dev/null +++ b/t/asn1_encode_leak.t @@ -0,0 +1,157 @@ +# 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 +--- 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 +--- 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..cbad63d --- /dev/null +++ b/t/asn1_ffi.t @@ -0,0 +1,322 @@ +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: i2d_* output buffers must be freed, not leaked once per encode +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + -- i2d with *pp==NULL makes OpenSSL malloc a buffer encode leaks; GC can't reclaim it + local function rss() + local f = assert(io.open("/proc/self/statm")) + local s = f:read("*l") + f:close() + return tonumber(s:match("^%d+%s+(%d+)")) * 4096 + end + + local N, SZ = 20000, 4000 + local payload = string.rep("A", SZ) + + -- Control: the SEQUENCE encoder (asn1_put_object) allocates nothing + collectgarbage("collect") + local b1 = rss() + for _ = 1, N do + local s = asn1.encode(payload, asn1.TAG.SEQUENCE) + assert(#s == SZ + 4, "control encoding size") + end + collectgarbage("collect") + local control = rss() - b1 + + -- Subject: the OCTET STRING encoder goes through i2d. + collectgarbage("collect") + local b2 = rss() + for _ = 1, N do + local s = asn1.encode(payload, asn1.TAG.OCTET_STRING) + assert(#s == SZ + 4, "subject encoding size") + end + collectgarbage("collect") + local subject = rss() - b2 + + local excess = subject - control + -- correct frees each i2d buffer, so both paths cost the same; leaking = ~80MB excess + assert(excess < 8 * 1024 * 1024, + string.format("i2d output buffers leaked: %.1f MB excess over the " + .. "put_object control across %d encodes (control %.1f MB, " + .. "subject %.1f MB)", + excess / 1048576, N, control / 1048576, subject / 1048576)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: start offset must be validated as a non-negative integer +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- get_object never checks start >= 0, so a negative start reads out of bounds + local der = ldap_hex("04 03 41 42 43") + + for _, start in ipairs({-1, -2, -4, -16}) do + local o, err = asn1.get_object(der, start, #der) + assert(o == nil, "get_object must reject negative start " .. start + .. " (returned a fabricated object instead of reading out of bounds)") + assert(err ~= nil, "negative start " .. start .. " must report an error") + + local off, v, derr = asn1.decode(der, start) + assert(off == nil and derr ~= nil, + "decode must reject negative offset " .. start) + end + + -- a negative start can yield a negative obj.offset, slicing from the buffer tail + local o2 = asn1.get_object(der, -4) + assert(o2 == nil, "negative start must never produce an object with a negative offset") + + -- fractional offsets make hl fractional, then marshalled into d2i's long length + local der2 = ldap_hex("41 04 03 41 42 43 00 00 00 00") + local o3, err3 = asn1.get_object(der2, 1.9, #der2) + assert(o3 == nil, "get_object must reject a fractional start" + .. (o3 and (" (got hl=" .. tostring(o3.hl) .. ")") or "")) + assert(err3 ~= nil, "fractional start must report an error") + + local _, v3, derr3 = asn1.decode(der2, 1.2) + assert(v3 == nil and derr3 ~= nil, + "decode must reject a fractional offset instead of silently flooring it") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: 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 4: encode(INTEGER) must not silently substitute a different value +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + -- ASN1_INTEGER_set takes a C long; LuaJIT truncates/saturates, emitting a different int + local function roundtrip(v) + local enc = asn1.encode(v, asn1.TAG.INTEGER) + if enc == nil then return nil end + return (select(2, asn1.decode(enc))) + end + + -- Values a C long represents exactly must round-trip. + for _, v in ipairs({0, 1, -1, 127, 128, -128, 2147483647, 2147483648, 2 ^ 53}) do + assert(roundtrip(v) == v, + "exact value " .. string.format("%.0f", v) .. " must round-trip") + end + + -- Non-integers must be refused, never silently truncated. + for _, v in ipairs({3.7, -3.7, 0.5}) do + assert(asn1.encode(v, asn1.TAG.INTEGER) == nil, + "encode(" .. tostring(v) .. ", INTEGER) must be refused, not truncated to " + .. tostring(roundtrip(v))) + end + + -- Values outside the C long range must be refused, never saturated. + for _, v in ipairs({2 ^ 63, 1e300, -1e300}) do + assert(asn1.encode(v, asn1.TAG.INTEGER) == nil, + "encode(" .. tostring(v) .. ", INTEGER) must be refused, not saturated to " + .. tostring(roundtrip(v))) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: 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 6: 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_int_precision.t b/t/asn1_int_precision.t new file mode 100644 index 0000000..7623a42 --- /dev/null +++ b/t/asn1_int_precision.t @@ -0,0 +1,160 @@ +# Go go1.26.3 encoding/asn1 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 above 2^53 must not silently collapse onto its neighbour +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, v, err = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) + assert(v == nil, "2^53+1 must not yield a value, got " .. tostring(v)) + assert(err ~= nil, "2^53+1 must report an error") + + -- Different encodings must never decode to the same Lua value. + local _, a = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) -- 2^53+1 + local _, b = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 00")) -- 2^53 + assert(not (a ~= nil and a == b), + "2^53+1 and 2^53 decoded to the same value " .. tostring(a)) + + -- Negative side is symmetric: -(2^53+1) vs -2^53. + local _, c = asn1.decode(ldap_hex("02 07 df ff ff ff ff ff ff")) -- -(2^53+1) + local _, d = asn1.decode(ldap_hex("02 07 e0 00 00 00 00 00 00")) -- -2^53 + assert(not (c ~= nil and c == d), + "-(2^53+1) and -2^53 decoded to the same value " .. tostring(c)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 2: 64-bit-range INTEGER is rejected rather than returned off-by-one +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, v, err = asn1.decode(ldap_hex("02 08 7f ff ff ff ff ff ff ff")) + assert(v == nil, "maxint64 must not yield a value, got " .. tostring(v)) + assert(err ~= nil, "maxint64 must report an error") + + -- ENUMERATED shares the marshalling (LDAP resultCode); reject identically. + local _, e, eerr = asn1.decode(ldap_hex("0a 07 20 00 00 00 00 00 01")) + assert(e == nil, "oversized ENUMERATED must not yield a value, got " .. tostring(e)) + assert(eerr ~= nil, "oversized ENUMERATED must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 3: exactly-representable integers still decode (fix must not over-reject) +--- 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 05", 5 }, + { "02 01 ff", -1 }, + { "02 04 7f ff ff ff", 2147483647 }, -- max messageID, RFC 4511 + { "02 04 80 00 00 00", -2147483648 }, + { "0a 01 31", 49 }, -- ENUMERATED resultCode + } + 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] .. " got " .. tostring(v) .. " want " .. tostring(c[2])) + end + + -- 2^53 and -2^53 are the boundary and exactly representable; must be accepted. + local _, hi, herr = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 00")) + assert(herr == nil, "2^53 unexpected error: " .. tostring(herr)) + assert(hi == 9007199254740992, "2^53 got " .. tostring(hi)) + + local _, lo, lerr = asn1.decode(ldap_hex("02 07 e0 00 00 00 00 00 00")) + assert(lerr == nil, "-2^53 unexpected error: " .. tostring(lerr)) + assert(lo == -9007199254740992, "-2^53 got " .. tostring(lo)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 4: oversized messageID is rejected by decode_message, not rounded +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local proto = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local big = "30 12 02 07 20 00 00 00 00 00 01 61 07 0a 01 00 04 00 04 00" + local near = "30 12 02 07 20 00 00 00 00 00 00 61 07 0a 01 00 04 00 04 00" + + local res, err = proto.decode_message(ldap_hex(big)) + assert(res == nil, "oversized messageID must not decode, got id " .. + tostring(res and res.message_id)) + assert(err ~= nil, "oversized messageID must report an error") + + -- Two different wire messageIDs must never correlate to the same in-process id. + local r1 = proto.decode_message(ldap_hex(big)) + local r2 = proto.decode_message(ldap_hex(near)) + assert(not (r1 and r2 and r1.message_id == r2.message_id), + "distinct wire messageIDs collided on " .. + tostring(r1 and r1.message_id)) + + 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..c291603 --- /dev/null +++ b/t/asn1_integer.t @@ -0,0 +1,312 @@ +# 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 + -- 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"}, + } + 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)) + 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"}, + } + 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)) + 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"}, + } + 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"}, + {"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 + + -- 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") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: resultCode must be a number, never a table or 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 res, err = protocol.decode_message(ldap_hex("30 0b 02 01 01 61 06 30 00 04 00 04 00")) + if res then + assert(type(res.result_code) == "number", + "resultCode from `30 00` is a " .. type(res.result_code)) + else + assert(err ~= nil, "rejection must carry an error") + end + + -- an OCTET STRING in the resultCode slot is the same class of defect + local res2, err2 = protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 04 01 00 04 00 04 00")) + if res2 then + assert(type(res2.result_code) == "number", + "resultCode from `04 01 00` is a " .. type(res2.result_code)) + else + assert(err2 ~= nil, "rejection must carry an error") + end + + -- proving the consequence: this is what client.lua:103 does with it + local victim = res and res.result_code + if victim ~= nil then + local built = pcall(function() + return "Unknown error occurred (code: " .. victim .. ")" + end) + assert(built, "client error formatter throws on this resultCode") + end + + 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..26da7a2 --- /dev/null +++ b/t/asn1_nil_member.t @@ -0,0 +1,178 @@ +# pyasn1 v0.6.4 Sequence/SetDecoderTestCase DefMode: 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: 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 _, 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") + + 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 _, 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") + + -- SET with only an undecodable member currently decodes to {}, like a genuine empty SET. + local _, v2, err2 = asn1.decode(ldap_hex("31 03 01 01 ff")) + assert(err2 ~= nil, "SET of one undecodable member must error") + assert(v2 == nil, "no value for an all-undecodable SET") + + -- ...while a genuinely empty SET stays a clean empty array. + local _, v3, err3 = asn1.decode(ldap_hex("31 00")) + assert(err3 == nil, "empty SET is still valid: " .. tostring(err3)) + assert(type(v3) == "table" and #v3 == 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 _, 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") + + -- Stray EOC filler in a definite-length container (30 02); tag-0 TLV that vanishes -> {}. + local _, 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") + + 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..f26e5ba --- /dev/null +++ b/t/asn1_oracle.t @@ -0,0 +1,304 @@ +# 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: a malformed attribute type returns an error, never an uncaught 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 cases = { + "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 05 00 31 00", -- type = NULL + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00", -- type = BOOLEAN + } + for _, hex in ipairs(cases) do + local pok, res, err = pcall(protocol.decode_message, ldap_hex(hex)) + assert(pok, "must not raise: " .. hex .. " -> " .. tostring(res)) + assert(res == nil, "must not return a result for " .. hex) + assert(err ~= nil, "must report an error for " .. hex) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: LDAPMessage fields are type-checked, not just structurally decoded +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- objectName is an INTEGER -> entry_dn becomes the number 5 + local b1, e1 = protocol.decode_message( + ldap_hex("30 11 02 01 03 64 0c 02 01 05 30 07 30 05 04 01 73 31 00")) + assert(b1 == nil, "non-string objectName must be rejected, got entry_dn=" .. + tostring(b1 and b1.entry_dn)) + assert(e1 ~= nil, "non-string objectName must report an error") + + -- attribute type is an INTEGER -> attributes[5], a numeric key + local b2, e2 = protocol.decode_message( + ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 30 05 02 01 05 31 00")) + assert(b2 == nil, "non-string attribute type must be rejected") + assert(e2 ~= nil, "non-string attribute type must report an error") + + -- attribute type is a SEQUENCE -> attributes[
], an unlookupable key + local b3, e3 = protocol.decode_message( + ldap_hex("30 10 02 01 03 64 0b 04 01 78 30 06 30 04 30 00 31 00")) + assert(b3 == nil, "table-valued attribute type must be rejected") + assert(e3 ~= nil, "table-valued attribute type must report an error") + + -- messageID is an OCTET STRING -> message_id becomes a string + local b4, e4 = protocol.decode_message( + ldap_hex("30 0c 04 01 01 61 07 0a 01 00 04 00 04 00")) + assert(b4 == nil, "non-integer messageID must be rejected, got " .. + type(b4 and b4.message_id)) + assert(e4 ~= nil, "non-integer messageID must report an error") + + -- a well-formed entry is unaffected + local ok = 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(ok.entry_dn == "x", "well-formed entry_dn") + assert(type(ok.attributes.s) == "table", "well-formed attribute key") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: SearchResultReference URI list never silently shrinks +--- 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 0a 02 01 02 73 05 04 01 78 05 00")) + if bad then + assert(false, "undecodable referral element must not be dropped, #uris=" .. + tostring(#bad.uris)) + end + assert(err ~= nil, "undecodable referral element must report an error") + + -- a genuine two-URI referral is unaffected + local ok = assert(protocol.decode_message( + ldap_hex("30 0e 02 01 02 73 09 04 01 78 04 04 6c 64 61 70"))) + assert(#ok.uris == 2, "two URIs, got " .. #ok.uris) + assert(ok.uris[1] == "x" and ok.uris[2] == "ldap", "URI values") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: 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..95345bd --- /dev/null +++ b/t/asn1_vectors.t @@ -0,0 +1,409 @@ +# 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: rasn BER decoder `sequence()` vector must not silently vanish +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, v, err = asn1.decode(ldap_hex("30 0a 16 05 53 6d 69 74 68 01 01 ff")) + assert(err ~= nil, + "SEQUENCE with undecodable members must report an error, got value=" .. + tostring(v) .. " n=" .. (type(v) == "table" and #v or -1)) + assert(v == nil, "no partial value alongside the error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: pyasn1 Sequence/Set DefMode vectors must not shrink and re-index +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, seq, err = 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(err ~= nil, + "SEQUENCE containing NULL must error rather than silently shrink; got n=" .. + (type(seq) == "table" and #seq or -1)) + assert(seq == nil, "no partial array alongside the error") + + local _, set, err2 = 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(err2 ~= nil, "SET containing NULL must error rather than silently shrink") + assert(set == nil, "no partial array alongside the error") + + local _, mix, err3 = asn1.decode(ldap_hex("30 09 02 01 01 01 01 ff 02 01 02")) + assert(err3 ~= nil, "undecodable middle member must error, not re-index the array") + assert(mix == nil, "no partial array alongside the error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: 9-byte INTEGER overflow must not be reported as -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 _, pos_long, err = asn1.decode(ldap_hex("02 09 00 ff ff ff ff ff ff ff ff")) + assert(err ~= nil, + "INTEGER exceeding the representable range must error; got " .. + tostring(pos_long)) + assert(pos_long ~= -1, "overflow must never be indistinguishable from -1") + + local _, neg_long, err2 = asn1.decode(ldap_hex("02 09 ff 00 00 00 00 00 00 00 01")) + assert(err2 ~= nil, "negative INTEGER overflow must error; got " .. tostring(neg_long)) + assert(neg_long ~= -1, "overflow must never be indistinguishable from -1") + + local _, go_of, err3 = asn1.decode(ldap_hex("02 09 80 00 00 00 00 00 00 00 00")) + assert(err3 ~= nil, "9-byte INTEGER must error; got " .. tostring(go_of)) + + -- The genuine -1 must keep working, so the fix cannot just blanket-ban -1. + local _, minus_one, err4 = asn1.decode(ldap_hex("02 01 ff")) + assert(err4 == nil and minus_one == -1, "a real -1 must still decode as -1") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: 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 5: dispatch must match tag CLASS, not just tag number +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- decode() dispatches on obj.tag alone, so non-universal tags alias universal types. + + local _, ctx4, e1 = asn1.decode(ldap_hex("84 03 61 62 63")) + assert(e1 ~= nil, "context-specific [4] must not decode as OCTET STRING; got " .. + tostring(ctx4)) + assert(ctx4 ~= "abc", "context [4] must not alias universal OCTET STRING") + + local _, priv4, e2 = asn1.decode(ldap_hex("c4 03 61 62 63")) + assert(e2 ~= nil, "private [4] must not decode as OCTET STRING; got " .. tostring(priv4)) + + -- Context [16] / [17] constructed get walked as SEQUENCE / SET. + local _, ctx16, e3 = asn1.decode(ldap_hex("b0 03 02 01 05")) + assert(e3 ~= nil, "context [16] must not be walked as SEQUENCE") + assert(ctx16 == nil, "context [16] must not yield a member array") + local _, ctx17, e4 = asn1.decode(ldap_hex("b1 03 02 01 05")) + assert(e4 ~= nil, "context [17] must not be walked as SET") + assert(ctx17 == nil, "context [17] must not yield a member array") + + -- APPLICATION [16] -- i.e. an LDAP protocolOp -- likewise. + local _, app16, e5 = asn1.decode(ldap_hex("70 03 02 01 05")) + assert(e5 ~= nil, "APPLICATION [16] must not be walked as SEQUENCE") + assert(app16 == nil, "APPLICATION [16] must not yield a member array") + + 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) + + 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") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: SEQUENCE/SET decoders must honour the constructed bit +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, prim_seq, e1 = asn1.decode(ldap_hex("10 03 02 01 05")) + assert(e1 ~= nil, "primitive universal tag 16 must be rejected, not walked as SEQUENCE") + assert(prim_seq == nil, "primitive tag 16 must not yield a member array") + + local _, prim_set, e2 = asn1.decode(ldap_hex("11 03 02 01 05")) + assert(e2 ~= nil, "primitive universal tag 17 must be rejected, not walked as SET") + assert(prim_set == nil, "primitive tag 17 must not yield a member array") + + -- A properly constructed SEQUENCE of the same content still decodes. + local _, good, e3 = asn1.decode(ldap_hex("30 03 02 01 05")) + assert(e3 == nil and type(good) == "table" and good[1] == 5, + "constructed SEQUENCE still decodes") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: 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] + +=== TEST 8: LDAPMessage envelope must consume the whole packet +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local base = "30 0c 02 01 01 61 07 0a 01 00 04 00 04 00" + + local ok_res = assert(protocol.decode_message(ldap_hex(base)), "clean packet still decodes") + assert(ok_res.result_code == 0, "clean packet result_code") + + local r1, e1 = protocol.decode_message(ldap_hex(base .. " de ad be ef")) + assert(r1 == nil, "trailing garbage after the envelope must be rejected") + assert(e1 ~= nil, "trailing garbage must report an error") + + -- A whole second LDAPMessage appended: today only the first is seen. + local r2, e2 = protocol.decode_message( + ldap_hex(base .. " 30 0c 02 01 02 61 07 0a 01 31 04 00 04 00")) + assert(r2 == nil, "a second appended LDAPMessage must be rejected") + assert(e2 ~= nil, "a second appended LDAPMessage must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 9: silent-nil fields must not reach protocol.lua as nil +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- (a) PartialAttribute type NULL not OCTET STRING: atype nil -> attributes[nil] error. + local entry = "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 05 00 31 00" + local called, res, err = pcall(protocol.decode_message, ldap_hex(entry)) + assert(called, "decode_message must not raise a Lua error, got: " .. tostring(res)) + assert(res == nil and err ~= nil, + "an unreadable attribute type must be a returned error") + + -- (b) resultCode BOOLEAN not ENUMERATED: result_code nil -> ldap.lua concat error. + local bad_code = "30 0c 02 01 01 61 07 01 01 ff 04 00 04 00" + local r2, e2 = protocol.decode_message(ldap_hex(bad_code)) + assert(r2 == nil, "a non-ENUMERATED resultCode must be rejected, got a result table") + assert(e2 ~= nil, "a non-ENUMERATED resultCode must report an error") + + -- (c) objectName NULL not OCTET STRING: entry_dn silently becomes nil. + local bad_dn = "30 0f 02 01 03 64 0a 05 00 30 06 30 04 04 00 31 00" + local r3, e3 = protocol.decode_message(ldap_hex(bad_dn)) + assert(r3 == nil, "an unreadable objectName must be rejected, got entry_dn=" .. + tostring(r3 and r3.entry_dn)) + assert(e3 ~= nil, "an unreadable objectName must report an error") + + -- (d) SearchResultReference with an undecodable element: URI list silently shrinks. + local bad_ref = "30 0c 02 01 02 73 07 04 03 61 62 63 05 00" + local r4, e4 = protocol.decode_message(ldap_hex(bad_ref)) + assert(r4 == nil, "an unreadable referral URI must be rejected, got n=" .. + tostring(r4 and r4.uris and #r4.uris)) + assert(e4 ~= nil, "an unreadable referral URI must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 10: INTEGER beyond 2^53 must not be silently rounded +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, a, ea = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 00")) + local _, b, eb = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) + assert(eb ~= nil or a ~= b, + "2^53 and 2^53+1 must not both decode to " .. string.format("%.17g", tonumber(b) or 0)) + + local _, mx, emx = asn1.decode(ldap_hex("02 08 7f ff ff ff ff ff ff ff")) + assert(emx ~= nil or mx == 9223372036854775807, + "maxint64 must be exact or refused, got " .. string.format("%.17g", tonumber(mx) or 0)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode.t b/t/decode.t index 26c1cc4..4f42ffc 100644 --- a/t/decode.t +++ b/t/decode.t @@ -1,3 +1,5 @@ +# Hand-built RFC 4511 message shapes. https://www.rfc-editor.org/rfc/rfc4511.txt + use Test::Nginx::Socket::Lua; log_level('info'); @@ -7,7 +9,7 @@ repeat_each(1); plan 'no_plan'; our $HttpConfig = <<'_EOC_'; - lua_package_path 'lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; resolver 127.0.0.53; _EOC_ @@ -22,9 +24,8 @@ __DATA__ location /t { content_by_lua_block { local protocol = require("resty.ldap.protocol") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end - -- 30 0c 02 01 01 61 07 0a 01 00 04 00 04 00 (reference 6.1) - local res = assert(protocol.decode_message(h("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + 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") @@ -45,9 +46,8 @@ ok location /t { content_by_lua_block { local protocol = require("resty.ldap.protocol") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end - -- reference 4.5 worked 75-byte entry - local pkt = h([[30 49 02 01 02 64 44 + 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 @@ -78,9 +78,8 @@ ok location /t { content_by_lua_block { local protocol = require("resty.ldap.protocol") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end - -- entry_dn "x", attribute "s" = single value bytes 01 00 02 - local res = assert(protocol.decode_message(h( + 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") @@ -102,14 +101,13 @@ ok location /t { content_by_lua_block { local protocol = require("resty.ldap.protocol") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end - -- entry_dn "x", one attribute "s" with EMPTY vals set (typesOnly): 31 00 - local res = assert(protocol.decode_message(h( + 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(h("30 0a 02 01 03 64 05 04 01 78 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") @@ -128,9 +126,8 @@ ok location /t { content_by_lua_block { local protocol = require("resty.ldap.protocol") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end - -- msgID 2, ref with one URI "ldap://x" - local res = assert(protocol.decode_message(h("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78"))) + 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") @@ -150,13 +147,12 @@ ok location /t { content_by_lua_block { local protocol = require("resty.ldap.protocol") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end - -- SearchResultDone noSuchObject(32=0x20) - local res = assert(protocol.decode_message(h("30 0c 02 01 02 65 07 0a 01 20 04 00 04 00"))) + 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(h("04 01 41")) + local bad, err = protocol.decode_message(ldap_hex("04 01 41")) assert(bad == nil and err ~= nil, "malformed rejected") ngx.say("ok") } @@ -174,16 +170,14 @@ ok location /t { content_by_lua_block { local protocol = require("resty.ldap.protocol") - local function h(s) return (s:gsub("%s+",""):gsub("%x%x", function(b) return string.char(tonumber(b,16)) end)) end + local ldap_hex = require("ldap_hex") - -- BindResponse declares 2 content bytes; its resultCode TLV is 4 and - -- runs into matchedDN, decoding as a "successful" result_code 256 - local bad, err = protocol.decode_message(h("30 0d 02 01 01 61 02 0a 02 01 00 04 00 04 00")) + 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(h("30 0f 02 01 02 73 02 04 08 6c 64 61 70 3a 2f 2f 78")) + 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") diff --git a/t/decode_hostile.t b/t/decode_hostile.t new file mode 100644 index 0000000..7faf026 --- /dev/null +++ b/t/decode_hostile.t @@ -0,0 +1,468 @@ +# Vectors cross-checked against pyasn1 v0.6.4 and rasn v0.6.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: 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: resultCode must be a number, or decoding must fail +--- 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 = { + { "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" }, + } + for _, c in ipairs(cases) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + if r ~= nil then + assert(type(r.result_code) == "number", + c[1] .. " resultCode yielded " .. type(r.result_code)) + else + assert(e ~= nil, c[1] .. " resultCode: nil result with no error") + end + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: an undecodable attribute type must error, not raise 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 14 02 01 03 64 0f 04 01 78 30 0a 30 08 01 01 ff 31 03 04 01 76") + local pok, r, e = pcall(protocol.decode_message, pkt) + assert(pok, "malicious attribute type raised a Lua exception: " .. tostring(r)) + assert(r == nil, "undecodable attribute type must not produce a result") + assert(e ~= nil, "undecodable attribute type must report an error") + + -- Same shape, `type` is a SEQUENCE: the key must not become a table. + local pkt2 = ldap_hex("30 13 02 01 03 64 0e 04 01 78 30 09 30 07 30 00 31 03 04 01 76") + local pok2, r2, e2 = pcall(protocol.decode_message, pkt2) + assert(pok2, "SEQUENCE attribute type raised a Lua exception: " .. tostring(r2)) + if r2 ~= nil then + for k in pairs(r2.attributes) do + assert(type(k) == "string", "attribute key is a " .. type(k)) + end + else + assert(e2 ~= nil, "non-string attribute type: nil result with no error") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: 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 6: 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 7: context-specific [4] is not a universal OCTET STRING (class confusion) +--- 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 [4]", "30 0f 02 01 01 61 0a 0a 01 31 84 03 41 42 43 04 00" }, + { "objectName [4]", "30 0c 02 01 03 64 07 84 03 41 42 43 30 00" }, + { "attribute type [4]","30 23 02 01 03 64 1e 04 01 78 30 19 30 17" .. + "84 08 6d 65 6d 62 65 72 4f 66" .. + "31 0b 04 09 63 6e 3d 61 64 6d 69 6e 73" }, + { "attribute val [4]", "30 16 02 01 03 64 11 04 01 78 30 0c 30 0a 04 01 6d 31 05 84 03 41 42 43" }, + { "SearchResRef [4]", "30 0b 02 01 02 73 06 04 01 61 84 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 8: 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 9: an ENUMERATED that overflows a C long must not collapse to -1 +--- 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 14 02 01 01 61 0f 0a 09 00 ff ff ff ff ff ff ff ff 04 00 04 00")) + assert(r == nil or r.result_code ~= -1, + "overflowing ENUMERATED silently reported as resultCode -1") + if r == nil then + assert(e ~= nil, "overflowing ENUMERATED: nil result with no error") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 10: 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 11: 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 12: 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 13: 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] diff --git a/t/decode_member_count.t b/t/decode_member_count.t new file mode 100644 index 0000000..5d457b2 --- /dev/null +++ b/t/decode_member_count.t @@ -0,0 +1,135 @@ +# 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: a dropped vals member is reachable from decode_message (EOC) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- vals SET holds "a", EOC (00 00), "b"; EOC decodes to silent nil -> must error, not drop + local res, err = protocol.decode_message(ldap_hex( + "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")) + + -- An element that yields no value is an error, not an omission. + assert(res == nil, "entry with an undecodable vals member must not decode") + assert(err ~= nil, "an undecodable vals member must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 2: same silent drop via a BOOLEAN member +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- Middle member is BOOLEAN TRUE (01 01 ff): has encoder but no decoder, same silent-nil path + local res, err = protocol.decode_message(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" .. + " 31 09 04 01 61 01 01 ff 04 01 62")) + + assert(res == nil, "entry with a BOOLEAN vals member must not decode") + assert(err ~= nil, "a BOOLEAN vals member must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 3: SearchResultReference URI list drops members too (separate code path) +--- 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_reference builds uris via its own loop, not decode_children; needs the decode() fix + local res, err = protocol.decode_message(ldap_hex( + "30 0d 02 01 02 73 08 04 01 61 00 00 04 01 62")) + + assert(res == nil, "reference with an undecodable URI member must not decode") + assert(err ~= nil, "an undecodable URI member must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 4: 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..937c2cc --- /dev/null +++ b/t/decode_protocol.t @@ -0,0 +1,313 @@ +# 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: an unrecognized protocolOp must not be parsed as 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") + + -- [APPLICATION 30] is unassigned in RFC 4511 + local bad, err = protocol.decode_message(ldap_hex("30 0c 02 01 01 7e 07 0a 01 00 04 00 04 00")) + assert(bad == nil, "unassigned app tag 30 must not yield an LDAPResult") + assert(err ~= nil, "unassigned app tag 30 must report an error") + + -- UnbindRequest [APPLICATION 2]: client->server NULL-body PDU (RFC 4511 s4.3), never result_code 0 + local bad2, err2 = protocol.decode_message(ldap_hex("30 0c 02 01 01 62 07 0a 01 00 04 00 04 00")) + assert(bad2 == nil, "UnbindRequest must not be reported as result_code 0") + assert(err2 ~= nil, "UnbindRequest must report an error") + + -- SearchRequest [APPLICATION 3], likewise a request PDU + local bad3, err3 = protocol.decode_message(ldap_hex("30 0c 02 01 01 63 07 0a 01 00 04 00 04 00")) + assert(bad3 == nil and err3 ~= nil, "SearchRequest must not decode as a result") + + -- primitive APPLICATION [1] is not well-formed; every protocolOp is constructed + local bad4, err4 = protocol.decode_message(ldap_hex("30 0c 02 01 01 41 07 0a 01 00 04 00 04 00")) + assert(bad4 == nil and err4 ~= nil, "primitive APPLICATION op must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: every real LDAPResult-shaped op still decodes (allowlist must not overreach) +--- 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 = { + { "61", protocol.APP_NO.BindResponse }, + { "65", protocol.APP_NO.SearchResultDone }, + { "67", protocol.APP_NO.ModifyResponse }, + { "78", protocol.APP_NO.ExtendedResponse }, + } + for _, c in ipairs(cases) do + local pkt = ldap_hex("30 0c 02 01 01 " .. c[1] .. " 07 0a 01 00 04 00 04 00") + local res, err = protocol.decode_message(pkt) + assert(res ~= nil, "op 0x" .. c[1] .. " must decode: " .. tostring(err)) + assert(res.protocol_op == c[2], "op 0x" .. c[1] .. " tag") + assert(res.result_code == 0, "op 0x" .. c[1] .. " result_code") + end + + local res = assert(protocol.decode_message( + ldap_hex("30 81 0d 02 01 01 61 81 07 0a 01 00 04 00 04 00"))) + assert(res.protocol_op == protocol.APP_NO.BindResponse, "non-minimal op") + assert(res.result_code == 0, "non-minimal result_code") + assert(res.matched_dn == "" and res.diagnostic_msg == "", "non-minimal fields") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: 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") + + local bad, err = protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 01 01 ff 04 00 04 00")) + assert(bad == nil, "non-numeric resultCode must not produce a result table") + assert(err ~= nil, "non-numeric resultCode must report an error") + + -- OCTET STRING resultCode becomes "\0", compares ~= 0, so a successful bind reads as failure + local bad2, err2 = protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 04 01 00 04 00 04 00")) + assert(bad2 == nil and err2 ~= nil, "OCTET STRING resultCode must be rejected") + + -- 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") + + -- messageID is INTEGER (RFC 4511 s4.1.1); an OCTET STRING here yields message_id == "A" + local bad4, err4 = protocol.decode_message(ldap_hex("30 0c 04 01 41 61 07 0a 01 00 04 00 04 00")) + assert(bad4 == nil and err4 ~= nil, "non-integer messageID must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: a non-string attribute type must error, never index the table with nil +--- 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 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00") + local ok, res, err = pcall(protocol.decode_message, pkt) + assert(ok, "decode_message must not raise: " .. tostring(res)) + assert(res == nil, "nil attribute type must not produce a result") + assert(err ~= nil, "nil attribute type must report an error") + + -- `type` is an INTEGER -> a numeric key lands in the attributes table + local pkt2 = ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 30 05 02 01 07 31 00") + local ok2, res2, err2 = pcall(protocol.decode_message, pkt2) + assert(ok2, "decode_message must not raise on numeric type") + assert(res2 == nil and err2 ~= nil, "numeric attribute type must be rejected") + + -- objectName is an LDAPDN (OCTET STRING); an INTEGER here yields entry_dn == 9 + local bad3, err3 = protocol.decode_message(ldap_hex("30 0a 02 01 03 64 05 02 01 09 30 00")) + assert(bad3 == nil and err3 ~= nil, "non-string objectName must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: PartialAttributeList and PartialAttribute must be constructed 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 bad, err = protocol.decode_message( + ldap_hex("30 14 02 01 03 64 0f 04 01 78 04 0a 30 08 04 01 73 31 03 04 01 61")) + assert(bad == nil, "PartialAttributeList must be a constructed SEQUENCE") + assert(err ~= nil, "non-SEQUENCE attribute list must report an error") + + -- primitive OCTET STRING walked as if it were a PartialAttribute SEQUENCE + local bad2, err2 = protocol.decode_message( + ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 04 05 04 01 73 31 00")) + assert(bad2 == nil, "PartialAttribute must be a constructed SEQUENCE") + assert(err2 ~= nil, "non-SEQUENCE PartialAttribute must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: attribute values are always an array (vals must be a SET) +--- 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 12 02 01 03 64 0d 04 01 78 30 08 30 06 04 01 73 04 01 41")) + assert(bad == nil, "vals that is not a SET must be rejected") + assert(err ~= nil, "non-SET vals must report an error") + + -- well-formed shape and typesOnly empty SET still work (SET wrapping adds 2 bytes per level) + local ok1 = assert(protocol.decode_message( + ldap_hex("30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 04 01 73 31 03 04 01 41"))) + assert(type(ok1.attributes.s) == "table" and ok1.attributes.s[1] == "A", "well-formed vals") + local ok2 = 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(ok2.attributes.s) == "table" and #ok2.attributes.s == 0, "empty vals SET") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: 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] + +=== TEST 8: bytes left over after a parsed element are never 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 bad, err = protocol.decode_message(ldap_hex("30 0d 02 01 03 64 08 04 01 78 30 00 04 01 5a")) + assert(bad == nil, "trailing bytes inside SearchResultEntry must be rejected") + assert(err ~= nil, "trailing bytes inside the op must report an error") + + -- same at the envelope level: the LDAPMessage SEQUENCE must consume the whole packet + local bad2, err2 = protocol.decode_message( + ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00 de ad be ef")) + assert(bad2 == nil, "trailing bytes after the envelope must be rejected") + assert(err2 ~= nil, "trailing bytes after the envelope must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 9: SearchResultReference URIs are never silently dropped or non-strings +--- 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 0c 02 01 02 73 07 01 01 ff 04 02 6f 6b")) + assert(bad == nil, "an undecodable referral element must not vanish") + assert(err ~= nil, "an undecodable referral element must report an error") + + -- nested SEQUENCE lands a table in uris[1]; URI (RFC 4511 s4.1.10) is an OCTET STRING on the wire + local bad2, err2 = protocol.decode_message(ldap_hex("30 09 02 01 02 73 04 30 02 04 00")) + assert(bad2 == nil, "non-string referral URI must be rejected") + assert(err2 ~= nil, "non-string referral URI must report an error") + + -- well-formed referrals still decode + local ok1 = assert(protocol.decode_message( + ldap_hex("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78"))) + assert(#ok1.uris == 1 and ok1.uris[1] == "ldap://x", "well-formed referral") + + 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..3ee9ffc --- /dev/null +++ b/t/decode_rfc4511.t @@ -0,0 +1,371 @@ +# 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") + + 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") + + local pkt = ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00") + local ok, res, err = pcall(protocol.decode_message, pkt) + assert(ok, "decode_message raised instead of returning an error: " .. tostring(res)) + assert(res == nil, "undecodable attribute type must not yield a result") + assert(err ~= nil, "undecodable attribute type must report an error") + + -- non-string AttributeDescription (INTEGER 5) keys attributes by number; must be LDAPString (RFC 4511 s4.1.4) + local ok2, res2, err2 = pcall(protocol.decode_message, + ldap_hex("30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 02 01 05 31 03 04 01 41")) + assert(ok2, "non-string attribute type raised: " .. tostring(res2)) + assert(res2 == nil and err2 ~= nil, + "attribute type that is not an LDAPString must be rejected") + + -- objectName that is not an LDAPDN (INTEGER 5) must be an error, not entry_dn == 5 + local ok3, res3, err3 = pcall(protocol.decode_message, ldap_hex("30 0a 02 01 03 64 05 02 01 05 30 00")) + assert(ok3, "non-string objectName raised: " .. tostring(res3)) + assert(res3 == nil and err3 ~= nil, "objectName must be an LDAPDN, got " .. tostring(res3 and res3.entry_dn)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: a missing resultCode or messageID must fail loudly, not become nil +--- 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 0c 02 01 01 61 07 01 01 ff 04 00 04 00")) + assert(bad == nil, "resultCode must be a number; got result_code=" .. + tostring(bad and bad.result_code)) + assert(err ~= nil, "undecodable resultCode must report an error") + + -- messageID as a BOOLEAN: a nil message_id breaks request/response correlation (RFC 4511 s4.1.1) + local bad2, err2 = protocol.decode_message(ldap_hex("30 0c 01 01 ff 61 07 0a 01 00 04 00 04 00")) + assert(bad2 == nil, "messageID must be an INTEGER; got message_id=" .. + tostring(bad2 and bad2.message_id)) + assert(err2 ~= nil, "undecodable messageID must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: 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") + + local bad, err = protocol.decode_message(ldap_hex([[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]])) + assert(bad == nil, "reference with an undecodable member must be rejected; got " .. + tostring(bad and #bad.uris) .. " uris") + assert(err ~= nil, "reference with an undecodable member must report an error") + + -- 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 7: 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] + +=== TEST 8: an out-of-range messageID must not be silently coerced to -1 +--- 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 14 02 09 00 ff ff ff ff ff ff ff ff 61 07 0a 01 00 04 00 04 00")) + assert(bad == nil, "out-of-range messageID must be rejected; got message_id=" .. + tostring(bad and bad.message_id)) + assert(err ~= nil, "out-of-range messageID must report an error") + + -- a genuine large-but-legal messageID still works: 2147483647 == maxInt + local r = assert(protocol.decode_message( + ldap_hex("30 0f 02 04 7f ff ff ff 61 07 0a 01 00 04 00 04 00"))) + assert(r.message_id == 2147483647, "maxInt messageID " .. tostring(r.message_id)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/lib/ldap_hex.lua b/t/lib/ldap_hex.lua new file mode 100644 index 0000000..ce951e1 --- /dev/null +++ b/t/lib/ldap_hex.lua @@ -0,0 +1,7 @@ +-- 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) + return (s:gsub("%s+", ""):gsub("%x%x", function(b) + return string.char(tonumber(b, 16)) + end)) +end From e63bae9c2977813227164842526046b914986803 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 22 Jul 2026 11:30:17 +0800 Subject: [PATCH 18/27] feat: enhance LDAP authentication with improved username validation and TLS verification handling --- README.md | 5 +- lib/resty/ldap.lua | 30 ++++- lib/resty/ldap/ldap.lua | 81 ++++++++---- t/compat_ldap.t | 274 ++++++++++++++++++++++++++++++++++++++++ t/lib/ldap_hex.lua | 9 +- 5 files changed, 365 insertions(+), 34 deletions(-) create mode 100644 t/compat_ldap.t diff --git a/README.md b/README.md index 44b8d9d..ceca1cc 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Compatibility entrypoint kept for APISIX's `ldap-auth` plugin (restored from v0. Binds against the directory as `=,` with the given password. `ok` is `true` when authentication succeeds; otherwise `ok` is `false` or `nil` and `err` carries the error. -`username` is RFC 4514-escaped before being placed in the DN, so DN metacharacters bind as literal characters instead of injecting extra RDN components. +`username` is placed in the DN verbatim, so the bind DN is byte-identical to the `=,` key APISIX's `ldap-auth` plugin derives for its Consumer lookup. A username that contains DN metacharacters (`, + " \ < > ; =`, a leading space or `#`, a trailing space, or a NUL) cannot be represented verbatim without injecting extra RDN components, so it is rejected — authentication fails before any connection is opened — rather than binding a DN that differs from the one APISIX looks up. `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): @@ -131,7 +131,8 @@ Binds against the directory as `=,` with the given | `keepalive` | number | 60000 | Connection pool keepalive in milliseconds. | | `start_tls` | boolean | false | Issue the StartTLS extended operation before binding. | | `ldaps` | boolean | false | Connect using LDAP over TLS. | -| `verify_ldap_host` | boolean | false | Verify the server certificate during the TLS handshake. | +| `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 index 667eaf6..5a4f885 100644 --- a/lib/resty/ldap.lua +++ b/lib/resty/ldap.lua @@ -13,7 +13,6 @@ local default_conf = { ldap_host = "localhost", ldap_port = 389, ldaps = false, - verify_ldap_host = false, base_dn = "ou=users,dc=example,dc=org", attribute = "cn", keepalive = 60000, @@ -28,9 +27,17 @@ local function set_conf_default_values(conf) 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 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 + + local function escape_dn_value(value) local lead = value:sub(1, 1) -- a lone leading space is covered by the lead rule alone @@ -55,6 +62,11 @@ local _M = {} function _M.ldap_authenticate(given_username, given_password, conf) set_conf_default_values(conf) + if type(given_username) ~= "string" or + escape_dn_value(given_username) ~= given_username then + return false, "username contains characters not allowed in a bind DN" + end + local is_authenticated local err, suppressed_err, ok, _ @@ -96,17 +108,21 @@ function _M.ldap_authenticate(given_username, given_password, conf) end if conf.start_tls or conf.ldaps then - _, err = sock:sslhandshake(true, conf.ldap_host, conf.verify_ldap_host) + _, err = sock:sslhandshake(true, conf.ldap_host, resolve_tls_verify(conf)) if err ~= nil then return false, fmt("failed to do SSL handshake with %s:%s: %s", conf.ldap_host, tostring(conf.ldap_port), err) end end - local who = conf.attribute .. "=" .. escape_dn_value(given_username) .. - "," .. conf.base_dn + local who = conf.attribute .. "=" .. given_username .. "," .. conf.base_dn is_authenticated, err = ldap.bind_request(sock, who, given_password) + if is_authenticated == nil then + -- transport failure; bind_request already closed the socket + return nil, err + end + ok, suppressed_err = sock:setkeepalive(conf.keepalive) if not ok then log(ERR, "failed to keepalive to ", conf.ldap_host, ":", diff --git a/lib/resty/ldap/ldap.lua b/lib/resty/ldap/ldap.lua index 604ef75..8219fb5 100644 --- a/lib/resty/ldap/ldap.lua +++ b/lib/resty/ldap/ldap.lua @@ -94,31 +94,44 @@ function _M.bind_request(socket, username, password) local ldapMsg = asn1_encode(ldapMessageId) .. asn1_put_object(APPNO.BindRequest, asn1.CLASS.APPLICATION, 1, bindReq) - local packet, packet_len, packet_header, lerr - - packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) + local packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) ldapMessageId = ldapMessageId + 1 - socket:send(packet) + local bytes, err = socket:send(packet) + if not bytes then + socket:close() + return nil, fmt("send bind request failed: %s", err or "closed") + end - packet = socket:receive(2) + local header, herr = socket:receive(2) + if not header then + socket:close() + return nil, fmt("receive bind response header failed: %s", herr or "closed") + end - packet_len, packet_header, lerr = calculate_payload_length(packet, 2, socket) + local packet_len, packet_header, lerr = calculate_payload_length(header, 2, socket) if not packet_len then - return false, lerr + socket:close() + return nil, lerr end - packet = socket:receive(packet_len) + local body, berr = socket:receive(packet_len) + if not body then + socket:close() + return nil, fmt("receive bind response body failed: %s", berr or "closed") + end -- decode_message expects the full LDAPMessage, envelope header included - local res, err = decode_ldap(packet_header .. packet) - if err then - return false, "Invalid LDAP message encoding: " .. err + local res, derr = decode_ldap(packet_header .. body) + if derr then + socket:close() + return nil, "Invalid LDAP message encoding: " .. derr end if res.protocol_op ~= APPNO.BindResponse then - return false, fmt("Received incorrect Op in packet: %d, expected %d", + socket:close() + return nil, fmt("Received incorrect Op in packet: %d, expected %d", res.protocol_op, APPNO.BindResponse) end @@ -144,45 +157,65 @@ function _M.unbind_request(socket) asn1_put_object(APPNO.UnbindRequest, asn1.CLASS.APPLICATION, 0) packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) - socket:send(packet) + local bytes, err = socket:send(packet) + if not bytes then + socket:close() + return nil, fmt("send unbind request failed: %s", err or "closed") + end return true, "" end function _M.start_tls(socket) - local ldapMsg, packet, packet_len, packet_header, lerr - local method_name = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, "1.3.6.1.4.1.1466.20037") ldapMessageId = ldapMessageId + 1 - ldapMsg = asn1_encode(ldapMessageId) .. + local ldapMsg = asn1_encode(ldapMessageId) .. asn1_put_object(APPNO.ExtendedRequest, asn1.CLASS.APPLICATION, 1, method_name) - packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) - socket:send(packet) - packet = socket:receive(2) + local packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) + + local bytes, err = socket:send(packet) + if not bytes then + socket:close() + return false, fmt("send STARTTLS request failed: %s", err or "closed") + end + + local header, herr = socket:receive(2) + if not header then + socket:close() + return false, fmt("receive STARTTLS response header failed: %s", herr or "closed") + end - packet_len, packet_header, lerr = calculate_payload_length(packet, 2, socket) + local packet_len, packet_header, lerr = calculate_payload_length(header, 2, socket) if not packet_len then + socket:close() return false, lerr end - packet = socket:receive(packet_len) + local body, berr = socket:receive(packet_len) + if not body then + socket:close() + return false, fmt("receive STARTTLS response body failed: %s", berr or "closed") + end -- decode_message expects the full LDAPMessage, envelope header included - local res, err = decode_ldap(packet_header .. packet) - if err then - return false, "Invalid LDAP message encoding: " .. err + local res, derr = decode_ldap(packet_header .. body) + if derr then + socket:close() + return false, "Invalid LDAP message encoding: " .. derr end if res.protocol_op ~= APPNO.ExtendedResponse then + socket:close() return false, fmt("Received incorrect Op in packet: %d, expected %d", res.protocol_op, APPNO.ExtendedResponse) end if res.result_code ~= 0 then + socket:close() local error_msg = ERROR_MSG[res.result_code] return false, fmt("\n Error: %s\n Details: %s", diff --git a/t/compat_ldap.t b/t/compat_ldap.t new file mode 100644 index 0000000..5d2df6d --- /dev/null +++ b/t/compat_ldap.t @@ -0,0 +1,274 @@ +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: 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 == false, "verification should have failed the handshake") + assert(err:find("SSL handshake", 1, true), "unexpected err: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +lua tls 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 == false, "legacy verify_ldap_host should have enforced verification") + assert(err:find("SSL handshake", 1, true), "unexpected err: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +lua tls certificate verify error + + + +=== TEST 4: bind_request on a dead socket fails cleanly +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap.ldap") + + local sock = ngx.socket.tcp() + sock:settimeout(10000) + assert(sock:connect("127.0.0.1", 1389)) + sock:close() -- kill the connection underneath bind_request + + local res, err = ldap.bind_request(sock, + "cn=user01,ou=users,dc=example,dc=org", "password1") + assert(res == nil, "a dead socket must fail, got " .. tostring(res)) + assert(err ~= nil, "a dead socket must report an error") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +attempt to send data on a closed socket + + + +=== TEST 5: bind_request returns a controlled error when the response header is unreadable +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap.ldap") + + 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 res, err = ldap.bind_request(sock, + "cn=user01,ou=users,dc=example,dc=org", "password1") + assert(res == nil, "a receive timeout must fail, got " .. tostring(res)) + assert(err ~= nil, "a receive timeout must report an error") + assert(closed, "the unusable socket must be closed") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 6: bind_request returns a controlled error when the response body is truncated +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap.ldap") + + 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 res, err = ldap.bind_request(sock, + "cn=user01,ou=users,dc=example,dc=org", "password1") + assert(res == nil, "a truncated body must fail, got " .. tostring(res)) + assert(err ~= nil, "a truncated body must report an error") + assert(closed, "the unusable socket must be closed") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 7: ldap_authenticate rejects usernames with DN metacharacters +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + -- a real port, but the metacharacter check must reject before connecting + local ok, err = 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, "a metacharacter username must be rejected") + assert(err == "username contains characters not allowed in a bind DN", + "unexpected err: " .. tostring(err)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 8: 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 = 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 9: 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 fail") + 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] diff --git a/t/lib/ldap_hex.lua b/t/lib/ldap_hex.lua index ce951e1..c7db34a 100644 --- a/t/lib/ldap_hex.lua +++ b/t/lib/ldap_hex.lua @@ -1,7 +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) - return (s:gsub("%s+", ""):gsub("%x%x", function(b) + 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 From 3a4face0c1940dbf042e4e8da3e9a055195a8353 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 22 Jul 2026 11:55:05 +0800 Subject: [PATCH 19/27] feat: improve username handling in ldap_authenticate by escaping DN metacharacters --- README.md | 2 +- lib/resty/ldap.lua | 15 +++++++++++---- t/compat_ldap.t | 11 ++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ceca1cc..2b97c1c 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Compatibility entrypoint kept for APISIX's `ldap-auth` plugin (restored from v0. Binds against the directory as `=,` with the given password. `ok` is `true` when authentication succeeds; otherwise `ok` is `false` or `nil` and `err` carries the error. -`username` is placed in the DN verbatim, so the bind DN is byte-identical to the `=,` key APISIX's `ldap-auth` plugin derives for its Consumer lookup. A username that contains DN metacharacters (`, + " \ < > ; =`, a leading space or `#`, a trailing space, or a NUL) cannot be represented verbatim without injecting extra RDN components, so it is rejected — authentication fails before any connection is opened — rather than binding a DN that differs from the one APISIX looks up. +`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. `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): diff --git a/lib/resty/ldap.lua b/lib/resty/ldap.lua index 5a4f885..5264023 100644 --- a/lib/resty/ldap.lua +++ b/lib/resty/ldap.lua @@ -38,6 +38,9 @@ local function resolve_tls_verify(conf) 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 @@ -62,9 +65,12 @@ local _M = {} function _M.ldap_authenticate(given_username, given_password, conf) set_conf_default_values(conf) - if type(given_username) ~= "string" or - escape_dn_value(given_username) ~= given_username then - return false, "username contains characters not allowed in a bind DN" + -- same coercion contract as ldap.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 is_authenticated @@ -115,7 +121,8 @@ function _M.ldap_authenticate(given_username, given_password, conf) end end - local who = conf.attribute .. "=" .. given_username .. "," .. conf.base_dn + local who = conf.attribute .. "=" .. escape_dn_value(given_username) .. + "," .. conf.base_dn is_authenticated, err = ldap.bind_request(sock, who, given_password) if is_authenticated == nil then diff --git a/t/compat_ldap.t b/t/compat_ldap.t index 5d2df6d..b3d4f5a 100644 --- a/t/compat_ldap.t +++ b/t/compat_ldap.t @@ -196,22 +196,23 @@ ok -=== TEST 7: ldap_authenticate rejects usernames with DN metacharacters +=== TEST 7: 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") - -- a real port, but the metacharacter check must reject before connecting + -- "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 = 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, "a metacharacter username must be rejected") - assert(err == "username contains characters not allowed in a bind DN", - "unexpected err: " .. tostring(err)) + assert(ok == false, "an unknown escaped username must fail cleanly") + assert(err:find("credential", 1, true), + "expected a credential error: " .. tostring(err)) ngx.say("ok") } } From f90c6f11ac0843907875ecaa31c44846c11a8505 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 22 Jul 2026 13:27:30 +0800 Subject: [PATCH 20/27] test: consolidate duplicate coverage into canonical homes Delete 41 duplicate test blocks and dissolve t/decode_member_count.t and t/asn1_int_precision.t into their canonical homes; strengthen each home first (offset assertions, migrated vectors, provenance headers) so no coverage is lost. Verified by mutation testing: re-introducing each hardened-away bug still fails a surviving test. --- t/asn1.t | 52 -------- t/asn1_bounds.t | 53 ++------ t/asn1_class.t | 48 ++++++- t/asn1_constructed.t | 5 + t/asn1_depth.t | 88 +------------ t/asn1_encode.t | 64 ++------- t/asn1_ffi.t | 152 +-------------------- t/asn1_int_precision.t | 160 ---------------------- t/asn1_integer.t | 61 +++------ t/asn1_nil_member.t | 50 +++++-- t/asn1_oracle.t | 112 +--------------- t/asn1_vectors.t | 285 +--------------------------------------- t/decode_hostile.t | 175 ++++++------------------ t/decode_member_count.t | 135 ------------------- t/decode_protocol.t | 256 +++--------------------------------- t/decode_rfc4511.t | 148 +++++++++------------ 16 files changed, 260 insertions(+), 1584 deletions(-) delete mode 100644 t/asn1_int_precision.t delete mode 100644 t/decode_member_count.t diff --git a/t/asn1.t b/t/asn1.t index 8ff4fea..5618ec0 100644 --- a/t/asn1.t +++ b/t/asn1.t @@ -194,55 +194,3 @@ GET /t ok --- no_error_log [error] - -=== TEST 7: put_object length header is not truncated at 0x00 length octets ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - - -- long-form length octets containing 0x00 (256, 512) must round-trip - for _, n in ipairs({0, 1, 255, 256, 512, 768}) do - local data = string.rep("A", n) - local enc = asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, data) - local obj = assert(asn1.get_object(enc), "n=" .. n .. " failed to parse") - assert(obj.len == n, "n=" .. n .. " decoded len " .. tostring(obj.len)) - assert(#enc == obj.hl + n, "n=" .. n .. " total length mismatch") - end - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 8: a child TLV may not overrun its parent's declared length ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - -- outer SEQUENCE says 3 bytes, inner OCTET STRING claims 5, buffer long enough - local _, v, err = asn1.decode(ldap_hex("30 03 04 05 41 42 43 44 45")) - assert(v == nil, "no value when a child overruns its parent") - assert(err ~= nil, "parent overrun reports an error") - - -- same bytes, correct outer length - local _, ok_v = asn1.decode(ldap_hex("30 07 04 05 41 42 43 44 45")) - assert(type(ok_v) == "table" and ok_v[1] == "ABCDE", "well-formed still decodes") - - 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 index 718e98d..de484c9 100644 --- a/t/asn1_bounds.t +++ b/t/asn1_bounds.t @@ -130,6 +130,17 @@ ok 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"), @@ -286,45 +297,7 @@ ok --- no_error_log [error] -=== TEST 7: decode_message returns an error, never raises, on a nil attribute type ---- 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:232 keys on atype; a nil atype raises "table index is nil" - local cases = { - -- SearchResultEntry, attr type = NULL (05 00), vals = empty SET - { "NULL type", "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 05 00 31 00" }, - -- SearchResultEntry, attr type = BOOLEAN (01 01 01), vals = empty SET - { "BOOLEAN type", "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 01 31 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 must not raise, got: " .. tostring(res)) - assert(res == nil, c[1] .. ": must not return a result") - assert(err ~= nil, c[1] .. ": must report an error") - end - - -- a well-formed entry with the same 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", "dn") - assert(type(good.attributes.s) == "table" and #good.attributes.s == 0, "empty vals") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 8: bytes trailing the PartialAttributeList inside the op are rejected +=== TEST 7: bytes trailing the PartialAttributeList inside the op are rejected --- http_config eval: $::HttpConfig --- config location /t { @@ -359,7 +332,7 @@ ok --- no_error_log [error] -=== TEST 9: BER leniency on non-minimal lengths is preserved (guard rail) +=== TEST 8: BER leniency on non-minimal lengths is preserved (guard rail) --- http_config eval: $::HttpConfig --- config location /t { diff --git a/t/asn1_class.t b/t/asn1_class.t index ed88dce..14b0fda 100644 --- a/t/asn1_class.t +++ b/t/asn1_class.t @@ -54,8 +54,18 @@ __DATA__ ((tn == 2 or tn == 4 or tn == 10) and not cons)) local _, v, err = asn1.decode(ldap_hex(hex)) - local got_ok = (err == nil and v ~= nil) - if got_ok ~= want_ok then + 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, @@ -67,6 +77,18 @@ __DATA__ 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")) @@ -151,13 +173,21 @@ ok .. 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 + -- 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") } } @@ -181,8 +211,6 @@ ok { "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" }, - { "resultCode as BOOLEAN", "30 0c 02 01 01 61 07 01 01 ff 04 00 04 00" }, - { "resultCode as NULL", "30 0b 02 01 01 61 06 05 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" }, } @@ -252,6 +280,11 @@ ok -- 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" }, @@ -359,6 +392,11 @@ ok .. 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") } } diff --git a/t/asn1_constructed.t b/t/asn1_constructed.t index 83a30f1..6368b70 100644 --- a/t/asn1_constructed.t +++ b/t/asn1_constructed.t @@ -122,6 +122,11 @@ ok 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") } } diff --git a/t/asn1_depth.t b/t/asn1_depth.t index 6e9ce39..66ee41a 100644 --- a/t/asn1_depth.t +++ b/t/asn1_depth.t @@ -244,93 +244,7 @@ ok --- no_error_log [error] -=== TEST 5: an undecodable member must never silently shrink a SET/SEQUENCE ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - local _, ctl, cerr = asn1.decode(ldap_hex("31 06 04 01 41 04 01 42")) - assert(cerr == nil, "control errored: " .. tostring(cerr)) - assert(#ctl == 2 and ctl[1] == "A" and ctl[2] == "B", "control array") - - -- undecodable members (e.g. BOOLEAN) must be reported, never silently dropped - local cases = { - { hex = "31 06 04 01 41 01 01 ff", why = "undecodable member last" }, - { hex = "31 06 01 01 ff 04 01 41", why = "undecodable member first" }, - { hex = "30 05 04 01 41 05 00", why = "NULL member in a SEQUENCE" }, - { hex = "30 02 00 00", why = "EOC octets as a member" }, - } - for _, c in ipairs(cases) do - local off, v, err = asn1.decode(ldap_hex(c.hex)) - assert(err ~= nil, c.why .. ": expected an error, got none") - assert(v == nil, c.why .. ": expected no value") - assert(off == nil, c.why .. ": expected no offset") - end - - -- sharpest form: a SET with one undecodable member must not look empty - local _, only, oerr = asn1.decode(ldap_hex("31 03 01 01 ff")) - assert(oerr ~= nil, "SET with one undecodable member must error") - assert(only == nil, "SET with one undecodable member must not look empty") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 6: SEQUENCE and SET must be constructed to be walked as containers ---- 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, err = asn1.decode(ldap_hex("10 03 04 01 41")) - assert(v == nil, "primitive SEQUENCE must not be walked as a container") - assert(err ~= nil, "primitive SEQUENCE must report an error") - - local _, v2, err2 = asn1.decode(ldap_hex("11 03 04 01 41")) - assert(v2 == nil, "primitive SET must not be walked as a container") - assert(err2 ~= nil, "primitive SET must report an error") - - local _, v3, err3 = asn1.decode(ldap_hex("10 00")) - assert(v3 == nil and err3 ~= nil, "empty primitive SEQUENCE must be rejected") - - -- constructed forms are of course still fine - local _, ok_v, ok_err = asn1.decode(ldap_hex("30 03 04 01 41")) - assert(ok_err == nil and #ok_v == 1 and ok_v[1] == "A", "constructed SEQUENCE still decodes") - - -- reachable end to end: a primitive-encoded LDAPMessage envelope - 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") - - -- ... and so is a primitive-encoded vals SET inside a SearchResultEntry - local bad2, berr2 = protocol.decode_message(ldap_hex( - "30 16 02 01 03 64 11 04 01 78 30 0c 30 0a 04 01 73 11 05 04 03 01 00 02")) - assert(bad2 == nil, "primitive vals SET must be rejected") - assert(berr2 ~= nil, "primitive vals SET must report an error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 7: a wide, shallow document decodes completely and in linear time +=== TEST 5: a wide, shallow document decodes completely and in linear time --- http_config eval: $::HttpConfig --- config location /t { diff --git a/t/asn1_encode.t b/t/asn1_encode.t index 98018d4..338473a 100644 --- a/t/asn1_encode.t +++ b/t/asn1_encode.t @@ -64,9 +64,12 @@ ok 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 ok, v, err = pcall(asn1.encode, val, asn1.TAG.INTEGER) - if not ok then return end + 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") @@ -74,12 +77,17 @@ ok 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, 2^31, -2^31, 2^53}) do + 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)) @@ -306,53 +314,3 @@ GET /t ok --- no_error_log [error] - -=== TEST 7: the i2d-based encoders must not leak the buffer OpenSSL allocates ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - - local function rss_kib() - local f = assert(io.open("/proc/self/statm")) - local line = f:read("*l") - f:close() - return tonumber(line:match("^%d+%s+(%d+)")) * 4 - end - - local blob = string.rep("Z", 4096) - local iters = 20000 - - collectgarbage("collect") - local before = rss_kib() - for _ = 1, iters do - local _ = asn1.encode(blob, asn1.TAG.OCTET_STRING) - end - collectgarbage("collect") - local grew = rss_kib() - before - - -- 80 MiB encoded and discarded; a non-leaking encoder stays flat - assert(grew < 24000, - "encoding " .. iters .. " x 4 KiB octet strings grew RSS by " .. - grew .. " KiB (~" .. math.floor(grew / (iters * 4) * 100) .. - "% of the bytes encoded) -- the i2d output buffer is never freed") - - -- control: the put_object-based container encoder allocates nothing - collectgarbage("collect") - local b2 = rss_kib() - for _ = 1, iters do - local _ = asn1.encode(blob, asn1.TAG.SEQUENCE) - end - collectgarbage("collect") - assert(rss_kib() - b2 < 24000, "put_object path leaked, unexpectedly") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] diff --git a/t/asn1_ffi.t b/t/asn1_ffi.t index cbad63d..10e19b5 100644 --- a/t/asn1_ffi.t +++ b/t/asn1_ffi.t @@ -16,109 +16,7 @@ run_tests(); __DATA__ -=== TEST 1: i2d_* output buffers must be freed, not leaked once per encode ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - - -- i2d with *pp==NULL makes OpenSSL malloc a buffer encode leaks; GC can't reclaim it - local function rss() - local f = assert(io.open("/proc/self/statm")) - local s = f:read("*l") - f:close() - return tonumber(s:match("^%d+%s+(%d+)")) * 4096 - end - - local N, SZ = 20000, 4000 - local payload = string.rep("A", SZ) - - -- Control: the SEQUENCE encoder (asn1_put_object) allocates nothing - collectgarbage("collect") - local b1 = rss() - for _ = 1, N do - local s = asn1.encode(payload, asn1.TAG.SEQUENCE) - assert(#s == SZ + 4, "control encoding size") - end - collectgarbage("collect") - local control = rss() - b1 - - -- Subject: the OCTET STRING encoder goes through i2d. - collectgarbage("collect") - local b2 = rss() - for _ = 1, N do - local s = asn1.encode(payload, asn1.TAG.OCTET_STRING) - assert(#s == SZ + 4, "subject encoding size") - end - collectgarbage("collect") - local subject = rss() - b2 - - local excess = subject - control - -- correct frees each i2d buffer, so both paths cost the same; leaking = ~80MB excess - assert(excess < 8 * 1024 * 1024, - string.format("i2d output buffers leaked: %.1f MB excess over the " - .. "put_object control across %d encodes (control %.1f MB, " - .. "subject %.1f MB)", - excess / 1048576, N, control / 1048576, subject / 1048576)) - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 2: start offset must be validated as a non-negative integer ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - -- get_object never checks start >= 0, so a negative start reads out of bounds - local der = ldap_hex("04 03 41 42 43") - - for _, start in ipairs({-1, -2, -4, -16}) do - local o, err = asn1.get_object(der, start, #der) - assert(o == nil, "get_object must reject negative start " .. start - .. " (returned a fabricated object instead of reading out of bounds)") - assert(err ~= nil, "negative start " .. start .. " must report an error") - - local off, v, derr = asn1.decode(der, start) - assert(off == nil and derr ~= nil, - "decode must reject negative offset " .. start) - end - - -- a negative start can yield a negative obj.offset, slicing from the buffer tail - local o2 = asn1.get_object(der, -4) - assert(o2 == nil, "negative start must never produce an object with a negative offset") - - -- fractional offsets make hl fractional, then marshalled into d2i's long length - local der2 = ldap_hex("41 04 03 41 42 43 00 00 00 00") - local o3, err3 = asn1.get_object(der2, 1.9, #der2) - assert(o3 == nil, "get_object must reject a fractional start" - .. (o3 and (" (got hl=" .. tostring(o3.hl) .. ")") or "")) - assert(err3 ~= nil, "fractional start must report an error") - - local _, v3, derr3 = asn1.decode(der2, 1.2) - assert(v3 == nil and derr3 ~= nil, - "decode must reject a fractional offset instead of silently flooring it") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 3: put_object header length must agree with the bytes it returns +=== TEST 1: put_object header length must agree with the bytes it returns --- http_config eval: $::HttpConfig --- config location /t { @@ -173,51 +71,7 @@ ok --- no_error_log [error] -=== TEST 4: encode(INTEGER) must not silently substitute a different value ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - - -- ASN1_INTEGER_set takes a C long; LuaJIT truncates/saturates, emitting a different int - local function roundtrip(v) - local enc = asn1.encode(v, asn1.TAG.INTEGER) - if enc == nil then return nil end - return (select(2, asn1.decode(enc))) - end - - -- Values a C long represents exactly must round-trip. - for _, v in ipairs({0, 1, -1, 127, 128, -128, 2147483647, 2147483648, 2 ^ 53}) do - assert(roundtrip(v) == v, - "exact value " .. string.format("%.0f", v) .. " must round-trip") - end - - -- Non-integers must be refused, never silently truncated. - for _, v in ipairs({3.7, -3.7, 0.5}) do - assert(asn1.encode(v, asn1.TAG.INTEGER) == nil, - "encode(" .. tostring(v) .. ", INTEGER) must be refused, not truncated to " - .. tostring(roundtrip(v))) - end - - -- Values outside the C long range must be refused, never saturated. - for _, v in ipairs({2 ^ 63, 1e300, -1e300}) do - assert(asn1.encode(v, asn1.TAG.INTEGER) == nil, - "encode(" .. tostring(v) .. ", INTEGER) must be refused, not saturated to " - .. tostring(roundtrip(v))) - end - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 5: high-tag-form encodings of low tags must not alias the short form +=== TEST 2: high-tag-form encodings of low tags must not alias the short form --- http_config eval: $::HttpConfig --- config location /t { @@ -265,7 +119,7 @@ ok --- no_error_log [error] -=== TEST 6: FFI marshalling invariants that currently hold (regression guard) +=== TEST 3: FFI marshalling invariants that currently hold (regression guard) --- http_config eval: $::HttpConfig --- config location /t { diff --git a/t/asn1_int_precision.t b/t/asn1_int_precision.t deleted file mode 100644 index 7623a42..0000000 --- a/t/asn1_int_precision.t +++ /dev/null @@ -1,160 +0,0 @@ -# Go go1.26.3 encoding/asn1 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 above 2^53 must not silently collapse onto its neighbour ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - local _, v, err = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) - assert(v == nil, "2^53+1 must not yield a value, got " .. tostring(v)) - assert(err ~= nil, "2^53+1 must report an error") - - -- Different encodings must never decode to the same Lua value. - local _, a = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) -- 2^53+1 - local _, b = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 00")) -- 2^53 - assert(not (a ~= nil and a == b), - "2^53+1 and 2^53 decoded to the same value " .. tostring(a)) - - -- Negative side is symmetric: -(2^53+1) vs -2^53. - local _, c = asn1.decode(ldap_hex("02 07 df ff ff ff ff ff ff")) -- -(2^53+1) - local _, d = asn1.decode(ldap_hex("02 07 e0 00 00 00 00 00 00")) -- -2^53 - assert(not (c ~= nil and c == d), - "-(2^53+1) and -2^53 decoded to the same value " .. tostring(c)) - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - - - -=== TEST 2: 64-bit-range INTEGER is rejected rather than returned off-by-one ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - local _, v, err = asn1.decode(ldap_hex("02 08 7f ff ff ff ff ff ff ff")) - assert(v == nil, "maxint64 must not yield a value, got " .. tostring(v)) - assert(err ~= nil, "maxint64 must report an error") - - -- ENUMERATED shares the marshalling (LDAP resultCode); reject identically. - local _, e, eerr = asn1.decode(ldap_hex("0a 07 20 00 00 00 00 00 01")) - assert(e == nil, "oversized ENUMERATED must not yield a value, got " .. tostring(e)) - assert(eerr ~= nil, "oversized ENUMERATED must report an error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - - - -=== TEST 3: exactly-representable integers still decode (fix must not over-reject) ---- 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 05", 5 }, - { "02 01 ff", -1 }, - { "02 04 7f ff ff ff", 2147483647 }, -- max messageID, RFC 4511 - { "02 04 80 00 00 00", -2147483648 }, - { "0a 01 31", 49 }, -- ENUMERATED resultCode - } - 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] .. " got " .. tostring(v) .. " want " .. tostring(c[2])) - end - - -- 2^53 and -2^53 are the boundary and exactly representable; must be accepted. - local _, hi, herr = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 00")) - assert(herr == nil, "2^53 unexpected error: " .. tostring(herr)) - assert(hi == 9007199254740992, "2^53 got " .. tostring(hi)) - - local _, lo, lerr = asn1.decode(ldap_hex("02 07 e0 00 00 00 00 00 00")) - assert(lerr == nil, "-2^53 unexpected error: " .. tostring(lerr)) - assert(lo == -9007199254740992, "-2^53 got " .. tostring(lo)) - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - - - -=== TEST 4: oversized messageID is rejected by decode_message, not rounded ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local proto = require("resty.ldap.protocol") - local ldap_hex = require("ldap_hex") - - local big = "30 12 02 07 20 00 00 00 00 00 01 61 07 0a 01 00 04 00 04 00" - local near = "30 12 02 07 20 00 00 00 00 00 00 61 07 0a 01 00 04 00 04 00" - - local res, err = proto.decode_message(ldap_hex(big)) - assert(res == nil, "oversized messageID must not decode, got id " .. - tostring(res and res.message_id)) - assert(err ~= nil, "oversized messageID must report an error") - - -- Two different wire messageIDs must never correlate to the same in-process id. - local r1 = proto.decode_message(ldap_hex(big)) - local r2 = proto.decode_message(ldap_hex(near)) - assert(not (r1 and r2 and r1.message_id == r2.message_id), - "distinct wire messageIDs collided on " .. - tostring(r1 and r1.message_id)) - - 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 index c291603..3bd7208 100644 --- a/t/asn1_integer.t +++ b/t/asn1_integer.t @@ -44,6 +44,7 @@ __DATA__ {"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}, @@ -133,11 +134,14 @@ ok {"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 @@ -170,11 +174,13 @@ ok {"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. @@ -208,6 +214,7 @@ ok {"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])) @@ -242,7 +249,7 @@ ok 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"}, + {"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 @@ -253,53 +260,19 @@ ok 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") - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 7: resultCode must be a number, never a table or 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 res, err = protocol.decode_message(ldap_hex("30 0b 02 01 01 61 06 30 00 04 00 04 00")) - if res then - assert(type(res.result_code) == "number", - "resultCode from `30 00` is a " .. type(res.result_code)) - else - assert(err ~= nil, "rejection must carry an error") - end - - -- an OCTET STRING in the resultCode slot is the same class of defect - local res2, err2 = protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 04 01 00 04 00 04 00")) - if res2 then - assert(type(res2.result_code) == "number", - "resultCode from `04 01 00` is a " .. type(res2.result_code)) - else - assert(err2 ~= nil, "rejection must carry an error") - end - - -- proving the consequence: this is what client.lua:103 does with it - local victim = res and res.result_code - if victim ~= nil then - local built = pcall(function() - return "Unknown error occurred (code: " .. victim .. ")" - end) - assert(built, "client error formatter throws on this resultCode") - end + -- 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") } diff --git a/t/asn1_nil_member.t b/t/asn1_nil_member.t index 26da7a2..f6e0a78 100644 --- a/t/asn1_nil_member.t +++ b/t/asn1_nil_member.t @@ -1,4 +1,5 @@ # 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; @@ -27,9 +28,17 @@ __DATA__ local ldap_hex = require("ldap_hex") -- SET { "A", BOOLEAN TRUE }: BOOLEAN has no decoder; slot silently dropped -> {"A"}. - local _, v, err = asn1.decode(ldap_hex("31 06 04 01 41 01 01 ff")) + 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") } @@ -50,19 +59,27 @@ ok local ldap_hex = require("ldap_hex") -- Undecodable member FIRST: currently yields {"A"} at index 1 (member 2's value). - local _, v, err = asn1.decode(ldap_hex("31 06 01 01 ff 04 01 41")) + 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 _, v2, err2 = asn1.decode(ldap_hex("31 03 01 01 ff")) - assert(err2 ~= nil, "SET of one undecodable member must error") - assert(v2 == nil, "no value for an all-undecodable 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 _, v3, err3 = asn1.decode(ldap_hex("31 00")) - assert(err3 == nil, "empty SET is still valid: " .. tostring(err3)) - assert(type(v3) == "table" and #v3 == 0, "empty SET decodes to {}") + 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") } @@ -83,14 +100,27 @@ ok local ldap_hex = require("ldap_hex") -- SEQUENCE { OCTET STRING "A", NULL }: the NULL slot vanishes -> {"A"} - local _, v, err = asn1.decode(ldap_hex("30 05 04 01 41 05 00")) + 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 _, v2, err2 = asn1.decode(ldap_hex("30 02 00 00")) + 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") } diff --git a/t/asn1_oracle.t b/t/asn1_oracle.t index f26e5ba..a8010dc 100644 --- a/t/asn1_oracle.t +++ b/t/asn1_oracle.t @@ -143,117 +143,7 @@ ok --- no_error_log [error] -=== TEST 4: a malformed attribute type returns an error, never an uncaught 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 cases = { - "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 05 00 31 00", -- type = NULL - "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00", -- type = BOOLEAN - } - for _, hex in ipairs(cases) do - local pok, res, err = pcall(protocol.decode_message, ldap_hex(hex)) - assert(pok, "must not raise: " .. hex .. " -> " .. tostring(res)) - assert(res == nil, "must not return a result for " .. hex) - assert(err ~= nil, "must report an error for " .. hex) - end - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 5: LDAPMessage fields are type-checked, not just structurally decoded ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local protocol = require("resty.ldap.protocol") - local ldap_hex = require("ldap_hex") - - -- objectName is an INTEGER -> entry_dn becomes the number 5 - local b1, e1 = protocol.decode_message( - ldap_hex("30 11 02 01 03 64 0c 02 01 05 30 07 30 05 04 01 73 31 00")) - assert(b1 == nil, "non-string objectName must be rejected, got entry_dn=" .. - tostring(b1 and b1.entry_dn)) - assert(e1 ~= nil, "non-string objectName must report an error") - - -- attribute type is an INTEGER -> attributes[5], a numeric key - local b2, e2 = protocol.decode_message( - ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 30 05 02 01 05 31 00")) - assert(b2 == nil, "non-string attribute type must be rejected") - assert(e2 ~= nil, "non-string attribute type must report an error") - - -- attribute type is a SEQUENCE -> attributes[
], an unlookupable key - local b3, e3 = protocol.decode_message( - ldap_hex("30 10 02 01 03 64 0b 04 01 78 30 06 30 04 30 00 31 00")) - assert(b3 == nil, "table-valued attribute type must be rejected") - assert(e3 ~= nil, "table-valued attribute type must report an error") - - -- messageID is an OCTET STRING -> message_id becomes a string - local b4, e4 = protocol.decode_message( - ldap_hex("30 0c 04 01 01 61 07 0a 01 00 04 00 04 00")) - assert(b4 == nil, "non-integer messageID must be rejected, got " .. - type(b4 and b4.message_id)) - assert(e4 ~= nil, "non-integer messageID must report an error") - - -- a well-formed entry is unaffected - local ok = 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(ok.entry_dn == "x", "well-formed entry_dn") - assert(type(ok.attributes.s) == "table", "well-formed attribute key") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 6: SearchResultReference URI list never silently shrinks ---- 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 0a 02 01 02 73 05 04 01 78 05 00")) - if bad then - assert(false, "undecodable referral element must not be dropped, #uris=" .. - tostring(#bad.uris)) - end - assert(err ~= nil, "undecodable referral element must report an error") - - -- a genuine two-URI referral is unaffected - local ok = assert(protocol.decode_message( - ldap_hex("30 0e 02 01 02 73 09 04 01 78 04 04 6c 64 61 70"))) - assert(#ok.uris == 2, "two URIs, got " .. #ok.uris) - assert(ok.uris[1] == "x" and ok.uris[2] == "ldap", "URI values") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 7: differential agreements that must keep holding +=== TEST 4: differential agreements that must keep holding --- http_config eval: $::HttpConfig --- config location /t { diff --git a/t/asn1_vectors.t b/t/asn1_vectors.t index 95345bd..7251c24 100644 --- a/t/asn1_vectors.t +++ b/t/asn1_vectors.t @@ -18,100 +18,7 @@ run_tests(); __DATA__ -=== TEST 1: rasn BER decoder `sequence()` vector must not silently vanish ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - local _, v, err = asn1.decode(ldap_hex("30 0a 16 05 53 6d 69 74 68 01 01 ff")) - assert(err ~= nil, - "SEQUENCE with undecodable members must report an error, got value=" .. - tostring(v) .. " n=" .. (type(v) == "table" and #v or -1)) - assert(v == nil, "no partial value alongside the error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 2: pyasn1 Sequence/Set DefMode vectors must not shrink and re-index ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - local _, seq, err = 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(err ~= nil, - "SEQUENCE containing NULL must error rather than silently shrink; got n=" .. - (type(seq) == "table" and #seq or -1)) - assert(seq == nil, "no partial array alongside the error") - - local _, set, err2 = 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(err2 ~= nil, "SET containing NULL must error rather than silently shrink") - assert(set == nil, "no partial array alongside the error") - - local _, mix, err3 = asn1.decode(ldap_hex("30 09 02 01 01 01 01 ff 02 01 02")) - assert(err3 ~= nil, "undecodable middle member must error, not re-index the array") - assert(mix == nil, "no partial array alongside the error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 3: 9-byte INTEGER overflow must not be reported as -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 _, pos_long, err = asn1.decode(ldap_hex("02 09 00 ff ff ff ff ff ff ff ff")) - assert(err ~= nil, - "INTEGER exceeding the representable range must error; got " .. - tostring(pos_long)) - assert(pos_long ~= -1, "overflow must never be indistinguishable from -1") - - local _, neg_long, err2 = asn1.decode(ldap_hex("02 09 ff 00 00 00 00 00 00 00 01")) - assert(err2 ~= nil, "negative INTEGER overflow must error; got " .. tostring(neg_long)) - assert(neg_long ~= -1, "overflow must never be indistinguishable from -1") - - local _, go_of, err3 = asn1.decode(ldap_hex("02 09 80 00 00 00 00 00 00 00 00")) - assert(err3 ~= nil, "9-byte INTEGER must error; got " .. tostring(go_of)) - - -- The genuine -1 must keep working, so the fix cannot just blanket-ban -1. - local _, minus_one, err4 = asn1.decode(ldap_hex("02 01 ff")) - assert(err4 == nil and minus_one == -1, "a real -1 must still decode as -1") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 4: tags with no decoder must error, never return a silent nil +=== TEST 1: tags with no decoder must error, never return a silent nil --- http_config eval: $::HttpConfig --- config location /t { @@ -148,90 +55,7 @@ ok --- no_error_log [error] -=== TEST 5: dispatch must match tag CLASS, not just tag number ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - -- decode() dispatches on obj.tag alone, so non-universal tags alias universal types. - - local _, ctx4, e1 = asn1.decode(ldap_hex("84 03 61 62 63")) - assert(e1 ~= nil, "context-specific [4] must not decode as OCTET STRING; got " .. - tostring(ctx4)) - assert(ctx4 ~= "abc", "context [4] must not alias universal OCTET STRING") - - local _, priv4, e2 = asn1.decode(ldap_hex("c4 03 61 62 63")) - assert(e2 ~= nil, "private [4] must not decode as OCTET STRING; got " .. tostring(priv4)) - - -- Context [16] / [17] constructed get walked as SEQUENCE / SET. - local _, ctx16, e3 = asn1.decode(ldap_hex("b0 03 02 01 05")) - assert(e3 ~= nil, "context [16] must not be walked as SEQUENCE") - assert(ctx16 == nil, "context [16] must not yield a member array") - local _, ctx17, e4 = asn1.decode(ldap_hex("b1 03 02 01 05")) - assert(e4 ~= nil, "context [17] must not be walked as SET") - assert(ctx17 == nil, "context [17] must not yield a member array") - - -- APPLICATION [16] -- i.e. an LDAP protocolOp -- likewise. - local _, app16, e5 = asn1.decode(ldap_hex("70 03 02 01 05")) - assert(e5 ~= nil, "APPLICATION [16] must not be walked as SEQUENCE") - assert(app16 == nil, "APPLICATION [16] must not yield a member array") - - 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) - - 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") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 6: SEQUENCE/SET decoders must honour the constructed bit ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - local _, prim_seq, e1 = asn1.decode(ldap_hex("10 03 02 01 05")) - assert(e1 ~= nil, "primitive universal tag 16 must be rejected, not walked as SEQUENCE") - assert(prim_seq == nil, "primitive tag 16 must not yield a member array") - - local _, prim_set, e2 = asn1.decode(ldap_hex("11 03 02 01 05")) - assert(e2 ~= nil, "primitive universal tag 17 must be rejected, not walked as SET") - assert(prim_set == nil, "primitive tag 17 must not yield a member array") - - -- A properly constructed SEQUENCE of the same content still decodes. - local _, good, e3 = asn1.decode(ldap_hex("30 03 02 01 05")) - assert(e3 == nil and type(good) == "table" and good[1] == 5, - "constructed SEQUENCE still decodes") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 7: ported vectors that must KEEP working (BER leniency preserved) +=== TEST 2: ported vectors that must KEEP working (BER leniency preserved) --- http_config eval: $::HttpConfig --- config location /t { @@ -302,108 +126,3 @@ GET /t ok --- no_error_log [error] - -=== TEST 8: LDAPMessage envelope must consume the whole packet ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local protocol = require("resty.ldap.protocol") - local ldap_hex = require("ldap_hex") - - local base = "30 0c 02 01 01 61 07 0a 01 00 04 00 04 00" - - local ok_res = assert(protocol.decode_message(ldap_hex(base)), "clean packet still decodes") - assert(ok_res.result_code == 0, "clean packet result_code") - - local r1, e1 = protocol.decode_message(ldap_hex(base .. " de ad be ef")) - assert(r1 == nil, "trailing garbage after the envelope must be rejected") - assert(e1 ~= nil, "trailing garbage must report an error") - - -- A whole second LDAPMessage appended: today only the first is seen. - local r2, e2 = protocol.decode_message( - ldap_hex(base .. " 30 0c 02 01 02 61 07 0a 01 31 04 00 04 00")) - assert(r2 == nil, "a second appended LDAPMessage must be rejected") - assert(e2 ~= nil, "a second appended LDAPMessage must report an error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 9: silent-nil fields must not reach protocol.lua as nil ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local protocol = require("resty.ldap.protocol") - local ldap_hex = require("ldap_hex") - - -- (a) PartialAttribute type NULL not OCTET STRING: atype nil -> attributes[nil] error. - local entry = "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 05 00 31 00" - local called, res, err = pcall(protocol.decode_message, ldap_hex(entry)) - assert(called, "decode_message must not raise a Lua error, got: " .. tostring(res)) - assert(res == nil and err ~= nil, - "an unreadable attribute type must be a returned error") - - -- (b) resultCode BOOLEAN not ENUMERATED: result_code nil -> ldap.lua concat error. - local bad_code = "30 0c 02 01 01 61 07 01 01 ff 04 00 04 00" - local r2, e2 = protocol.decode_message(ldap_hex(bad_code)) - assert(r2 == nil, "a non-ENUMERATED resultCode must be rejected, got a result table") - assert(e2 ~= nil, "a non-ENUMERATED resultCode must report an error") - - -- (c) objectName NULL not OCTET STRING: entry_dn silently becomes nil. - local bad_dn = "30 0f 02 01 03 64 0a 05 00 30 06 30 04 04 00 31 00" - local r3, e3 = protocol.decode_message(ldap_hex(bad_dn)) - assert(r3 == nil, "an unreadable objectName must be rejected, got entry_dn=" .. - tostring(r3 and r3.entry_dn)) - assert(e3 ~= nil, "an unreadable objectName must report an error") - - -- (d) SearchResultReference with an undecodable element: URI list silently shrinks. - local bad_ref = "30 0c 02 01 02 73 07 04 03 61 62 63 05 00" - local r4, e4 = protocol.decode_message(ldap_hex(bad_ref)) - assert(r4 == nil, "an unreadable referral URI must be rejected, got n=" .. - tostring(r4 and r4.uris and #r4.uris)) - assert(e4 ~= nil, "an unreadable referral URI must report an error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 10: INTEGER beyond 2^53 must not be silently rounded ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local asn1 = require("resty.ldap.asn1") - local ldap_hex = require("ldap_hex") - - local _, a, ea = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 00")) - local _, b, eb = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) - assert(eb ~= nil or a ~= b, - "2^53 and 2^53+1 must not both decode to " .. string.format("%.17g", tonumber(b) or 0)) - - local _, mx, emx = asn1.decode(ldap_hex("02 08 7f ff ff ff ff ff ff ff")) - assert(emx ~= nil or mx == 9223372036854775807, - "maxint64 must be exact or refused, got " .. string.format("%.17g", tonumber(mx) or 0)) - - 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 index 7faf026..d2c9e00 100644 --- a/t/decode_hostile.t +++ b/t/decode_hostile.t @@ -1,4 +1,5 @@ # 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; @@ -75,76 +76,7 @@ ok --- no_error_log [error] -=== TEST 3: resultCode must be a number, or decoding must fail ---- 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 = { - { "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" }, - } - for _, c in ipairs(cases) do - local r, e = protocol.decode_message(ldap_hex(c[2])) - if r ~= nil then - assert(type(r.result_code) == "number", - c[1] .. " resultCode yielded " .. type(r.result_code)) - else - assert(e ~= nil, c[1] .. " resultCode: nil result with no error") - end - end - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 4: an undecodable attribute type must error, not raise 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 14 02 01 03 64 0f 04 01 78 30 0a 30 08 01 01 ff 31 03 04 01 76") - local pok, r, e = pcall(protocol.decode_message, pkt) - assert(pok, "malicious attribute type raised a Lua exception: " .. tostring(r)) - assert(r == nil, "undecodable attribute type must not produce a result") - assert(e ~= nil, "undecodable attribute type must report an error") - - -- Same shape, `type` is a SEQUENCE: the key must not become a table. - local pkt2 = ldap_hex("30 13 02 01 03 64 0e 04 01 78 30 09 30 07 30 00 31 03 04 01 76") - local pok2, r2, e2 = pcall(protocol.decode_message, pkt2) - assert(pok2, "SEQUENCE attribute type raised a Lua exception: " .. tostring(r2)) - if r2 ~= nil then - for k in pairs(r2.attributes) do - assert(type(k) == "string", "attribute key is a " .. type(k)) - end - else - assert(e2 ~= nil, "non-string attribute type: nil result with no error") - end - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 5: attribute values are always an array, never a bare string +=== TEST 3: attribute values are always an array, never a bare string --- http_config eval: $::HttpConfig --- config location /t { @@ -177,7 +109,7 @@ ok --- no_error_log [error] -=== TEST 6: a duplicate attribute type is decoded, not treated as a protocol error +=== TEST 4: a duplicate attribute type is decoded, not treated as a protocol error --- http_config eval: $::HttpConfig --- config location /t { @@ -207,40 +139,7 @@ ok --- no_error_log [error] -=== TEST 7: context-specific [4] is not a universal OCTET STRING (class confusion) ---- 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 [4]", "30 0f 02 01 01 61 0a 0a 01 31 84 03 41 42 43 04 00" }, - { "objectName [4]", "30 0c 02 01 03 64 07 84 03 41 42 43 30 00" }, - { "attribute type [4]","30 23 02 01 03 64 1e 04 01 78 30 19 30 17" .. - "84 08 6d 65 6d 62 65 72 4f 66" .. - "31 0b 04 09 63 6e 3d 61 64 6d 69 6e 73" }, - { "attribute val [4]", "30 16 02 01 03 64 11 04 01 78 30 0c 30 0a 04 01 6d 31 05 84 03 41 42 43" }, - { "SearchResRef [4]", "30 0b 02 01 02 73 06 04 01 61 84 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 8: an undecodable SET/SEQUENCE member must error, not shrink the array +=== TEST 5: an undecodable SET/SEQUENCE member must error, not shrink the array --- http_config eval: $::HttpConfig --- config location /t { @@ -276,33 +175,7 @@ ok --- no_error_log [error] -=== TEST 9: an ENUMERATED that overflows a C long must not collapse to -1 ---- 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 14 02 01 01 61 0f 0a 09 00 ff ff ff ff ff ff ff ff 04 00 04 00")) - assert(r == nil or r.result_code ~= -1, - "overflowing ENUMERATED silently reported as resultCode -1") - if r == nil then - assert(e ~= nil, "overflowing ENUMERATED: nil result with no error") - end - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 10: constructed OCTET STRING (0x24) is rejected at every nesting level +=== TEST 6: constructed OCTET STRING (0x24) is rejected at every nesting level --- http_config eval: $::HttpConfig --- config location /t { @@ -333,7 +206,7 @@ ok --- no_error_log [error] -=== TEST 11: PartialAttributeList / PartialAttribute containers must be SEQUENCEs +=== TEST 7: PartialAttributeList / PartialAttribute containers must be SEQUENCEs --- http_config eval: $::HttpConfig --- config location /t { @@ -365,7 +238,7 @@ ok --- no_error_log [error] -=== TEST 12: truncation and byte-flip sweep never raises a Lua exception +=== TEST 8: truncation and byte-flip sweep never raises a Lua exception --- http_config eval: $::HttpConfig --- config location /t { @@ -408,7 +281,7 @@ ok --- no_error_log [error] -=== TEST 13: BER leniency and hard limits are preserved +=== TEST 9: BER leniency and hard limits are preserved --- http_config eval: $::HttpConfig --- config location /t { @@ -466,3 +339,35 @@ GET /t 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_member_count.t b/t/decode_member_count.t deleted file mode 100644 index 5d457b2..0000000 --- a/t/decode_member_count.t +++ /dev/null @@ -1,135 +0,0 @@ -# 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: a dropped vals member is reachable from decode_message (EOC) ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local protocol = require("resty.ldap.protocol") - local ldap_hex = require("ldap_hex") - - -- vals SET holds "a", EOC (00 00), "b"; EOC decodes to silent nil -> must error, not drop - local res, err = protocol.decode_message(ldap_hex( - "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")) - - -- An element that yields no value is an error, not an omission. - assert(res == nil, "entry with an undecodable vals member must not decode") - assert(err ~= nil, "an undecodable vals member must report an error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - - - -=== TEST 2: same silent drop via a BOOLEAN member ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local protocol = require("resty.ldap.protocol") - local ldap_hex = require("ldap_hex") - - -- Middle member is BOOLEAN TRUE (01 01 ff): has encoder but no decoder, same silent-nil path - local res, err = protocol.decode_message(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" .. - " 31 09 04 01 61 01 01 ff 04 01 62")) - - assert(res == nil, "entry with a BOOLEAN vals member must not decode") - assert(err ~= nil, "a BOOLEAN vals member must report an error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - - - -=== TEST 3: SearchResultReference URI list drops members too (separate code path) ---- 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_reference builds uris via its own loop, not decode_children; needs the decode() fix - local res, err = protocol.decode_message(ldap_hex( - "30 0d 02 01 02 73 08 04 01 61 00 00 04 01 62")) - - assert(res == nil, "reference with an undecodable URI member must not decode") - assert(err ~= nil, "an undecodable URI member must report an error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - - - -=== TEST 4: 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_protocol.t b/t/decode_protocol.t index 937c2cc..0930d09 100644 --- a/t/decode_protocol.t +++ b/t/decode_protocol.t @@ -18,43 +18,7 @@ run_tests(); __DATA__ -=== TEST 1: an unrecognized protocolOp must not be parsed as 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") - - -- [APPLICATION 30] is unassigned in RFC 4511 - local bad, err = protocol.decode_message(ldap_hex("30 0c 02 01 01 7e 07 0a 01 00 04 00 04 00")) - assert(bad == nil, "unassigned app tag 30 must not yield an LDAPResult") - assert(err ~= nil, "unassigned app tag 30 must report an error") - - -- UnbindRequest [APPLICATION 2]: client->server NULL-body PDU (RFC 4511 s4.3), never result_code 0 - local bad2, err2 = protocol.decode_message(ldap_hex("30 0c 02 01 01 62 07 0a 01 00 04 00 04 00")) - assert(bad2 == nil, "UnbindRequest must not be reported as result_code 0") - assert(err2 ~= nil, "UnbindRequest must report an error") - - -- SearchRequest [APPLICATION 3], likewise a request PDU - local bad3, err3 = protocol.decode_message(ldap_hex("30 0c 02 01 01 63 07 0a 01 00 04 00 04 00")) - assert(bad3 == nil and err3 ~= nil, "SearchRequest must not decode as a result") - - -- primitive APPLICATION [1] is not well-formed; every protocolOp is constructed - local bad4, err4 = protocol.decode_message(ldap_hex("30 0c 02 01 01 41 07 0a 01 00 04 00 04 00")) - assert(bad4 == nil and err4 ~= nil, "primitive APPLICATION op must be rejected") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 2: every real LDAPResult-shaped op still decodes (allowlist must not overreach) +=== TEST 1: LDAPResult fields must have the right ASN.1 types --- http_config eval: $::HttpConfig --- config location /t { @@ -62,122 +26,33 @@ ok 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 = { - { "61", protocol.APP_NO.BindResponse }, - { "65", protocol.APP_NO.SearchResultDone }, - { "67", protocol.APP_NO.ModifyResponse }, - { "78", protocol.APP_NO.ExtendedResponse }, + { "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 pkt = ldap_hex("30 0c 02 01 01 " .. c[1] .. " 07 0a 01 00 04 00 04 00") - local res, err = protocol.decode_message(pkt) - assert(res ~= nil, "op 0x" .. c[1] .. " must decode: " .. tostring(err)) - assert(res.protocol_op == c[2], "op 0x" .. c[1] .. " tag") - assert(res.result_code == 0, "op 0x" .. c[1] .. " result_code") + 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 - local res = assert(protocol.decode_message( - ldap_hex("30 81 0d 02 01 01 61 81 07 0a 01 00 04 00 04 00"))) - assert(res.protocol_op == protocol.APP_NO.BindResponse, "non-minimal op") - assert(res.result_code == 0, "non-minimal result_code") - assert(res.matched_dn == "" and res.diagnostic_msg == "", "non-minimal fields") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 3: 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") - - local bad, err = protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 01 01 ff 04 00 04 00")) - assert(bad == nil, "non-numeric resultCode must not produce a result table") - assert(err ~= nil, "non-numeric resultCode must report an error") - - -- OCTET STRING resultCode becomes "\0", compares ~= 0, so a successful bind reads as failure - local bad2, err2 = protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 04 01 00 04 00 04 00")) - assert(bad2 == nil and err2 ~= nil, "OCTET STRING resultCode must be rejected") - -- 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") - -- messageID is INTEGER (RFC 4511 s4.1.1); an OCTET STRING here yields message_id == "A" - local bad4, err4 = protocol.decode_message(ldap_hex("30 0c 04 01 41 61 07 0a 01 00 04 00 04 00")) - assert(bad4 == nil and err4 ~= nil, "non-integer messageID must be rejected") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 4: a non-string attribute type must error, never index the table with nil ---- 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 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00") - local ok, res, err = pcall(protocol.decode_message, pkt) - assert(ok, "decode_message must not raise: " .. tostring(res)) - assert(res == nil, "nil attribute type must not produce a result") - assert(err ~= nil, "nil attribute type must report an error") - - -- `type` is an INTEGER -> a numeric key lands in the attributes table - local pkt2 = ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 30 05 02 01 07 31 00") - local ok2, res2, err2 = pcall(protocol.decode_message, pkt2) - assert(ok2, "decode_message must not raise on numeric type") - assert(res2 == nil and err2 ~= nil, "numeric attribute type must be rejected") - - -- objectName is an LDAPDN (OCTET STRING); an INTEGER here yields entry_dn == 9 - local bad3, err3 = protocol.decode_message(ldap_hex("30 0a 02 01 03 64 05 02 01 09 30 00")) - assert(bad3 == nil and err3 ~= nil, "non-string objectName must be rejected") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 5: PartialAttributeList and PartialAttribute must be constructed 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 bad, err = protocol.decode_message( - ldap_hex("30 14 02 01 03 64 0f 04 01 78 04 0a 30 08 04 01 73 31 03 04 01 61")) - assert(bad == nil, "PartialAttributeList must be a constructed SEQUENCE") - assert(err ~= nil, "non-SEQUENCE attribute list must report an error") - - -- primitive OCTET STRING walked as if it were a PartialAttribute SEQUENCE - local bad2, err2 = protocol.decode_message( - ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 04 05 04 01 73 31 00")) - assert(bad2 == nil, "PartialAttribute must be a constructed SEQUENCE") - assert(err2 ~= nil, "non-SEQUENCE PartialAttribute must report an error") + -- 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") } @@ -189,38 +64,7 @@ ok --- no_error_log [error] -=== TEST 6: attribute values are always an array (vals must be a SET) ---- 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 12 02 01 03 64 0d 04 01 78 30 08 30 06 04 01 73 04 01 41")) - assert(bad == nil, "vals that is not a SET must be rejected") - assert(err ~= nil, "non-SET vals must report an error") - - -- well-formed shape and typesOnly empty SET still work (SET wrapping adds 2 bytes per level) - local ok1 = assert(protocol.decode_message( - ldap_hex("30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 04 01 73 31 03 04 01 41"))) - assert(type(ok1.attributes.s) == "table" and ok1.attributes.s[1] == "A", "well-formed vals") - local ok2 = 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(ok2.attributes.s) == "table" and #ok2.attributes.s == 0, "empty vals SET") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 7: a repeated attribute type still decodes to a well-formed attribute map +=== TEST 2: a repeated attribute type still decodes to a well-formed attribute map --- http_config eval: $::HttpConfig --- config location /t { @@ -251,63 +95,3 @@ GET /t ok --- no_error_log [error] - -=== TEST 8: bytes left over after a parsed element are never 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 bad, err = protocol.decode_message(ldap_hex("30 0d 02 01 03 64 08 04 01 78 30 00 04 01 5a")) - assert(bad == nil, "trailing bytes inside SearchResultEntry must be rejected") - assert(err ~= nil, "trailing bytes inside the op must report an error") - - -- same at the envelope level: the LDAPMessage SEQUENCE must consume the whole packet - local bad2, err2 = protocol.decode_message( - ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00 de ad be ef")) - assert(bad2 == nil, "trailing bytes after the envelope must be rejected") - assert(err2 ~= nil, "trailing bytes after the envelope must report an error") - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 9: SearchResultReference URIs are never silently dropped or non-strings ---- 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 0c 02 01 02 73 07 01 01 ff 04 02 6f 6b")) - assert(bad == nil, "an undecodable referral element must not vanish") - assert(err ~= nil, "an undecodable referral element must report an error") - - -- nested SEQUENCE lands a table in uris[1]; URI (RFC 4511 s4.1.10) is an OCTET STRING on the wire - local bad2, err2 = protocol.decode_message(ldap_hex("30 09 02 01 02 73 04 30 02 04 00")) - assert(bad2 == nil, "non-string referral URI must be rejected") - assert(err2 ~= nil, "non-string referral URI must report an error") - - -- well-formed referrals still decode - local ok1 = assert(protocol.decode_message( - ldap_hex("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78"))) - assert(#ok1.uris == 1 and ok1.uris[1] == "ldap://x", "well-formed referral") - - 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 index 3ee9ffc..90bb5eb 100644 --- a/t/decode_rfc4511.t +++ b/t/decode_rfc4511.t @@ -181,6 +181,15 @@ ok "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") @@ -203,52 +212,40 @@ ok local protocol = require("resty.ldap.protocol") local ldap_hex = require("ldap_hex") - local pkt = ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00") - local ok, res, err = pcall(protocol.decode_message, pkt) - assert(ok, "decode_message raised instead of returning an error: " .. tostring(res)) - assert(res == nil, "undecodable attribute type must not yield a result") - assert(err ~= nil, "undecodable attribute type must report an error") - - -- non-string AttributeDescription (INTEGER 5) keys attributes by number; must be LDAPString (RFC 4511 s4.1.4) - local ok2, res2, err2 = pcall(protocol.decode_message, - ldap_hex("30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 02 01 05 31 03 04 01 41")) - assert(ok2, "non-string attribute type raised: " .. tostring(res2)) - assert(res2 == nil and err2 ~= nil, - "attribute type that is not an LDAPString must be rejected") - - -- objectName that is not an LDAPDN (INTEGER 5) must be an error, not entry_dn == 5 - local ok3, res3, err3 = pcall(protocol.decode_message, ldap_hex("30 0a 02 01 03 64 05 02 01 05 30 00")) - assert(ok3, "non-string objectName raised: " .. tostring(res3)) - assert(res3 == nil and err3 ~= nil, "objectName must be an LDAPDN, got " .. tostring(res3 and res3.entry_dn)) - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - -=== TEST 5: a missing resultCode or messageID must fail loudly, not become nil ---- 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 0c 02 01 01 61 07 01 01 ff 04 00 04 00")) - assert(bad == nil, "resultCode must be a number; got result_code=" .. - tostring(bad and bad.result_code)) - assert(err ~= nil, "undecodable resultCode must report an error") - - -- messageID as a BOOLEAN: a nil message_id breaks request/response correlation (RFC 4511 s4.1.1) - local bad2, err2 = protocol.decode_message(ldap_hex("30 0c 01 01 ff 61 07 0a 01 00 04 00 04 00")) - assert(bad2 == nil, "messageID must be an INTEGER; got message_id=" .. - tostring(bad2 and bad2.message_id)) - assert(err2 ~= nil, "undecodable messageID must report an error") + -- 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") } @@ -260,7 +257,7 @@ ok --- no_error_log [error] -=== TEST 6: SearchResultReference must not silently drop an undecodable URI +=== TEST 5: SearchResultReference must not silently drop an undecodable URI --- http_config eval: $::HttpConfig --- config location /t { @@ -268,13 +265,25 @@ ok local protocol = require("resty.ldap.protocol") local ldap_hex = require("ldap_hex") - local bad, err = protocol.decode_message(ldap_hex([[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]])) - assert(bad == nil, "reference with an undecodable member must be rejected; got " .. - tostring(bad and #bad.uris) .. " uris") - assert(err ~= nil, "reference with an undecodable member must report an error") + -- 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 @@ -294,7 +303,7 @@ ok --- no_error_log [error] -=== TEST 7: PartialAttributeList containers must actually be SEQUENCEs +=== TEST 6: PartialAttributeList containers must actually be SEQUENCEs --- http_config eval: $::HttpConfig --- config location /t { @@ -340,32 +349,3 @@ GET /t ok --- no_error_log [error] - -=== TEST 8: an out-of-range messageID must not be silently coerced to -1 ---- 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 14 02 09 00 ff ff ff ff ff ff ff ff 61 07 0a 01 00 04 00 04 00")) - assert(bad == nil, "out-of-range messageID must be rejected; got message_id=" .. - tostring(bad and bad.message_id)) - assert(err ~= nil, "out-of-range messageID must report an error") - - -- a genuine large-but-legal messageID still works: 2147483647 == maxInt - local r = assert(protocol.decode_message( - ldap_hex("30 0f 02 04 7f ff ff ff 61 07 0a 01 00 04 00 04 00"))) - assert(r.message_id == 2147483647, "maxInt messageID " .. tostring(r.message_id)) - - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] From 2b6e55252729383c6ab311c1258251a2715b1db0 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 22 Jul 2026 19:41:10 +0800 Subject: [PATCH 21/27] feat: enhance ldap_authenticate to return canonical user DN and improve TLS connection handling --- .github/workflows/ci.yml | 10 ++++-- README.md | 4 ++- lib/resty/ldap.lua | 18 +++++++--- lib/resty/ldap/client.lua | 27 +++++++++++---- t/client.t | 70 +++++++++++++++++++++++++++++++++++++++ t/compat_ldap.t | 50 ++++++++++++++++++++++++++-- 6 files changed, 164 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e11a6bc..1e16cf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,14 @@ 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 bitnamilegacy/openldap:2.6.10-debian-12-r4 - for _ in $(seq 1 30); do - 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 && break + 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 docker cp t/fixtures/ad.ldif openldap:/tmp/ad.ldif diff --git a/README.md b/README.md index 2b97c1c..ba79ef8 100644 --- a/README.md +++ b/README.md @@ -115,12 +115,14 @@ Compatibility entrypoint kept for APISIX's `ldap-auth` plugin (restored from v0. #### ldap_authenticate -**syntax:** *ok, err = ldap.ldap_authenticate(username, password, conf)* +**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; otherwise `ok` is `false` or `nil` and `err` carries the error. `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 | diff --git a/lib/resty/ldap.lua b/lib/resty/ldap.lua index 5264023..24383e4 100644 --- a/lib/resty/ldap.lua +++ b/lib/resty/ldap.lua @@ -82,11 +82,20 @@ function _M.ldap_authenticate(given_username, given_password, conf) local opts - -- 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 + -- tls_verify=false connection must never serve one that demands verification. + local pool_suffix = "" if conf.start_tls then + pool_suffix = ":starttls" + elseif conf.ldaps then + pool_suffix = ":ldaps" + end + if pool_suffix ~= "" then + pool_suffix = pool_suffix .. + (resolve_tls_verify(conf) and ":verify" or ":noverify") opts = { - pool = conf.ldap_host .. ":" .. conf.ldap_port .. ":starttls" + pool = conf.ldap_host .. ":" .. conf.ldap_port .. pool_suffix } end @@ -136,7 +145,8 @@ function _M.ldap_authenticate(given_username, given_password, conf) tostring(conf.ldap_port), ": ", suppressed_err) end - return is_authenticated, err + -- who: the canonical escaped bind DN, for callers that key identity on it + return is_authenticated, err, who end diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 4ffc91b..0da787d 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -84,12 +84,14 @@ local function _start_tls(sock) local packet = packet_header .. 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 @@ -97,6 +99,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 "") @@ -111,16 +114,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) @@ -223,7 +238,7 @@ local function _send_recieve(cli, request, multi_resp_hint) -- this error is considered unacceptable and therefore an error is -- returned directly instead of processing the received data. _reset_socket(cli) - return nil, err + return nil, fmt("receive response failed: %s", err) end local packet = packet_header .. packet diff --git a/t/client.t b/t/client.t index 60a1815..5f03ab2 100644 --- a/t/client.t +++ b/t/client.t @@ -176,3 +176,73 @@ GET /t 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] diff --git a/t/compat_ldap.t b/t/compat_ldap.t index b3d4f5a..b1cda3f 100644 --- a/t/compat_ldap.t +++ b/t/compat_ldap.t @@ -204,7 +204,7 @@ ok 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 = ldap.ldap_authenticate("al,ice", "password1", { + 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", @@ -213,6 +213,8 @@ ok assert(ok == false, "an unknown escaped username must fail cleanly") 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") } } @@ -231,13 +233,15 @@ ok location /t { content_by_lua_block { local ldap = require("resty.ldap") - local ok, err = ldap.ldap_authenticate("user01", "password1", { + 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") } } @@ -273,3 +277,45 @@ GET /t ok --- no_error_log [error] + + + +=== TEST 10: a pooled unverified connection is never reused when tls_verify=true +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap") + + -- pool a connection established with verification disabled + 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)) + + -- reusing the pooled connection would skip verification and bind; + -- a fresh connection must fail (no trusted certificate 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 == false, "the unverified pooled connection must not be reused") + assert(err2:find("SSL handshake", 1, true), "unexpected err: " .. tostring(err2)) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- error_log +lua tls certificate verify error From c57a8d3de7af1572dca7faeceb28ddcdecddcd28 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 22 Jul 2026 19:50:11 +0800 Subject: [PATCH 22/27] test: tolerate CI environment differences Loosen the TLS verify error_log pattern to a substring stable across lua-nginx-module versions, and raise the client timeout on the encode leak tests, which exceed Test::Nginx's 3s default on slow runners. --- t/asn1_encode_leak.t | 2 ++ t/compat_ldap.t | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/t/asn1_encode_leak.t b/t/asn1_encode_leak.t index 416361e..1191f02 100644 --- a/t/asn1_encode_leak.t +++ b/t/asn1_encode_leak.t @@ -94,6 +94,7 @@ __DATA__ } --- request GET /t +--- timeout: 60 --- response_body ok --- no_error_log @@ -151,6 +152,7 @@ ok } --- request GET /t +--- timeout: 60 --- response_body ok --- no_error_log diff --git a/t/compat_ldap.t b/t/compat_ldap.t index b1cda3f..5a6bf5c 100644 --- a/t/compat_ldap.t +++ b/t/compat_ldap.t @@ -41,7 +41,7 @@ GET /t --- response_body ok --- error_log -lua tls certificate verify error +certificate verify error @@ -96,7 +96,7 @@ GET /t --- response_body ok --- error_log -lua tls certificate verify error +certificate verify error @@ -318,4 +318,4 @@ GET /t --- response_body ok --- error_log -lua tls certificate verify error +certificate verify error From ef1f64dae5dafc1b3f199027029d5fe7909c1698 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 22 Jul 2026 23:55:15 +0800 Subject: [PATCH 23/27] fix: replace NUL-delimited response reader with plain receive LDAP framing is BER length-driven; receiveuntil("\0") silently consumed 0x00 bytes at the header position and crashed calculate_payload_length on hostile responses. --- lib/resty/ldap/client.lua | 7 +------ t/client.t | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 0da787d..7e2c911 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -5,7 +5,6 @@ local tostring = tostring local fmt = string.format local tcp = ngx.socket.tcp local table_insert = table.insert -local string_char = string.char local decode_ldap = protocol.decode_message -- Upper bound on a single LDAP message body (bytes). A well-formed length such @@ -203,10 +202,6 @@ local function _send_recieve(cli, request, multi_resp_hint) 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 @@ -216,7 +211,7 @@ 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 _reset_socket(cli) diff --git a/t/client.t b/t/client.t index 5f03ab2..fcf9d35 100644 --- a/t/client.t +++ b/t/client.t @@ -246,3 +246,45 @@ GET /t 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 == false, "a 0x00 header byte must fail, 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] From bf897a5db24cb1b88720acde98c757b5f8719c1d Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 22 Jul 2026 23:55:15 +0800 Subject: [PATCH 24/27] ci: fail fast when OpenLDAP does not become ready --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e16cf6..3a25c05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,11 @@ jobs: 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 From 7b1436f5f09c38b17edbb27cfb239e76d48abb1c Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Thu, 23 Jul 2026 14:39:42 +0800 Subject: [PATCH 25/27] fix: never pool bound LDAP sockets; wrap message IDs at maxInt A Bind pins an identity to the connection, but the keepalive pool key only encodes endpoint and TLS policy, so a pooled bound socket could serve a later client's requests under the previous identity. Track bind state and close such sockets on release instead of pooling them. Both module-level message ID counters now wrap back to 1 past RFC 4511 maxInt. Also renames the file-local _send_recieve to _send_receive. --- lib/resty/ldap.lua | 12 +++-- lib/resty/ldap/client.lua | 26 ++++++++--- lib/resty/ldap/ldap.lua | 15 +++++-- lib/resty/ldap/protocol.lua | 5 +++ t/asn1_encode.t | 35 ++++++++++++++- t/client.t | 90 +++++++++++++++++++++++++++++++++++++ t/compat_ldap.t | 43 +++++++++++++++++- t/lib/ldap_msgid.lua | 8 ++++ t/lib/upvalue.lua | 29 ++++++++++++ t/patch/unknown_op.patch | 2 +- t/search.t | 10 +++++ t/session.t | 40 +++++++++++++++++ 12 files changed, 299 insertions(+), 16 deletions(-) create mode 100644 t/lib/ldap_msgid.lua create mode 100644 t/lib/upvalue.lua diff --git a/lib/resty/ldap.lua b/lib/resty/ldap.lua index 24383e4..4250acf 100644 --- a/lib/resty/ldap.lua +++ b/lib/resty/ldap.lua @@ -139,10 +139,14 @@ function _M.ldap_authenticate(given_username, given_password, conf) return nil, err end - ok, suppressed_err = sock:setkeepalive(conf.keepalive) - if not ok then - log(ERR, "failed to keepalive to ", conf.ldap_host, ":", - tostring(conf.ldap_port), ": ", suppressed_err) + if is_authenticated then + sock:close() + else + ok, suppressed_err = sock:setkeepalive(conf.keepalive) + if not ok then + log(ERR, "failed to keepalive to ", conf.ldap_host, ":", + tostring(conf.ldap_port), ": ", suppressed_err) + end end -- who: the canonical escaped bind DN, for callers that key identity on it diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 7e2c911..b753309 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -178,17 +178,19 @@ 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_recieve(cli, request, multi_resp_hint) +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 @@ -264,9 +266,15 @@ local function _send_recieve(cli, request, multi_resp_hint) end -- single-shot ops return the socket to the pool; a pinned session is - -- released explicitly by the caller + -- 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 - socket:setkeepalive(cli.socket_config.keepalive_timeout) + 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] @@ -326,12 +334,17 @@ function _M.set_keepalive(self) 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 @@ -343,13 +356,14 @@ end function _M.simple_bind(self, dn, password) -- bind to a local first: as the last call argument a (nil, err) return would - -- expand into _send_recieve's multi_resp_hint and send a nil request + -- expand into _send_receive's multi_resp_hint and send a nil request local req, berr = protocol.simple_bind_request(dn, password) if not req then return false, berr end - local res, err = _send_recieve(self, req) + self.bound = true + local res, err = _send_receive(self, req) if not res then return false, err end @@ -388,7 +402,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/ldap.lua b/lib/resty/ldap/ldap.lua index 8219fb5..7b960d9 100644 --- a/lib/resty/ldap/ldap.lua +++ b/lib/resty/ldap/ldap.lua @@ -12,6 +12,15 @@ local _M = {} local ldapMessageId = 1 +local LDAP_MAX_INT = 2147483647 + +local function advance_message_id() + ldapMessageId = ldapMessageId + 1 + if ldapMessageId > LDAP_MAX_INT then + ldapMessageId = 1 + end +end + local ERROR_MSG = { [1] = "Initialization of LDAP library failed.", @@ -96,7 +105,7 @@ function _M.bind_request(socket, username, password) local packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) - ldapMessageId = ldapMessageId + 1 + advance_message_id() local bytes, err = socket:send(packet) if not bytes then @@ -151,7 +160,7 @@ end function _M.unbind_request(socket) local ldapMsg, packet - ldapMessageId = ldapMessageId + 1 + advance_message_id() ldapMsg = asn1_encode(ldapMessageId) .. asn1_put_object(APPNO.UnbindRequest, asn1.CLASS.APPLICATION, 0) @@ -170,7 +179,7 @@ end function _M.start_tls(socket) local method_name = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, "1.3.6.1.4.1.1466.20037") - ldapMessageId = ldapMessageId + 1 + advance_message_id() local ldapMsg = asn1_encode(ldapMessageId) .. asn1_put_object(APPNO.ExtendedRequest, asn1.CLASS.APPLICATION, 1, method_name) diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index b138c57..04933ba 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -11,6 +11,8 @@ local _M = {} local ldapMessageId = 1 +local LDAP_MAX_INT = 2147483647 + _M.ERROR_MSG = { [1] = "Initialization of LDAP library failed", [4] = "Size limit exceeded", @@ -39,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 diff --git a/t/asn1_encode.t b/t/asn1_encode.t index 338473a..61f410e 100644 --- a/t/asn1_encode.t +++ b/t/asn1_encode.t @@ -9,7 +9,7 @@ repeat_each(1); plan 'no_plan'; our $HttpConfig = <<'_EOC_'; - lua_package_path 'lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; resolver 127.0.0.53; _EOC_ @@ -314,3 +314,36 @@ GET /t 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/client.t b/t/client.t index fcf9d35..5ef8518 100644 --- a/t/client.t +++ b/t/client.t @@ -288,3 +288,93 @@ GET /t 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 the socket of a successful 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", + } + + -- control: a failed bind leaves the session anonymous (RFC 4513 s4), + -- so its socket is pooled, observable on the default host:port pool + local ok = ldap.ldap_authenticate("user01", "wrong-password", conf) + assert(ok == false, "a wrong password must fail") + local probe = ngx.socket.tcp() + assert(probe:connect("127.0.0.1", 1389)) + assert(probe:getreusedtimes() > 0, "failed-bind socket should be pooled") + probe:close() + + -- a successful bind leaves the socket holding the user's identity, + -- so it 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] diff --git a/t/compat_ldap.t b/t/compat_ldap.t index 5a6bf5c..aa4ef29 100644 --- a/t/compat_ldap.t +++ b/t/compat_ldap.t @@ -7,7 +7,7 @@ repeat_each(1); plan 'no_plan'; our $HttpConfig = <<'_EOC_'; - lua_package_path 'lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; resolver 127.0.0.53; _EOC_ @@ -319,3 +319,44 @@ GET /t ok --- error_log certificate verify error + + + +=== TEST 11: bind_request message IDs wrap back to 1 past RFC 4511 maxInt +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local ldap = require("resty.ldap.ldap") + local upvalue = require("upvalue") + local message_id_of = require("ldap_msgid") + + -- the counter is module-private; set it through bind_request's + -- upvalue cell, which all three request builders share + assert(upvalue.set(ldap.bind_request, "ldapMessageId", 2147483647)) + + local sent + local sock = { + send = function(_, p) sent = p return #p end, + receive = function() return nil, "closed" end, + close = function() return true end, + } + + -- the mock cuts the response off, but the request is sent (and the + -- counter advanced) before the receive + ldap.bind_request(sock, "cn=x,dc=example,dc=org", "pw") + assert(message_id_of(sent) == 2147483647, + "the request at maxInt itself is legal") + ldap.bind_request(sock, "cn=x,dc=example,dc=org", "pw") + local id = message_id_of(sent) + 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/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 54b5050..2c3a21c 100644 --- a/t/search.t +++ b/t/search.t @@ -588,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 @@ -609,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 @@ -662,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 @@ -683,6 +691,8 @@ GET /t assert(res.protocol_op == 7, "protocol_op is not equal to 7, " .. res.protocol_op) assert(res.result_code == 0, "result_code is not equal to 0, " .. res.result_code) + + client:close() } } --- request diff --git a/t/session.t b/t/session.t index 65ee1ea..e081adb 100644 --- a/t/session.t +++ b/t/session.t @@ -153,3 +153,43 @@ GET /t 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] From 89dd1b085dc5fba07b7e3cbd6a0e2ab18fc93740 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Thu, 23 Jul 2026 17:12:27 +0800 Subject: [PATCH 26/27] ci: pin actions/checkout to a commit hash --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a25c05..19fa574 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: OPENRESTY_PREFIX: "/usr/local/openresty" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: get dependencies run: | From b665ffa91a2671e81158e4c1a445530442b8bc7e Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Thu, 23 Jul 2026 17:12:27 +0800 Subject: [PATCH 27/27] refactor: rebuild resty.ldap on client and drop resty.ldap.ldap resty.ldap.ldap duplicated the client transport stack line for line (calculate_payload_length, bind/start_tls send-receive loops, error tables, message-id counter). ldap_authenticate is now a thin wrapper over resty.ldap.client, leaving two entrypoints: resty.ldap.client (the API) and resty.ldap (compat facade for APISIX ldap-auth). - a socket that carried a Bind is always closed, never pooled, whether the bind succeeded or failed - client:simple_bind returns nil on transport/decode failures and false only when the server rejects the bind, so the facade keeps its nil=unreachable / false=rejected contract - header-timeout and truncated-body seams ported to client.t; the dead-socket and message-id-wrap seams were already covered by session.t and asn1_encode.t --- README.md | 11 +- lib/resty/ldap.lua | 87 ++------ lib/resty/ldap/client.lua | 4 +- lib/resty/ldap/ldap.lua | 240 ----------------------- rockspec/lua-resty-ldap-0.3.0-0.rockspec | 1 - rockspec/lua-resty-ldap-local-0.rockspec | 1 - rockspec/lua-resty-ldap-main-0.rockspec | 1 - t/client.t | 96 ++++++++- t/compat_ldap.t | 172 ++-------------- 9 files changed, 134 insertions(+), 479 deletions(-) delete mode 100644 lib/resty/ldap/ldap.lua diff --git a/README.md b/README.md index ba79ef8..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 @@ -107,7 +107,7 @@ Closes a pinned connection without returning it to the pool. ### resty.ldap -Compatibility entrypoint kept for APISIX's `ldap-auth` plugin (restored from v0.1.0). New code should use `resty.ldap.client` instead. +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" @@ -117,7 +117,7 @@ Compatibility entrypoint kept for APISIX's `ldap-auth` plugin (restored from v0. **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; otherwise `ok` is `false` or `nil` and `err` carries the error. +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. @@ -130,7 +130,7 @@ Binds against the directory as `=,` with the given | `ldap_host` | string | localhost | LDAP server host. | | `ldap_port` | number | 389 | LDAP server port. | | `timeout` | number | 10000 | Socket timeout in milliseconds. | -| `keepalive` | number | 60000 | Connection pool keepalive 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. | @@ -138,6 +138,3 @@ Binds against the directory as `=,` with the given | `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. | -### resty.ldap.ldap - -Low-level compatibility module used by `resty.ldap` (restored from v0.1.0): `bind_request`, `unbind_request` and `start_tls` over a caller-supplied cosocket. New code should use `resty.ldap.client` instead. diff --git a/lib/resty/ldap.lua b/lib/resty/ldap.lua index 4250acf..0583026 100644 --- a/lib/resty/ldap.lua +++ b/lib/resty/ldap.lua @@ -1,11 +1,9 @@ local log = ngx.log local ERR = ngx.ERR -local ldap = require "resty.ldap.ldap" +local client = require "resty.ldap.client" -local tostring = tostring -local fmt = string.format -local tcp = ngx.socket.tcp +local tostring = tostring local default_conf = { timeout = 10000, @@ -65,7 +63,7 @@ local _M = {} function _M.ldap_authenticate(given_username, given_password, conf) set_conf_default_values(conf) - -- same coercion contract as ldap.bind_request + -- same coercion contract as protocol.simple_bind_request if type(given_username) == "number" then given_username = tostring(given_username) end @@ -73,84 +71,35 @@ function _M.ldap_authenticate(given_username, given_password, conf) return false, "bind username must be a string" end - local is_authenticated - local err, suppressed_err, ok, _ + 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 sock = tcp() - - sock:settimeout(conf.timeout) - - local opts - - -- Partition the pool by transport mode and verification policy: - -- sslhandshake() returns immediately on a reused connection, so a pooled - -- tls_verify=false connection must never serve one that demands verification. - local pool_suffix = "" - if conf.start_tls then - pool_suffix = ":starttls" - elseif conf.ldaps then - pool_suffix = ":ldaps" - end - if pool_suffix ~= "" then - pool_suffix = pool_suffix .. - (resolve_tls_verify(conf) and ":verify" or ":noverify") - opts = { - pool = conf.ldap_host .. ":" .. conf.ldap_port .. pool_suffix - } - end - - ok, err = sock:connect(conf.ldap_host, conf.ldap_port, opts) + 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 - if conf.start_tls then - -- convert connection to a STARTTLS connection only if it is a new connection - local count, err = sock:getreusedtimes() - if not count then - -- connection was closed, just return instead - return nil, err - end - - if count == 0 then - local ok, err = ldap.start_tls(sock) - if not ok then - return nil, err - end - end - end - - if conf.start_tls or conf.ldaps then - _, err = sock:sslhandshake(true, conf.ldap_host, resolve_tls_verify(conf)) - if err ~= nil then - return false, fmt("failed to do SSL handshake with %s:%s: %s", - conf.ldap_host, tostring(conf.ldap_port), err) - end - end - local who = conf.attribute .. "=" .. escape_dn_value(given_username) .. "," .. conf.base_dn - is_authenticated, err = ldap.bind_request(sock, who, given_password) + local is_authenticated, bind_err = cli:simple_bind(who, given_password) - if is_authenticated == nil then - -- transport failure; bind_request already closed the socket - return nil, err - end + -- the socket carried a Bind attempt: always close it, never pool it + cli:close() - if is_authenticated then - sock:close() - else - ok, suppressed_err = sock:setkeepalive(conf.keepalive) - if not ok then - log(ERR, "failed to keepalive to ", conf.ldap_host, ":", - tostring(conf.ldap_port), ": ", suppressed_err) - end + 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, err, who + return is_authenticated, bind_err, who end diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index b753309..e29edfd 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -365,7 +365,9 @@ function _M.simple_bind(self, dn, password) 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 diff --git a/lib/resty/ldap/ldap.lua b/lib/resty/ldap/ldap.lua deleted file mode 100644 index 7b960d9..0000000 --- a/lib/resty/ldap/ldap.lua +++ /dev/null @@ -1,240 +0,0 @@ -local asn1 = require("resty.ldap.asn1") -local protocol = require("resty.ldap.protocol") -local bunpack = require("lua_pack").unpack -local fmt = string.format -local decode_ldap = protocol.decode_message -local asn1_put_object = asn1.put_object -local asn1_encode = asn1.encode - - -local _M = {} - - -local ldapMessageId = 1 - -local LDAP_MAX_INT = 2147483647 - -local function advance_message_id() - ldapMessageId = ldapMessageId + 1 - if ldapMessageId > LDAP_MAX_INT then - ldapMessageId = 1 - end -end - - -local ERROR_MSG = { - [1] = "Initialization of LDAP library failed.", - [4] = "Size limit exceeded.", - [13] = "Confidentiality required", - [32] = "No such object", - [34] = "Invalid DN", - [49] = "The supplied credential is invalid." -} - - -local APPNO = { - BindRequest = 0, - BindResponse = 1, - UnbindRequest = 2, - ExtendedRequest = 23, - ExtendedResponse = 24 -} - - --- A legal length such as 84 7f ff ff ff would force a multi-GiB allocation in --- the worker. 16 MiB comfortably exceeds any real entry. -local MAX_LDAP_MESSAGE_SIZE = 16 * 1024 * 1024 - - --- Returns the body length and the header bytes, or nil plus an error. Both --- callers receive() the length while still in cleartext, so validate it here. -local function calculate_payload_length(encStr, pos, socket) - local elen - - pos, elen = bunpack(encStr, "C", pos) - - -- 0x80 is the indefinite form and 0xff is reserved; RFC 4511 s5.1 requires - -- the definite form. Neither is a 128-byte short-form length. - 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 _ = 1, elen do - elenCalc = elenCalc * 256 - 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 - - elen = elenCalc - end - - if elen > MAX_LDAP_MESSAGE_SIZE then - return nil, nil, fmt("ldap message too large: %d bytes", elen) - end - - return elen, encStr -end - - -function _M.bind_request(socket, username, password) - -- pin the LDAPString type: put_object rejects a non-string payload, so - -- catch it here rather than as a concat error on the next line - if type(username) == "number" then username = tostring(username) end - if type(password) == "number" then password = tostring(password) end - if type(username) ~= "string" then - return false, "bind username must be a string" - end - if type(password) ~= "string" then - return false, "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(username) .. ldapAuth - local ldapMsg = asn1_encode(ldapMessageId) .. - asn1_put_object(APPNO.BindRequest, asn1.CLASS.APPLICATION, 1, bindReq) - - local packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) - - advance_message_id() - - local bytes, err = socket:send(packet) - if not bytes then - socket:close() - return nil, fmt("send bind request failed: %s", err or "closed") - end - - local header, herr = socket:receive(2) - if not header then - socket:close() - return nil, fmt("receive bind response header failed: %s", herr or "closed") - end - - local packet_len, packet_header, lerr = calculate_payload_length(header, 2, socket) - if not packet_len then - socket:close() - return nil, lerr - end - - local body, berr = socket:receive(packet_len) - if not body then - socket:close() - return nil, fmt("receive bind response body failed: %s", berr or "closed") - end - - -- decode_message expects the full LDAPMessage, envelope header included - local res, derr = decode_ldap(packet_header .. body) - if derr then - socket:close() - return nil, "Invalid LDAP message encoding: " .. derr - end - - if res.protocol_op ~= APPNO.BindResponse then - socket:close() - return nil, fmt("Received incorrect Op in packet: %d, expected %d", - res.protocol_op, APPNO.BindResponse) - end - - if res.result_code ~= 0 then - local error_msg = ERROR_MSG[res.result_code] - - return false, fmt("\n Error: %s\n Details: %s", - error_msg or "Unknown error occurred (code: " .. - res.result_code .. ")", res.diagnostic_msg or "") - - else - return true - end -end - - -function _M.unbind_request(socket) - local ldapMsg, packet - - advance_message_id() - - ldapMsg = asn1_encode(ldapMessageId) .. - asn1_put_object(APPNO.UnbindRequest, asn1.CLASS.APPLICATION, 0) - packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) - - local bytes, err = socket:send(packet) - if not bytes then - socket:close() - return nil, fmt("send unbind request failed: %s", err or "closed") - end - - return true, "" -end - - -function _M.start_tls(socket) - local method_name = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, "1.3.6.1.4.1.1466.20037") - - advance_message_id() - - local ldapMsg = asn1_encode(ldapMessageId) .. - asn1_put_object(APPNO.ExtendedRequest, asn1.CLASS.APPLICATION, 1, method_name) - - local packet = asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) - - local bytes, err = socket:send(packet) - if not bytes then - socket:close() - return false, fmt("send STARTTLS request failed: %s", err or "closed") - end - - local header, herr = socket:receive(2) - if not header then - socket:close() - return false, fmt("receive STARTTLS response header failed: %s", herr or "closed") - end - - local packet_len, packet_header, lerr = calculate_payload_length(header, 2, socket) - if not packet_len then - socket:close() - return false, lerr - end - - local body, berr = socket:receive(packet_len) - if not body then - socket:close() - return false, fmt("receive STARTTLS response body failed: %s", berr or "closed") - end - - -- decode_message expects the full LDAPMessage, envelope header included - local res, derr = decode_ldap(packet_header .. body) - if derr then - socket:close() - return false, "Invalid LDAP message encoding: " .. derr - end - - if res.protocol_op ~= APPNO.ExtendedResponse then - socket:close() - return false, fmt("Received incorrect Op in packet: %d, expected %d", - res.protocol_op, APPNO.ExtendedResponse) - end - - if res.result_code ~= 0 then - socket:close() - local error_msg = ERROR_MSG[res.result_code] - - return false, fmt("\n Error: %s\n Details: %s", - error_msg or "Unknown error occurred (code: " .. - res.result_code .. ")", res.diagnostic_msg or "") - - else - return true - end -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 index eba11c8..654e787 100644 --- a/rockspec/lua-resty-ldap-0.3.0-0.rockspec +++ b/rockspec/lua-resty-ldap-0.3.0-0.rockspec @@ -21,7 +21,6 @@ build = { type = "builtin", modules = { ["resty.ldap"] = "lib/resty/ldap.lua", - ["resty.ldap.ldap"] = "lib/resty/ldap/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", diff --git a/rockspec/lua-resty-ldap-local-0.rockspec b/rockspec/lua-resty-ldap-local-0.rockspec index eb8e752..6d1fa3d 100644 --- a/rockspec/lua-resty-ldap-local-0.rockspec +++ b/rockspec/lua-resty-ldap-local-0.rockspec @@ -20,7 +20,6 @@ build = { type = "builtin", modules = { ["resty.ldap"] = "lib/resty/ldap.lua", - ["resty.ldap.ldap"] = "lib/resty/ldap/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", diff --git a/rockspec/lua-resty-ldap-main-0.rockspec b/rockspec/lua-resty-ldap-main-0.rockspec index 2072909..908df4b 100644 --- a/rockspec/lua-resty-ldap-main-0.rockspec +++ b/rockspec/lua-resty-ldap-main-0.rockspec @@ -21,7 +21,6 @@ build = { type = "builtin", modules = { ["resty.ldap"] = "lib/resty/ldap.lua", - ["resty.ldap.ldap"] = "lib/resty/ldap/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", diff --git a/t/client.t b/t/client.t index 5ef8518..fd088da 100644 --- a/t/client.t +++ b/t/client.t @@ -275,7 +275,8 @@ ok local res, err = client:simple_bind( "cn=user01,ou=users,dc=example,dc=org", "password1") - assert(res == false, "a 0x00 header byte must fail, got " .. tostring(res)) + 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") @@ -337,7 +338,7 @@ ok -=== TEST 12: ldap_authenticate never pools the socket of a successful bind +=== TEST 12: ldap_authenticate never pools a socket that carried a bind --- http_config eval: $::HttpConfig --- config location /t { @@ -350,17 +351,18 @@ ok attribute = "cn", } - -- control: a failed bind leaves the session anonymous (RFC 4513 s4), - -- so its socket is pooled, observable on the default host:port pool + -- 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 fail") + 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 should be pooled") + 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 must be closed, never pooled + -- 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() @@ -378,3 +380,83 @@ GET /t 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 index aa4ef29..0d0830f 100644 --- a/t/compat_ldap.t +++ b/t/compat_ldap.t @@ -31,8 +31,9 @@ __DATA__ base_dn = "ou=users,dc=example,dc=org", attribute = "cn", }) - assert(ok == false, "verification should have failed the handshake") - assert(err:find("SSL handshake", 1, true), "unexpected err: " .. tostring(err)) + 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") } } @@ -86,8 +87,9 @@ ok base_dn = "ou=users,dc=example,dc=org", attribute = "cn", }) - assert(ok == false, "legacy verify_ldap_host should have enforced verification") - assert(err:find("SSL handshake", 1, true), "unexpected err: " .. tostring(err)) + 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") } } @@ -100,103 +102,7 @@ certificate verify error -=== TEST 4: bind_request on a dead socket fails cleanly ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local ldap = require("resty.ldap.ldap") - - local sock = ngx.socket.tcp() - sock:settimeout(10000) - assert(sock:connect("127.0.0.1", 1389)) - sock:close() -- kill the connection underneath bind_request - - local res, err = ldap.bind_request(sock, - "cn=user01,ou=users,dc=example,dc=org", "password1") - assert(res == nil, "a dead socket must fail, got " .. tostring(res)) - assert(err ~= nil, "a dead socket must report an error") - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- error_log -attempt to send data on a closed socket - - - -=== TEST 5: bind_request returns a controlled error when the response header is unreadable ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local ldap = require("resty.ldap.ldap") - - 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 res, err = ldap.bind_request(sock, - "cn=user01,ou=users,dc=example,dc=org", "password1") - assert(res == nil, "a receive timeout must fail, got " .. tostring(res)) - assert(err ~= nil, "a receive timeout must report an error") - assert(closed, "the unusable socket must be closed") - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - - - -=== TEST 6: bind_request returns a controlled error when the response body is truncated ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local ldap = require("resty.ldap.ldap") - - 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 res, err = ldap.bind_request(sock, - "cn=user01,ou=users,dc=example,dc=org", "password1") - assert(res == nil, "a truncated body must fail, got " .. tostring(res)) - assert(err ~= nil, "a truncated body must report an error") - assert(closed, "the unusable socket must be closed") - ngx.say("ok") - } - } ---- request -GET /t ---- response_body -ok ---- no_error_log -[error] - - - -=== TEST 7: a DN-metacharacter username is escaped into the bind DN +=== TEST 4: a DN-metacharacter username is escaped into the bind DN --- http_config eval: $::HttpConfig --- config location /t { @@ -210,7 +116,8 @@ ok base_dn = "ou=users,dc=example,dc=org", attribute = "cn", }) - assert(ok == false, "an unknown escaped username must fail cleanly") + 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", @@ -227,7 +134,7 @@ ok -=== TEST 8: ldap_authenticate binds a valid user with the raw DN +=== TEST 5: ldap_authenticate binds a valid user with the raw DN --- http_config eval: $::HttpConfig --- config location /t { @@ -254,7 +161,7 @@ ok -=== TEST 9: a wrong password is a clean auth failure +=== TEST 6: a wrong password is a clean auth failure --- http_config eval: $::HttpConfig --- config location /t { @@ -266,7 +173,7 @@ ok base_dn = "ou=users,dc=example,dc=org", attribute = "cn", }) - assert(ok == false, "a wrong password must fail") + 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") } @@ -280,14 +187,14 @@ ok -=== TEST 10: a pooled unverified connection is never reused when tls_verify=true +=== 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") - -- pool a connection established with verification disabled + -- authenticate with verification disabled first local ok, err = ldap.ldap_authenticate("user01", "password1", { ldap_host = "localhost", ldap_port = 1636, @@ -298,8 +205,9 @@ ok }) assert(ok, "unverified handshake should bind: " .. tostring(err)) - -- reusing the pooled connection would skip verification and bind; - -- a fresh connection must fail (no trusted certificate configured) + -- 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, @@ -308,8 +216,9 @@ ok base_dn = "ou=users,dc=example,dc=org", attribute = "cn", }) - assert(ok2 == false, "the unverified pooled connection must not be reused") - assert(err2:find("SSL handshake", 1, true), "unexpected err: " .. tostring(err2)) + 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") } } @@ -319,44 +228,3 @@ GET /t ok --- error_log certificate verify error - - - -=== TEST 11: bind_request message IDs wrap back to 1 past RFC 4511 maxInt ---- http_config eval: $::HttpConfig ---- config - location /t { - content_by_lua_block { - local ldap = require("resty.ldap.ldap") - local upvalue = require("upvalue") - local message_id_of = require("ldap_msgid") - - -- the counter is module-private; set it through bind_request's - -- upvalue cell, which all three request builders share - assert(upvalue.set(ldap.bind_request, "ldapMessageId", 2147483647)) - - local sent - local sock = { - send = function(_, p) sent = p return #p end, - receive = function() return nil, "closed" end, - close = function() return true end, - } - - -- the mock cuts the response off, but the request is sent (and the - -- counter advanced) before the receive - ldap.bind_request(sock, "cn=x,dc=example,dc=org", "pw") - assert(message_id_of(sent) == 2147483647, - "the request at maxInt itself is legal") - ldap.bind_request(sock, "cn=x,dc=example,dc=org", "pw") - local id = message_id_of(sent) - 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]