diff --git a/lib/resty/ldap/asn1.lua b/lib/resty/ldap/asn1.lua index a248f65..190a35e 100644 --- a/lib/resty/ldap/asn1.lua +++ b/lib/resty/ldap/asn1.lua @@ -1,12 +1,18 @@ -local ffi = require("ffi") -local C = ffi.C -local ffi_new = ffi.new -local ffi_string = ffi.string -local string_char = string.char -local new_tab = require("resty.core.base").new_tab +local ffi = require("ffi") +local C = ffi.C +local ffi_new = ffi.new +local ffi_string = ffi.string +local ffi_cast = ffi.cast +local band = require("bit").band +local string_byte = string.byte +local string_char = string.char +local string_format = string.format +local string_sub = string.sub +local new_tab = require("resty.core.base").new_tab local ucharpp = ffi_new("unsigned char*[1]") local charpp = ffi_new("char*[1]") +local cucharpp = ffi_new("const unsigned char*[1]") ffi.cdef [[ @@ -22,6 +28,9 @@ ffi.cdef [[ void ASN1_INTEGER_free(ASN1_INTEGER *a); void ASN1_STRING_free(ASN1_STRING *a); + // frees the buffer i2d_* allocates when *pp is NULL + void CRYPTO_free(void *ptr, const char *file, int line); + long ASN1_INTEGER_get(const ASN1_INTEGER *a); long ASN1_ENUMERATED_get(const ASN1_ENUMERATED *a); @@ -76,56 +85,104 @@ local TAG = { _M.TAG = TAG +-- ASN1_put_object emits at most 11 header bytes (tag + long-form length) +local hdrbuf = ffi_new("unsigned char[16]") + +-- length is a C int; guard against FFI-boundary truncation/clamping +local INT_MAX = 2147483647 + local function asn1_put_object(tag, class, constructed, data, len) - len = type(data) == "string" and #data or len or 0 - if len < 0 then + -- a non-string payload would coerce via tostring() with len 0, emitting a + -- header that disagrees with the bytes that follow it + if data ~= nil and type(data) ~= "string" then + return nil, "invalid object payload" + end + if data ~= nil then + len = #data + elseif len == nil then + len = 0 + end + if type(len) ~= "number" or len % 1 ~= 0 or len < 0 or len > INT_MAX then return nil, "invalid object length" end - local outbuf = ffi_new("unsigned char[?]", len) - ucharpp[0] = outbuf - + -- read back exactly the header bytes written; a strlen-based read would + -- truncate a length octet containing 0x00 (e.g. 256 -> 82 01 00) + ucharpp[0] = hdrbuf C.ASN1_put_object(ucharpp, constructed, len, tag, class) + local header = ffi_string(hdrbuf, ucharpp[0] - hdrbuf) if not data then - return ffi_string(outbuf) + return header end - return ffi_string(outbuf) .. data + return header .. data end _M.put_object = asn1_put_object +-- 2^53: largest magnitude a Lua 5.1 double holds exactly; matches the decoder +local INT_EXACT = 9007199254740992 + local encode do local encoder = new_tab(0, 3) + -- refuse values the C long conversion would truncate/clamp; the integrality + -- test also rejects NaN and +/-inf ((0/0)%1 and (1/0)%1 are NaN) + local function integer_encodable(val) + return type(val) == "number" and val % 1 == 0 + and val <= INT_EXACT and val >= -INT_EXACT + end + + -- i2d with *pp == NULL allocates a buffer we own; copy it out then free it, + -- else a worker leaks one encoding per encode. charpp is module-level. + local function take_i2d_output(ret) + if ret <= 0 then + charpp[0] = nil + return nil, "failed to encode ASN.1 value" + end + local out = ffi_string(charpp[0], ret) + C.CRYPTO_free(charpp[0], "asn1.lua", 0) + charpp[0] = nil + return out + end + -- Integer encoder[TAG.INTEGER] = function(val) + if not integer_encodable(val) then + return nil, "INTEGER value is not an exactly representable integer" + end local typ = C.ASN1_INTEGER_new() C.ASN1_INTEGER_set(typ, val) charpp[0] = nil local ret = C.i2d_ASN1_INTEGER(typ, charpp) C.ASN1_INTEGER_free(typ) - return ffi_string(charpp[0], ret) + return take_i2d_output(ret) end -- Octet String encoder[TAG.OCTET_STRING] = function(val) + if type(val) ~= "string" then + return nil, "OCTET STRING value must be a string" + end local typ = C.ASN1_OCTET_STRING_new() C.ASN1_STRING_set(typ, val, #val) charpp[0] = nil local ret = C.i2d_ASN1_OCTET_STRING(typ, charpp) C.ASN1_STRING_free(typ) - return ffi_string(charpp[0], ret) + return take_i2d_output(ret) end encoder[TAG.ENUMERATED] = function(val) + if not integer_encodable(val) then + return nil, "ENUMERATED value is not an exactly representable integer" + end local typ = C.ASN1_ENUMERATED_new() C.ASN1_ENUMERATED_set(typ, val) charpp[0] = nil local ret = C.i2d_ASN1_ENUMERATED(typ, charpp) - C.ASN1_INTEGER_free(typ) - return ffi_string(charpp[0], ret) + C.ASN1_INTEGER_free(typ) -- shares the ASN1_STRING struct + return take_i2d_output(ret) end encoder[TAG.SEQUENCE] = function(val) @@ -137,8 +194,13 @@ do end encoder[TAG.BOOLEAN] = function(val) + -- reject non-booleans: under Lua truthiness 0 is true, so a caller's + -- 0-for-false would silently encode as TRUE + if type(val) ~= "boolean" then + return nil, "BOOLEAN value must be a boolean" + end -- tag(BOOLEAN), length(1), value(TRUE: 0xFF, FALSE: 0) - return string_char(1, 1, val and 0xff or 0) + return string_char(TAG.BOOLEAN, 1, val and 0xff or 0) end function encode(val, tag) @@ -153,12 +215,208 @@ do end end - if encoder[tag] then - return encoder[tag](val) + local enc = encoder[tag] + if not enc then + -- return an error, not a bare nil callers would concatenate + return nil, string_format("no encoder for ASN.1 tag %s (value of type %s)", + tostring(tag), type(val)) end + return enc(val) end end _M.encode = encode +local MAX_DECODE_DEPTH = 100 + +local asn1_get_object +do + local lenp = ffi_new("long[1]") + local tagp = ffi_new("int[1]") + local classp = ffi_new("int[1]") + local strpp = ffi_new("const unsigned char*[1]") + + function asn1_get_object(der, start, stop) + start = start or 0 + stop = stop or #der + -- validate offsets before they feed pointer arithmetic and omax below + if type(start) ~= "number" or start < 0 or start % 1 ~= 0 + or type(stop) ~= "number" or stop % 1 ~= 0 then + return nil, "invalid offset" + end + if stop <= start or stop > #der then + return nil, "invalid offset" + end + + local s_der = ffi_cast("const unsigned char *", der) + strpp[0] = s_der + start + + local ret = C.ASN1_get_object(strpp, lenp, tagp, classp, stop - start) + -- 0x80 signals an encoding error (incl. indefinite/too-long forms) + if band(ret, 0x80) == 0x80 or band(ret, 0x01) == 0x01 then + return nil, "invalid BER: bad tag/length encoding" + end + + -- X.690 s8.1.2.2/s8.1.2.4.2: tags 0..30 must use the low-tag-number form + -- and forbid a leading 0x80 padding octet. ASN1_get_object normalises the + -- non-canonical forms to the same tag, so guard them here. + local id = string_byte(der, start + 1) + if band(id, 0x1f) == 0x1f then + if tagp[0] < 31 then + return nil, "invalid BER: tag <= 30 must use the low-tag-number form" + end + if band(string_byte(der, start + 2) or 0, 0x7f) == 0 then + return nil, "invalid BER: non-canonical tag number padding" + end + end + + local content_off = strpp[0] - s_der + return { + tag = tagp[0], + class = classp[0], + len = tonumber(lenp[0]), + offset = content_off, -- 0-indexed content start + hl = content_off - start, -- header length (tag + length octets) + cons = band(ret, 0x20) == 0x20, + } + end +end +_M.get_object = asn1_get_object + + +local decode +do + local decoder = new_tab(0, 5) + + -- required form per universal type (X.690 s8.9.1/s8.11.1, RFC 4511 s5.1); + -- the wrong form is a BER-smuggling vector against auth parsers + local CONSTRUCTED_FORM = { + [TAG.INTEGER] = false, + [TAG.OCTET_STRING] = false, + [TAG.ENUMERATED] = false, + [TAG.SEQUENCE] = true, + [TAG.SET] = true, + } + + local TAG_NAME = { + [TAG.INTEGER] = "INTEGER", + [TAG.OCTET_STRING] = "OCTET STRING", + [TAG.ENUMERATED] = "ENUMERATED", + [TAG.SEQUENCE] = "SEQUENCE", + [TAG.SET] = "SET", + } + + -- refuse magnitudes a Lua double can't hold exactly; RFC 4511 caps LDAP + -- integers at 2^31-1 so nothing legitimate is lost. Compared on the int64 + -- cdata, before tonumber() rounds. + local INT_EXACT_MAX = 9007199254740992LL -- 2^53 + + local function exact_number(v, what) + if v > INT_EXACT_MAX or v < -INT_EXACT_MAX then + return nil, what .. " out of representable range" + end + return tonumber(v) + end + + -- OCTET STRING: slice raw content bytes; binary/NUL-safe (no d2i, no strlen) + decoder[TAG.OCTET_STRING] = function(der, _, obj) + return string_sub(der, obj.offset + 1, obj.offset + obj.len) + end + + -- d2i parses a full TLV, so offset is the TLV start. >8 content octets + -- cannot fit a C long, so the magnitude is out of range. + decoder[TAG.INTEGER] = function(der, offset, obj) + if obj.len > 8 then + return nil, "INTEGER out of representable range" + end + cucharpp[0] = ffi_cast("const unsigned char *", der) + offset + local typ = C.d2i_ASN1_INTEGER(nil, cucharpp, obj.hl + obj.len) + if typ == nil then + return nil, "invalid INTEGER encoding" + end + local v = C.ASN1_INTEGER_get(typ) + C.ASN1_INTEGER_free(typ) + return exact_number(v, "INTEGER") + end + + decoder[TAG.ENUMERATED] = function(der, offset, obj) + if obj.len > 8 then + return nil, "ENUMERATED out of representable range" + end + cucharpp[0] = ffi_cast("const unsigned char *", der) + offset + local typ = C.d2i_ASN1_ENUMERATED(nil, cucharpp, obj.hl + obj.len) + if typ == nil then + return nil, "invalid ENUMERATED encoding" + end + local v = C.ASN1_ENUMERATED_get(typ) + C.ASN1_INTEGER_free(typ) -- shares the ASN1_STRING struct + return exact_number(v, "ENUMERATED") + end + + local function decode_children(der, _, obj, depth) + local stop = obj.offset + obj.len + local pos = obj.offset + local values = {} + local n = 0 + while pos < stop do + local value, err + -- bound to the parent's content so an overrunning child cannot + -- absorb a sibling's bytes without an error + pos, value, err = decode(der, pos, stop, depth + 1) + if err then + return nil, err + end + -- decode() never returns a nil value with a nil err, so no holes + n = n + 1 + values[n] = value + end + return values + end + + decoder[TAG.SEQUENCE] = decode_children + decoder[TAG.SET] = decode_children + + -- offset is 0-indexed; stop bounds the TLV to its parent, defaulting to the + -- whole buffer. Returns next_offset, value, err. + function decode(der, offset, stop, depth) + offset = offset or 0 + depth = depth or 0 + if depth > MAX_DECODE_DEPTH then + return nil, nil, "max decode depth exceeded" + end + local obj, err = asn1_get_object(der, offset, stop) + if not obj then + return nil, nil, err + end + + -- dispatch on class + tag + form; tag alone would confuse + -- context-specific [4]/[16]/[17] with universal types + if obj.class ~= CLASS.UNIVERSAL then + return nil, nil, string_format("unsupported element: class 0x%02x tag %d", + obj.class, obj.tag) + end + local d = decoder[obj.tag] + if not d then + -- error, not a silent nil ("absent" vs "present but unknown") + return nil, nil, string_format("unsupported ASN.1 tag %d", obj.tag) + end + local want_cons = CONSTRUCTED_FORM[obj.tag] + if obj.cons ~= want_cons then + return nil, nil, string_format("%s must be %s", TAG_NAME[obj.tag], + want_cons and "constructed" or "primitive") + end + + local value, derr = d(der, offset, obj, depth) + if derr then + return nil, nil, derr + end + if value == nil then + return nil, nil, string_format("%s produced no value", TAG_NAME[obj.tag]) + end + return obj.offset + obj.len, value + end +end +_M.decode = decode + + return _M diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index ef25f56..4481559 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -2,13 +2,17 @@ local asn1 = require("resty.ldap.asn1") local filter_compiler = require("resty.ldap.filter") local asn1_put_object = asn1.put_object local asn1_encode = asn1.encode -local string_char = string.char +local asn1_get_object = asn1.get_object +local asn1_decode = asn1.decode +local string_format = string.format local _M = {} local ldapMessageId = 1 +local LDAP_MAX_INT = 2147483647 + _M.ERROR_MSG = { [1] = "Initialization of LDAP library failed", [4] = "Size limit exceeded", @@ -25,6 +29,8 @@ _M.APP_NO = { SearchRequest = 3, SearchResultEntry = 4, SearchResultDone = 5, + ModifyResponse = 7, + SearchResultReference = 19, ExtendedRequest = 23, ExtendedResponse = 24 } @@ -35,6 +41,9 @@ local function ldap_message(app_no, req) asn1_put_object(app_no, asn1.CLASS.APPLICATION, 1, req) ldapMessageId = ldapMessageId + 1 + if ldapMessageId > LDAP_MAX_INT then + ldapMessageId = 1 + end return ldapMsg end @@ -48,14 +57,23 @@ end function _M.simple_bind_request(dn, password) - local ldapAuth = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, password or "") - if not password then - -- When password is nil, ASN1_put_object does not generate a zero length for it, - -- so we need to fill it in manually. - -- This is a compatibility measure for anonymous bind. - ldapAuth = ldapAuth .. string_char(0) - end - local bindReq = asn1_encode(3) .. asn1_encode(dn or "") .. ldapAuth + -- simple [0] OCTET STRING; a zero-length password encodes as `80 00`. + -- Pin the LDAPString type: put_object rejects a non-string payload, which for + -- a numeric password would encode an empty (unauthenticated) bind. Coerce + -- numbers; reject anything else. + dn = dn == nil and "" or dn + password = password == nil and "" or password + if type(dn) == "number" then dn = tostring(dn) end + if type(password) == "number" then password = tostring(password) end + if type(dn) ~= "string" then + return nil, "bind dn must be a string" + end + if type(password) ~= "string" then + return nil, "bind password must be a string" + end + + local ldapAuth = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, password) + local bindReq = asn1_encode(3) .. asn1_encode(dn) .. ldapAuth local ldapMsg = ldap_message(_M.APP_NO.BindRequest, bindReq) return asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) end @@ -157,12 +175,27 @@ end function _M.search_request(base_obj, scope, deref_aliases, size_limit, time_limit, types_only, filter, attributes) - local base_obj = asn1_encode(base_obj, asn1.TAG.OCTET_STRING) - local scope = asn1_encode(scope, asn1.TAG.ENUMERATED) - local deref_aliases = asn1_encode(deref_aliases, asn1.TAG.ENUMERATED) - local size_limit = asn1_encode(size_limit, asn1.TAG.INTEGER) - local time_limit = asn1_encode(time_limit, asn1.TAG.INTEGER) - local types_only = asn1_encode(types_only, asn1.TAG.BOOLEAN) + -- typesOnly is a BOOLEAN, but callers write 0 for false among the numeric + -- args; Lua treats 0 as true, so normalise the numeric spelling here + if types_only == nil or types_only == 0 then + types_only = false + elseif type(types_only) == "number" then + types_only = true + end + + -- surface asn1.encode's (nil, err) here; the results are concatenated below + local base_obj, err = asn1_encode(base_obj, asn1.TAG.OCTET_STRING) + if not base_obj then return nil, err end + local scope, err = asn1_encode(scope, asn1.TAG.ENUMERATED) + if not scope then return nil, err end + local deref_aliases, err = asn1_encode(deref_aliases, asn1.TAG.ENUMERATED) + if not deref_aliases then return nil, err end + local size_limit, err = asn1_encode(size_limit, asn1.TAG.INTEGER) + if not size_limit then return nil, err end + local time_limit, err = asn1_encode(time_limit, asn1.TAG.INTEGER) + if not time_limit then return nil, err end + local types_only, err = asn1_encode(types_only, asn1.TAG.BOOLEAN) + if not types_only then return nil, err end -- compile filter local filter_tbl, err = filter_compiler.compile(filter) @@ -185,4 +218,189 @@ function _M.search_request(base_obj, scope, deref_aliases, size_limit, time_limi end +-- Response decoding. + +-- Every decode below passes the enclosing element's end offset, so a field that +-- overruns its parent is rejected instead of swallowing the next field's bytes. + +-- decode one element and require the ASN.1 type RFC 4511 assigns to the field; +-- asn1.decode checks class, but the tag must be pinned per field. Returns +-- next_offset, value, err. +local function decode_typed(packet, pos, stop, tag, what) + local obj, err = asn1_get_object(packet, pos, stop) + if not obj then return nil, nil, err end + if obj.class ~= asn1.CLASS.UNIVERSAL or obj.tag ~= tag then + return nil, nil, string_format("%s has the wrong ASN.1 type (class 0x%02x tag %d)", + what, obj.class, obj.tag) + end + return asn1_decode(packet, pos, stop) +end + +-- hand-walked containers skip asn1.decode's dispatch, so assert their form here +local function is_sequence(obj) + return obj.class == asn1.CLASS.UNIVERSAL + and obj.tag == asn1.TAG.SEQUENCE + and obj.cons +end + +-- protocolOp tags a server may send; anything else must not reach parse_ldap_result +local LDAP_RESULT_OPS = { + [_M.APP_NO.BindResponse] = true, + [_M.APP_NO.SearchResultDone] = true, + [_M.APP_NO.ModifyResponse] = true, + [_M.APP_NO.ExtendedResponse] = true, +} + +local function parse_ldap_result(packet, op, res) + -- RFC 4511 s4.1.9: LDAPResult ::= SEQUENCE { resultCode ENUMERATED, + -- matchedDN LDAPDN, diagnosticMessage LDAPString, referral [3] OPTIONAL } + local stop = op.offset + op.len + local _, pos, code, matched_dn, diag, err + pos, code, err = decode_typed(packet, op.offset, stop, + asn1.TAG.ENUMERATED, "resultCode") + if err then return nil, err end + pos, matched_dn, err = decode_typed(packet, pos, stop, + asn1.TAG.OCTET_STRING, "matchedDN") + if err then return nil, err end + _, diag, err = decode_typed(packet, pos, stop, + asn1.TAG.OCTET_STRING, "diagnosticMessage") + if err then return nil, err end + res.result_code = code + res.matched_dn = matched_dn + res.diagnostic_msg = diag + -- no whole-op check: optional trailing fields (referral [3], SASL creds, + -- responseName/Value) legally follow and AD emits them constantly + return res +end + +local function parse_search_entry(packet, op, res) + -- RFC 4511 s4.5.2: SearchResultEntry ::= [APPLICATION 4] SEQUENCE + -- { objectName LDAPDN, attributes PartialAttributeList } + local stop = op.offset + op.len + local pos, entry_dn, err + pos, entry_dn, err = decode_typed(packet, op.offset, stop, + asn1.TAG.OCTET_STRING, "objectName") + if err then return nil, err end + res.entry_dn = entry_dn + + -- PartialAttributeList ::= SEQUENCE OF PartialAttribute + local attrs, aerr = asn1_get_object(packet, pos, stop) + if not attrs then return nil, aerr end + if not is_sequence(attrs) then + return nil, "PartialAttributeList is not a universal constructed SEQUENCE" + end + + local attributes = {} + local apos = attrs.offset + local astop = attrs.offset + attrs.len + while apos < astop do + -- PartialAttribute ::= SEQUENCE { type AttributeDescription, + -- vals SET OF AttributeValue } + local pa, perr = asn1_get_object(packet, apos, astop) + if not pa then return nil, perr end + if not is_sequence(pa) then + return nil, "PartialAttribute is not a universal constructed SEQUENCE" + end + local pastop = pa.offset + pa.len + -- becomes a table key, so its string type is load-bearing + local vpos, atype, terr = decode_typed(packet, pa.offset, pastop, + asn1.TAG.OCTET_STRING, + "AttributeDescription") + if terr then return nil, terr end + -- vals is a SET OF: enforce it so the stored value is always an array + local vend, vals, verr = decode_typed(packet, vpos, pastop, + asn1.TAG.SET, "attribute vals") + if verr then return nil, verr end + -- PartialAttribute has exactly two components; nothing may follow vals + if vend ~= pastop then + return nil, "trailing bytes in PartialAttribute" + end + attributes[atype] = vals -- ALWAYS an array (empty for typesOnly) + apos = pastop + end + -- SearchResultEntry has exactly two components; nothing may follow + if astop ~= stop then + return nil, "trailing bytes in SearchResultEntry" + end + res.attributes = attributes + return res +end + +local function parse_search_reference(packet, op, res) + -- [APPLICATION 19] IMPLICIT replaces the SEQUENCE OF tag, so `op` IS the + -- sequence. RFC 4511 s4.5.2: SearchResultReference ::= [APPLICATION 19] + -- SEQUENCE SIZE (1..MAX) OF uri URI, with URI ::= LDAPString (s4.1.10). + local uris = {} + local n = 0 + local pos = op.offset + local stop = op.offset + op.len + while pos < stop do + local uri, err + pos, uri, err = decode_typed(packet, pos, stop, asn1.TAG.OCTET_STRING, + "SearchResultReference URI") + if err then return nil, err end + n = n + 1 + uris[n] = uri + end + res.uris = uris + return res +end + +function _M.decode_message(packet) + local env, err = asn1_get_object(packet, 0) + if not env then return nil, err end + -- LDAPMessage is a constructed SEQUENCE (X.690 s8.9.1) + if not is_sequence(env) then + return nil, "invalid LDAPMessage envelope" + end + + local envstop = env.offset + env.len + -- the envelope must account for every byte; trailing data is a second + -- message or garbage that would otherwise be silently discarded + if envstop ~= #packet then + return nil, "trailing bytes after LDAPMessage" + end + + -- messageID ::= INTEGER (0..maxInt), RFC 4511 s4.1.1 + local pos, message_id, merr = decode_typed(packet, env.offset, envstop, + asn1.TAG.INTEGER, "messageID") + if merr then return nil, merr end + + local op, oerr = asn1_get_object(packet, pos, envstop) -- protocolOp + if not op then return nil, oerr end + if op.class ~= asn1.CLASS.APPLICATION then + return nil, "protocolOp is not APPLICATION-tagged" + end + -- every response protocolOp is a constructed SEQUENCE + if not op.cons then + return nil, "protocolOp is not constructed" + end + + -- exactly one protocolOp per LDAPMessage; only controls [0] may follow it + -- (RFC 4511 s4.1.1). A second APPLICATION element is smuggled data. + local opend = op.offset + op.len + if opend ~= envstop then + local ctrl, cerr = asn1_get_object(packet, opend, envstop) + if not ctrl then return nil, cerr end + if ctrl.class ~= asn1.CLASS.CONTEXT_SPECIFIC or ctrl.tag ~= 0 then + return nil, "unexpected element after protocolOp" + end + if ctrl.offset + ctrl.len ~= envstop then + return nil, "trailing bytes after controls" + end + end + + local res = { message_id = message_id, protocol_op = op.tag } + + if op.tag == _M.APP_NO.SearchResultEntry then + return parse_search_entry(packet, op, res) + elseif op.tag == _M.APP_NO.SearchResultReference then + return parse_search_reference(packet, op, res) + elseif LDAP_RESULT_OPS[op.tag] then + return parse_ldap_result(packet, op, res) + end + return nil, "unsupported protocolOp " .. op.tag +end + + return _M diff --git a/t/asn1.t b/t/asn1.t new file mode 100644 index 0000000..5618ec0 --- /dev/null +++ b/t/asn1.t @@ -0,0 +1,196 @@ +# Test vectors: rasn v0.6.1 (octet_string); Go go1.26.3 encoding/asn1 (int64TestData). + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: get_object reads tag/class/len/hl for short and long form +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- short form: 30 03 02 01 05 (SEQUENCE len 3) + local o = assert(asn1.get_object(ldap_hex("30 03 02 01 05"))) + assert(o.tag == 16, "tag " .. o.tag) -- SEQUENCE + assert(o.class == asn1.CLASS.UNIVERSAL, "class") + assert(o.cons == true, "cons") + assert(o.len == 3, "len " .. o.len) + assert(o.hl == 2, "hl " .. o.hl) + assert(o.offset == 2, "offset " .. o.offset) + + -- long form: 30 81 82 <130 bytes> (2-extra-byte header) + local body = string.rep("\0", 130) + local o2 = assert(asn1.get_object(ldap_hex("30 81 82") .. body)) + assert(o2.len == 130, "long len " .. o2.len) + assert(o2.hl == 3, "long hl " .. o2.hl) -- NOT 2 + + -- application-tagged: 64 00 (SearchResultEntry, empty) + local o3 = assert(asn1.get_object(ldap_hex("64 00"))) + assert(o3.tag == 4, "app tag " .. o3.tag) + assert(o3.class == asn1.CLASS.APPLICATION, "app class") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: decode octet string is NUL-safe (regression: no strlen truncation) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- 04 05 41 42 00 43 44 => "AB\0CD" (5 bytes, embedded NUL) + local _, v = asn1.decode(ldap_hex("04 05 41 42 00 43 44")) + assert(#v == 5, "length " .. #v) -- a strlen-based read would stop at the NUL + assert(v == "AB\0CD", "value") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: decode integer, enumerated, and nested SEQUENCE/SET (uses hl, not +2) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, id = asn1.decode(ldap_hex("02 01 03")) + assert(id == 3, "int " .. tostring(id)) + local _, code = asn1.decode(ldap_hex("0a 01 00")) + assert(code == 0, "enum " .. tostring(code)) + + -- SET OF two octet strings: 31 0d 04 03 746f70 04 06 646f6d61696e + local _, vals = asn1.decode(ldap_hex("31 0d 04 03 74 6f 70 04 06 64 6f 6d 61 69 6e")) + assert(type(vals) == "table", "set is table") + assert(vals[1] == "top" and vals[2] == "domain", "set values") + + -- long-form SET (>=128 bytes of content) parses via hl, not +2 + local big = string.rep("\4\1X", 60) -- 60 octet strings "X" = 180 bytes + local seq = ldap_hex("31 81 b4") .. big -- 0xb4 = 180 + local _, arr = asn1.decode(seq) + assert(#arr == 60, "long set count " .. #arr) + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: malformed input returns error, never crashes +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- claims 5 content bytes, only 2 present + local off, v, err = asn1.decode(ldap_hex("30 05 02 01")) + assert(v == nil, "no value on malformed") + assert(err ~= nil, "error reported") + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: decoder errors surface, not swallowed as empty success +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- truncated-child case is original: exercises the `stop` bound (asn1.lua:412) + + -- truncated child: outer len 3, inner OCTET STRING claims 5 but 1 present + local _, v, err = asn1.decode(ldap_hex("30 03 04 05 41")) + assert(v == nil, "no value for truncated child") + assert(err ~= nil, "truncated child reports error") + + -- zero-length INTEGER: d2i_ASN1_INTEGER returns nil + local _, v2, err2 = asn1.decode(ldap_hex("02 00")) + assert(v2 == nil, "no value for zero-len INTEGER") + assert(err2 ~= nil, "zero-len INTEGER reports error") + + -- zero-length ENUMERATED: d2i_ASN1_ENUMERATED returns nil + local _, v3, err3 = asn1.decode(ldap_hex("0a 00")) + assert(v3 == nil, "no value for zero-len ENUMERATED") + assert(err3 ~= nil, "zero-len ENUMERATED reports error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: constructed OCTET STRING (0x24) is rejected (BER smuggling) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- constructed OCTET STRING: inner TLVs must not be handed back (RFC 4511 s5.1) + local _, v, err = asn1.decode(ldap_hex("24 07 04 02 41 42 04 01 43")) + assert(v == nil, "constructed OCTET STRING yields no value") + assert(err ~= nil, "constructed OCTET STRING reports error") + + -- primitive OCTET STRING still decodes normally + local _, v2 = asn1.decode(ldap_hex("04 02 41 42")) + assert(v2 == "AB", "primitive OCTET STRING still works") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_bounds.t b/t/asn1_bounds.t new file mode 100644 index 0000000..c8f82e0 --- /dev/null +++ b/t/asn1_bounds.t @@ -0,0 +1,398 @@ +# Test vectors: pyasn1 v0.6.4 (LengthFieldLimit, NestingDepthLimit); Go encoding/asn1. + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: degenerate [start, stop) regions are rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local d = ldap_hex("04 03 41 42 43") -- 5 bytes + + -- empty region: start == stop + local o, e = asn1.get_object(d, 0, 0) + assert(o == nil and e ~= nil, "start==stop must error") + local o2, e2 = asn1.get_object(d, 3, 3) + assert(o2 == nil and e2 ~= nil, "start==stop mid-buffer must error") + + -- inverted region: start > stop + local o3, e3 = asn1.get_object(d, 4, 2) + assert(o3 == nil and e3 ~= nil, "start>stop must error") + + -- stop past the end of the buffer + local o4, e4 = asn1.get_object(d, 0, 99) + assert(o4 == nil and e4 ~= nil, "stop>#der must error") + + -- start at/past the end of the buffer + local o5, e5 = asn1.get_object(d, 5) + assert(o5 == nil and e5 ~= nil, "start==#der must error") + local o6, e6 = asn1.get_object(d, 6) + assert(o6 == nil and e6 ~= nil, "start>#der must error") + + -- empty buffer + local o7, e7 = asn1.get_object("") + assert(o7 == nil and e7 ~= nil, "empty der must error") + local n8, v8, e8 = asn1.decode("") + assert(v8 == nil and e8 ~= nil, "decode('') must error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: a negative start is rejected, never read out of bounds +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- guard at asn1.lua:187 has no lower bound; negative start reads before payload + for _, hex in ipairs({"04 03 41 42 43", "30 06 04 01 41 04 01 42", + "30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"}) do + local d = ldap_hex(hex) + for s = -8, -1 do + local o, e = asn1.get_object(d, s) + assert(o == nil, "get_object must reject start=" .. s .. + " (returned tag=" .. tostring(o and o.tag) .. + " len=" .. tostring(o and o.len) .. + " offset=" .. tostring(o and o.offset) .. ")") + assert(e ~= nil, "negative start must report an error, s=" .. s) + end + end + + -- decode() must refuse too, and never return a negative next-offset + local d = ldap_hex("30 06 04 01 41 04 01 42") + for s = -8, -1 do + local n, v, e = asn1.decode(d, s) + assert(e ~= nil, "decode must error on start=" .. s .. + " (got next=" .. tostring(n) .. ")") + assert(n == nil or n >= 0, "decode must not return a negative offset, s=" .. s) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: non-integer offsets are rejected; hl is always a whole number +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- fractional start escapes with hl = 1.5, corrupting d2i decoding + local d = ldap_hex("04 02 41 42 43") + local o, e = asn1.get_object(d, 0.5) + assert(o == nil, "fractional start must be rejected (got hl=" .. + tostring(o and o.hl) .. ")") + assert(e ~= nil, "fractional start must report an error") + + local ds, es = asn1.get_object(d, 0, 4.5) + assert(ds == nil and es ~= nil, "fractional stop must be rejected") + + -- same INTEGER decodes to 5 at start 0 but fails at start 0.5 (hl+len truncated) + local di = ldap_hex("02 01 05 41 42") + local _, v0 = asn1.decode(di, 0) + assert(v0 == 5, "INTEGER at start 0 is 5, got " .. tostring(v0)) + local _, vf, ef = asn1.decode(di, 0.5) + assert(ef ~= nil, "fractional start must error, not silently mis-decode") + + -- fractional start at a NONZERO offset where flooring would land on a valid TLV: + -- rejection must be pinned, not left to a downstream parse error + local der2 = ldap_hex("41 04 03 41 42 43 00 00 00 00") + local og, eg = asn1.get_object(der2, 1.9, #der2) + assert(og == nil, "fractional start 1.9 must be rejected, not floored onto the TLV at 1" + .. (og and (" (got hl=" .. tostring(og.hl) .. ")") or "")) + assert(eg ~= nil, "fractional start 1.9 must report an error") + local _, vg, dg = asn1.decode(der2, 1.2) + assert(vg == nil and dg ~= nil, + "decode must reject fractional offset 1.2 instead of silently flooring it") + + -- whenever get_object succeeds, every reported offset must be integral + local buffers = { + ldap_hex("04 03 41 42 43"), + ldap_hex("30 81 82") .. string.rep("\0", 130), + ldap_hex("04 81 03 41 42 43"), + } + for i, buf in ipairs(buffers) do + local ok = assert(asn1.get_object(buf), "buffer " .. i) + assert(ok.hl % 1 == 0, "hl must be integral, got " .. tostring(ok.hl)) + assert(ok.offset % 1 == 0, "offset must be integral") + assert(ok.len % 1 == 0, "len must be integral") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: zero-length content and content ending exactly at the buffer end +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- string_sub with len == 0 must yield "", not a slice from the end + local n, v, e = asn1.decode(ldap_hex("04 00")) + assert(e == nil, "zero-length OCTET STRING must decode") + assert(v == "", "zero-length value must be empty string, got " .. tostring(v)) + assert(n == 2, "next offset must be 2, got " .. tostring(n)) + + -- zero-length constructed types + local _, seq = asn1.decode(ldap_hex("30 00")) + assert(type(seq) == "table" and #seq == 0, "empty SEQUENCE is an empty array") + local _, set = asn1.decode(ldap_hex("31 00")) + assert(type(set) == "table" and #set == 0, "empty SET is an empty array") + + -- content ends exactly on the last byte of the buffer + local n2, v2, e2 = asn1.decode(ldap_hex("04 03 41 42 43")) + assert(e2 == nil and v2 == "ABC", "value at buffer end") + assert(n2 == 5, "next offset equals #der, got " .. tostring(n2)) + + -- zero-length element as the very last byte pair of the buffer + local n3, v3 = asn1.decode(ldap_hex("30 02 04 00")) + assert(type(v3) == "table" and #v3 == 1 and v3[1] == "", "trailing empty child") + assert(n3 == 4, "next offset " .. tostring(n3)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: child TLVs are bounded exactly by the parent's content +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- child ends EXACTLY on the parent boundary: must decode + local n, v, e = asn1.decode(ldap_hex("30 05 04 03 41 42 43")) + assert(e == nil, "exact fit must not error: " .. tostring(e)) + assert(type(v) == "table" and #v == 1 and v[1] == "ABC", "exact-fit value") + assert(n == 7, "next offset " .. tostring(n)) + + -- two children ending exactly on both parent and buffer boundary + local n2, v2, e2 = asn1.decode(ldap_hex("30 06 04 01 41 04 01 42")) + assert(e2 == nil and #v2 == 2 and v2[1] == "A" and v2[2] == "B", "two exact children") + assert(n2 == 8, "next offset " .. tostring(n2)) + + -- child overshoots parent by exactly 1 byte, buffer long enough: reject + local _, v3, e3 = asn1.decode(ldap_hex("30 04 04 03 41 42 43")) + assert(v3 == nil and e3 ~= nil, "overshoot by 1 must be rejected") + + -- child header itself is truncated by the parent bound + local _, v4, e4 = asn1.decode(ldap_hex("30 01 04 03 41 42 43")) + assert(v4 == nil and e4 ~= nil, "truncated child header must be rejected") + + -- one stray byte left inside the parent after the last child + local _, v5, e5 = asn1.decode(ldap_hex("30 07 04 01 41 04 01 42 43")) + assert(v5 == nil and e5 ~= nil, "leftover byte inside parent must be rejected") + + -- MAX_DECODE_DEPTH: 100 levels ok, 101 rejected, and neither raises + local function nest(k) + local s = ldap_hex("04 00") + for _ = 1, k do + s = asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, s) + end + return s + end + local ok100, _, _, e100 = pcall(asn1.decode, nest(100)) + assert(ok100, "depth 100 must not raise") + assert(e100 == nil, "depth 100 must decode, got " .. tostring(e100)) + local ok101, _, v101, e101 = pcall(asn1.decode, nest(101)) + assert(ok101, "depth 101 must not raise") + assert(v101 == nil and e101 ~= nil, "depth 101 must be rejected") + local okdeep, _, vdeep, edeep = pcall(asn1.decode, nest(2000)) + assert(okdeep, "depth 2000 must not raise (no C-stack blowout)") + assert(vdeep == nil and edeep ~= nil, "depth 2000 must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: zero-length children always advance pos (no spin in decode_children) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- zero-length child still costs a 2-byte header, so pos always advances + local n, v, e = asn1.decode(ldap_hex("30 06 04 00 04 00 04 00")) + assert(e == nil, "three empty children must decode: " .. tostring(e)) + assert(type(v) == "table" and #v == 3, "three empty children, got " .. + tostring(type(v) == "table" and #v or v)) + assert(v[1] == "" and v[2] == "" and v[3] == "", "all empty") + assert(n == 8, "next offset " .. tostring(n)) + + -- stress: 1000 zero-length children must terminate, not spin + local body = string.rep(ldap_hex("04 00"), 1000) + local seq = asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, body) + local n2, v2, e2 = asn1.decode(seq) + assert(e2 == nil, "1000 empty children: " .. tostring(e2)) + assert(#v2 == 1000, "1000 children, got " .. tostring(#v2)) + assert(n2 == #seq, "consumed whole buffer") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: bytes trailing a SearchResultEntry member 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") + + -- one level deeper: PartialAttribute ::= SEQUENCE { type, vals } has + -- exactly two components, so bytes after vals are equally invalid + local inner = assert(protocol.decode_message(ldap_hex( + "30 15 02 01 03 64 10 04 01 78 30 0b 30 09 04 02 63 6e 31 03 04 01 41"))) + assert(inner.attributes.cn[1] == "A", "inner baseline decodes") + + -- same attribute, PartialAttribute grown by `de ad` after vals + local res3, err3 = protocol.decode_message(ldap_hex( + "30 17 02 01 03 64 12 04 01 78 30 0d 30 0b 04 02 63 6e 31 03 04 01 41 de ad")) + assert(res3 == nil, "trailing bytes in PartialAttribute must not decode " .. + "(got cn=" .. tostring(res3 and res3.attributes.cn[1]) .. ")") + assert(err3 ~= nil, "trailing bytes in PartialAttribute must report an error") + + -- and a well-formed TLV hidden in that same gap + local res4, err4 = protocol.decode_message(ldap_hex( + "30 1a 02 01 03 64 15 04 01 78 30 10 30 0e 04 02 63 6e 31 03 " .. + "04 01 41 04 03 41 42 43")) + assert(res4 == nil and err4 ~= nil, + "trailing TLV in PartialAttribute must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 8: BER leniency on non-minimal lengths is preserved (guard rail) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- RFC 4511 s5.1 wants DER, but real servers emit BER non-minimal lengths: keep them + local _, v, e = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(e == nil and v == "ABC", "1-octet long form must still decode, got " .. + tostring(v) .. "/" .. tostring(e)) + local _, v2, e2 = asn1.decode(ldap_hex("04 82 00 03 41 42 43")) + assert(e2 == nil and v2 == "ABC", "2-octet long form must still decode") + + -- hl reflects the non-minimal header, and the next offset accounts for it + local o = assert(asn1.get_object(ldap_hex("04 81 03 41 42 43"))) + assert(o.hl == 3 and o.len == 3 and o.offset == 3, "hl/len/offset " .. + o.hl .. "/" .. o.len .. "/" .. o.offset) + local n = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(n == 6, "next offset must be 6, got " .. tostring(n)) + + -- nested inside a parent whose length accounts for the longer header + local _, arr, e3 = asn1.decode(ldap_hex("30 06 04 81 03 41 42 43")) + assert(e3 == nil, "non-minimal child: " .. tostring(e3)) + assert(type(arr) == "table" and #arr == 1 and arr[1] == "ABC", "non-minimal child value") + + -- end to end: BindResponse matchedDN carries a 1-octet long-form length + local res = assert(protocol.decode_message( + ldap_hex("30 0e 02 01 01 61 09 0a 01 00 04 81 01 41 04 00"))) + assert(res.result_code == 0, "code") + assert(res.matched_dn == "A", "matched_dn " .. tostring(res.matched_dn)) + assert(res.diagnostic_msg == "", "diag") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_class.t b/t/asn1_class.t new file mode 100644 index 0000000..14b0fda --- /dev/null +++ b/t/asn1_class.t @@ -0,0 +1,408 @@ +# Test vectors: rasn v0.6.1 (tagged_boolean). + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: decode() dispatches on tag NUMBER only -- full class x P/C matrix +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- every decoder tag x 4 classes x P/C: only universal correct-form yields a value + local UNIV, APPL, CTXT, PRIV = 0x00, 0x40, 0x80, 0xc0 + local payload = { + [2] = { prim = "01 05", cons = "03 02 01 05" }, + [4] = { prim = "03 41 42 43", cons = "05 04 03 41 42 43" }, + [10] = { prim = "01 07", cons = "03 0a 01 07" }, + [16] = { prim = "03 04 01 41", cons = "03 04 01 41" }, + [17] = { prim = "03 04 01 41", cons = "03 04 01 41" }, + } + local names = { [2]="INTEGER", [4]="OCTET_STRING", [10]="ENUMERATED", + [16]="SEQUENCE", [17]="SET" } + local cnames = { [UNIV]="UNIVERSAL", [APPL]="APPLICATION", + [CTXT]="CONTEXT", [PRIV]="PRIVATE" } + + local leaks = {} + for _, tn in ipairs({2, 4, 10, 16, 17}) do + for _, cls in ipairs({UNIV, APPL, CTXT, PRIV}) do + for _, consbit in ipairs({0x00, 0x20}) do + local id = tn + cls + consbit + local cons = consbit == 0x20 + local hex = string.format("%02x ", id) .. + (cons and payload[tn].cons or payload[tn].prim) + -- SEQUENCE/SET must be constructed; the scalar types primitive + local want_ok = (cls == UNIV) and + (((tn == 16 or tn == 17) and cons) or + ((tn == 2 or tn == 4 or tn == 10) and not cons)) + + local _, v, err = asn1.decode(ldap_hex(hex)) + local cell_ok + if want_ok then + cell_ok = (err == nil and v ~= nil) + else + -- rejection must be explicit: an error and no value, never a silent nil + cell_ok = (err ~= nil and v == nil) + end + -- non-universal tag-4 cells must never alias universal OCTET STRING + if tn == 4 and cls ~= UNIV and v == "ABC" then + cell_ok = false + end + if not cell_ok then + table.insert(leaks, string.format( + "%s %s %s (id=%02x) want_%s got value=%s err=%s", + names[tn], cnames[cls], cons and "cons" or "prim", id, + want_ok and "value" or "error", + type(v) == "table" and "" or tostring(v), + tostring(err))) + end + end + end + end + + -- cross-checked vectors (pyasn1 v0.6.4 / rasn v0.6.1 / Go encoding/asn1): + -- APPLICATION [5] and constructed context [2] must error explicitly, never silent-nil + local _, exp, e7 = asn1.decode( + ldap_hex("65 11 04 0f 51 75 69 63 6b 20 62 72 6f 77 6e 20 66 6f 78")) + assert(e7 ~= nil, "APPLICATION [5] must report an error, not a silent nil") + assert(exp == nil, "APPLICATION [5] must not yield a value") + local _, tb, e6 = asn1.decode(ldap_hex("a2 03 01 01 ff")) + assert(tb == nil, "context [2] constructed must not yield a value") + assert(e6 ~= nil, "context [2] constructed must report an error") + assert(not e6:find("INTEGER"), + "error for a context-specific tag must not blame INTEGER, got: " .. e6) + + if #leaks > 0 then + ngx.say(#leaks .. " class/form mismatches accepted:") + ngx.say(table.concat(leaks, "\n")) + else + ngx.say("ok") + end + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: tightening class dispatch must not break legal universal encodings +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Guard rail for the class fix: these must all keep working. + local _, v = asn1.decode(ldap_hex("04 03 41 42 43")) + assert(v == "ABC", "universal OCTET STRING: " .. tostring(v)) + local _, i = asn1.decode(ldap_hex("02 01 05")) + assert(i == 5, "universal INTEGER: " .. tostring(i)) + local _, e = asn1.decode(ldap_hex("0a 01 07")) + assert(e == 7, "universal ENUMERATED: " .. tostring(e)) + local _, s = asn1.decode(ldap_hex("30 03 04 01 41")) + assert(type(s) == "table" and s[1] == "A", "universal SEQUENCE") + local _, t = asn1.decode(ldap_hex("31 03 04 01 41")) + assert(type(t) == "table" and t[1] == "A", "universal SET") + + -- BER non-minimal length still accepted: real directory servers emit it + local _, nm = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(nm == "ABC", "non-minimal length must still decode: " .. tostring(nm)) + local _, nm2 = asn1.decode(ldap_hex("30 81 03 04 01 41")) + assert(type(nm2) == "table" and nm2[1] == "A", "non-minimal SEQUENCE length") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: LDAPMessage envelope and messageID must be universal types +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- baseline BindResponse still decodes + local ok_res = assert(protocol.decode_message( + ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok_res.message_id == 1, "baseline message_id") + + -- envelope tag 16 but primitive (0x10): cons bit unchecked, walks as SEQUENCE + local r1, e1 = protocol.decode_message( + ldap_hex("10 0c 02 01 01 61 07 0a 01 00 04 00 04 00")) + assert(r1 == nil, "primitive envelope 0x10 must be rejected") + assert(e1 ~= nil, "primitive envelope reports an error") + + -- messageID is INTEGER; context-specific [4] currently yields the string "A" + local r2, e2 = protocol.decode_message( + ldap_hex("30 0c 84 01 41 61 07 0a 01 00 04 00 04 00")) + assert(r2 == nil, "messageID as context [4] must be rejected, got message_id=" + .. tostring(r2 and r2.message_id)) + assert(e2 ~= nil, "context messageID reports an error") + + -- messageID as context-specific [16]: currently yields a table + local r3, e3 = protocol.decode_message( + ldap_hex("30 0b 90 00 61 07 0a 01 00 04 00 04 00")) + assert(r3 == nil, "messageID as context [16] must be rejected, got type=" + .. type(r3 and r3.message_id)) + assert(e3 ~= nil, "context [16] messageID reports an error") + + -- messageID as BOOLEAN (no decoder) currently succeeds with message_id == nil; + -- a nil/mistyped id breaks request/response correlation (RFC 4511 s4.1.1) + local r4, e4 = protocol.decode_message( + ldap_hex("30 0c 01 01 ff 61 07 0a 01 00 04 00 04 00")) + assert(r4 == nil, "BOOLEAN messageID must be rejected, got message_id=" + .. tostring(r4 and r4.message_id)) + assert(e4 ~= nil, "BOOLEAN messageID reports an error") + + -- messageID as OCTET STRING (a registered decoder): message_id must not become "A" + local r5, e5 = protocol.decode_message( + ldap_hex("30 0c 04 01 41 61 07 0a 01 00 04 00 04 00")) + assert(r5 == nil, "OCTET STRING messageID must be rejected, got message_id=" + .. tostring(r5 and r5.message_id)) + assert(e5 ~= nil, "OCTET STRING messageID reports an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: LDAPResult fields reject class-mismatched elements +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- BindResponse is parsed pre-auth; resultCode ENUMERATED, matchedDN/diag OCTET STRING + local cases = { + { "resultCode as context [4]", "30 0c 02 01 01 61 07 84 01 41 04 00 04 00" }, + { "resultCode as context [16]", "30 0b 02 01 01 61 06 90 00 04 00 04 00" }, + { "resultCode as private [17]", "30 0b 02 01 01 61 06 f1 00 04 00 04 00" }, + { "matchedDN as private [16]", "30 0c 02 01 01 61 07 0a 01 00 d0 00 04 00" }, + { "diagnosticMessage as appl [4]", "30 0d 02 01 01 61 08 0a 01 00 04 00 44 01 41" }, + } + + local leaks = {} + for _, c in ipairs(cases) do + local res, err = protocol.decode_message(ldap_hex(c[2])) + if res ~= nil or err == nil then + table.insert(leaks, string.format( + "%s accepted (result_code=%s/%s matched_dn=%s diag=%s)", + c[1], + type(res and res.result_code) == "table" and "
" + or tostring(res and res.result_code), + type(res and res.result_code), + type(res and res.matched_dn), + type(res and res.diagnostic_msg))) + end + end + + -- callers concatenate result_code into an error string, so its type must be guaranteed + local good = assert(protocol.decode_message( + ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(type(good.result_code) == "number", "result_code is a number") + assert(type(good.matched_dn) == "string", "matched_dn is a string") + assert(type(good.diagnostic_msg) == "string", "diagnostic_msg is a string") + + if #leaks > 0 then + ngx.say(#leaks .. " LDAPResult class confusions accepted:") + ngx.say(table.concat(leaks, "\n")) + else + ngx.say("ok") + end + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: SearchResultEntry -- hostile server controls the attribute type tag +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- baseline: objectName "x", attribute "s" with an empty vals SET + local base = assert(protocol.decode_message( + ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 30 05 04 01 73 31 00"))) + assert(base.entry_dn == "x", "baseline entry_dn") + assert(type(base.attributes.s) == "table", "baseline vals is an array") + + -- protocol.lua:228-232 type-checks none of these; the decoded type becomes a table key + local cases = { + -- attribute type as context-specific [4]: currently keys as "s" + { "attr type as context [4]", + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 84 01 73 31 00" }, + -- attribute type as context-specific [16]: currently a TABLE key + { "attr type as context [16]", + "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 90 00 31 00" }, + -- attr type BOOLEAN decodes to nil; `attributes[nil] = vals` raises + { "attr type as BOOLEAN", + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00" }, + -- vals as context [4]: attributes.s becomes a STRING, not an array + { "vals as context [4]", + "30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 04 01 73 84 03 41 42 43" }, + -- attribute value as context [4] INSIDE a well-formed vals SET: pins asn1.decode's + -- recursive-descent class gate at a nested position (distinct from "vals as + -- context [4]" above, which hits protocol.lua's container pin instead) + { "attr value as context [4] inside vals", + "30 16 02 01 03 64 11 04 01 78 30 0c 30 0a 04 01 6d 31 05 84 03 41 42 43" }, + -- objectName as private [16]: entry_dn becomes a table + { "objectName as private [16]", + "30 10 02 01 03 64 0b d0 00 30 07 30 05 04 01 73 31 00" }, + } + + local leaks = {} + for _, c in ipairs(cases) do + -- pcall: a malformed packet must return (nil, err), never throw + local pok, res, err = pcall(protocol.decode_message, ldap_hex(c[2])) + if not pok then + table.insert(leaks, c[1] .. " RAISED a Lua error: " .. tostring(res)) + elseif res ~= nil or err == nil then + local ktypes = {} + for k, v in pairs(res.attributes or {}) do + table.insert(ktypes, type(k) .. "->" .. type(v)) + end + table.insert(leaks, string.format( + "%s accepted (entry_dn=%s attributes=[%s])", + c[1], type(res.entry_dn), table.concat(ktypes, ","))) + end + end + + if #leaks > 0 then + ngx.say(#leaks .. " SearchResultEntry class confusions:") + ngx.say(table.concat(leaks, "\n")) + else + ngx.say("ok") + end + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: SearchResultEntry container tags are not validated at all +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- parse_search_entry uses only .offset/.len, so any tag walks as a SEQUENCE + local cases = { + -- PartialAttributeList sent as a primitive OCTET STRING + { "attribute list as OCTET STRING 04", + "30 11 02 01 03 64 0c 04 01 78 04 07 30 05 04 01 73 31 00" }, + -- PartialAttribute sent as a primitive OCTET STRING + { "PartialAttribute as OCTET STRING 04", + "30 11 02 01 03 64 0c 04 01 78 30 07 04 05 04 01 73 31 00" }, + -- vals SET sent APPLICATION-class primitive + { "vals SET as application [17]", + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 04 01 73 51 00" }, + } + + local leaks = {} + for _, c in ipairs(cases) do + local res, err = protocol.decode_message(ldap_hex(c[2])) + if res ~= nil or err == nil then + table.insert(leaks, c[1] .. " accepted") + end + end + + if #leaks > 0 then + ngx.say(#leaks .. " container tag confusions accepted:") + ngx.say(table.concat(leaks, "\n")) + else + ngx.say("ok") + end + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: SearchResultReference URIs must be universal OCTET STRINGs +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- baseline + local base = assert(protocol.decode_message( + ldap_hex("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78"))) + assert(base.uris[1] == "ldap://x", "baseline uri") + + -- RFC 4511: URIs are universal OCTET STRINGs; a class-confused URI steers a referral + local r1, e1 = protocol.decode_message( + ldap_hex("30 0f 02 01 02 73 0a 84 08 6c 64 61 70 3a 2f 2f 78")) + assert(r1 == nil, "context [4] URI must be rejected, got " + .. tostring(r1 and r1.uris[1])) + assert(e1 ~= nil, "context [4] URI reports an error") + + -- context [16] members currently come back as tables inside uris + local r2, e2 = protocol.decode_message(ldap_hex("30 09 02 01 02 73 04 90 00 90 00")) + assert(r2 == nil, "context [16] URI must be rejected, got " + .. type(r2 and r2.uris and r2.uris[1])) + assert(e2 ~= nil, "context [16] URI reports an error") + + -- second-position wrong-class URI: the gate must hold after a valid first member + local r3, e3 = protocol.decode_message(ldap_hex("30 0b 02 01 02 73 06 04 01 61 84 01 62")) + assert(r3 == nil, "second-position context [4] URI must be rejected") + assert(e3 ~= nil, "second-position context [4] URI reports an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_constructed.t b/t/asn1_constructed.t new file mode 100644 index 0000000..6368b70 --- /dev/null +++ b/t/asn1_constructed.t @@ -0,0 +1,138 @@ +# Test vectors: original; spec X.690 §8.7.1/§8.9.1/§8.11.1. + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: primitive universal SEQUENCE/SET must be rejected, not walked +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Baseline: the constructed forms decode as containers. + local _, ok_seq = assert(asn1.decode(ldap_hex("30 03 02 01 05"))) + assert(type(ok_seq) == "table" and ok_seq[1] == 5, "constructed SEQUENCE decodes") + local _, ok_set = assert(asn1.decode(ldap_hex("31 03 02 01 05"))) + assert(type(ok_set) == "table" and ok_set[1] == 5, "constructed SET decodes") + + -- X.690 s8.9.1/s8.11.1: SEQUENCE/SET always constructed; 0x10/0x11 are illegal + local _, v1, e1 = asn1.decode(ldap_hex("10 03 02 01 05")) + assert(v1 == nil, "primitive SEQUENCE (0x10) yields no value, got " .. tostring(v1)) + assert(e1 ~= nil, "primitive SEQUENCE (0x10) reports an error") + + local _, v2, e2 = asn1.decode(ldap_hex("11 03 02 01 05")) + assert(v2 == nil, "primitive SET (0x11) yields no value, got " .. tostring(v2)) + assert(e2 ~= nil, "primitive SET (0x11) reports an error") + + -- empty primitive container: while loop never runs, returns {} silently + local _, v3, e3 = asn1.decode(ldap_hex("10 00")) + assert(v3 == nil, "empty primitive SEQUENCE yields no value") + assert(e3 ~= nil, "empty primitive SEQUENCE reports an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 2: the constructed check must not tighten accepted BER length leniency +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- guard rail: constructed-bit check must not reject BER non-minimal lengths + local _, v1 = assert(asn1.decode(ldap_hex("30 81 03 02 01 05"))) + assert(type(v1) == "table" and v1[1] == 5, "non-minimal-length SEQUENCE still decodes") + + local _, v2 = assert(asn1.decode(ldap_hex("31 81 03 04 01 41"))) + assert(type(v2) == "table" and v2[1] == "A", "non-minimal-length SET still decodes") + + local _, v3 = assert(asn1.decode(ldap_hex("04 81 03 41 42 43"))) + assert(v3 == "ABC", "non-minimal-length OCTET STRING still decodes, got " .. tostring(v3)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 3: primitive containers reached through decode_message must be rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- SearchResultEntry: dn "a", attribute "cn" with one value "x" + local function entry(pal, pa, set) + return "30 15 02 01 02 64 10 04 01 61 " .. pal .. " 0b " .. pa + .. " 09 04 02 63 6e " .. set .. " 03 04 01 78" + end + + local ok = assert(protocol.decode_message(ldap_hex(entry("30", "30", "31")))) + assert(ok.entry_dn == "a", "baseline entry_dn") + assert(ok.attributes.cn[1] == "x", "baseline attribute value") + + -- each flips one container to primitive; PAL/PA bypass decode_children + local cases = { + { "10", "30", "31", "PartialAttributeList primitive (0x10)" }, + { "30", "10", "31", "PartialAttribute primitive (0x10)" }, + { "30", "30", "11", "vals SET OF primitive (0x11)" }, + { "10", "10", "11", "every container primitive" }, + } + for _, c in ipairs(cases) do + local res, err = protocol.decode_message(ldap_hex(entry(c[1], c[2], c[3]))) + assert(res == nil, c[4] .. " must be rejected, got cn=" + .. tostring(res and res.attributes + and res.attributes.cn + and res.attributes.cn[1])) + assert(err ~= nil, c[4] .. " reports an error") + end + + -- the envelope itself: a primitive-encoded LDAPMessage must be rejected + local bad, berr = protocol.decode_message(ldap_hex("10 0c 02 01 01 61 07 0a 01 00 04 00 04 00")) + assert(bad == nil, "primitive LDAPMessage envelope must be rejected") + assert(berr ~= nil, "primitive LDAPMessage envelope must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_depth.t b/t/asn1_depth.t new file mode 100644 index 0000000..66ee41a --- /dev/null +++ b/t/asn1_depth.t @@ -0,0 +1,281 @@ +# Test vectors: pyasn1 v0.6.4 https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: unbounded nesting is refused with an error, never a crash +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + -- hand-rolled BER writer, independent of the library's encoder + local function tlv(tagbyte, content) + local n = #content + local len + if n < 128 then + len = string.char(n) + elseif n < 256 then + len = string.char(0x81, n) + elseif n < 65536 then + len = string.char(0x82, math.floor(n / 256), n % 256) + else + len = string.char(0x83, math.floor(n / 65536), + math.floor(n / 256) % 256, n % 256) + end + return string.char(tagbyte) .. len .. content + end + local function nest(tagbyte, n, inner) + local s = inner or "" + for _ = 1, n do s = tlv(tagbyte, s) end + return s + end + + for _, tb in ipairs({0x30, 0x31}) do + local label = string.format("tag 0x%02x", tb) + + -- comfortably inside the budget: must decode + local _, v, err = asn1.decode(nest(tb, 100)) + assert(err == nil, label .. " nest 100 errored: " .. tostring(err)) + assert(type(v) == "table", label .. " nest 100 value type") + + -- far outside it: must be a clean error, not a stack overflow + for _, deep in ipairs({102, 500, 2000}) do + local off, dv, derr = asn1.decode(nest(tb, deep)) + assert(dv == nil, label .. " nest " .. deep .. " returned a value") + assert(off == nil, label .. " nest " .. deep .. " returned an offset") + assert(derr == "max decode depth exceeded", + label .. " nest " .. deep .. " err = " .. tostring(derr)) + end + end + + -- context-specific [16] is not a SEQUENCE: refused on class grounds + local _, bv, berr = asn1.decode(nest(0xb0, 100)) + assert(bv == nil, "context [16] must not be walked as a container") + assert(berr ~= nil, "context [16] must report an error") + + -- alternating SEQ/SET must count the same as either alone + local mixed = "" + for i = 1, 500 do mixed = tlv(i % 2 == 0 and 0x30 or 0x31, mixed) end + local _, mv, merr = asn1.decode(mixed) + assert(mv == nil and merr == "max decode depth exceeded", + "alternating SEQ/SET nesting err = " .. tostring(merr)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: the enforced nesting bound matches MAX_DECODE_DEPTH +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function tlv(tagbyte, content) + local n = #content + local len + if n < 128 then len = string.char(n) + elseif n < 256 then len = string.char(0x81, n) + else len = string.char(0x82, math.floor(n / 256), n % 256) end + return string.char(tagbyte) .. len .. content + end + local function nest(tagbyte, n, inner) + local s = inner or "" + for _ = 1, n do s = tlv(tagbyte, s) end + return s + end + + for _, tb in ipairs({0x30, 0x31}) do + local label = string.format("tag 0x%02x", tb) + + local _, ok_v, ok_err = asn1.decode(nest(tb, 101)) + assert(ok_err == nil, label .. " 101 levels must decode: " .. tostring(ok_err)) + assert(type(ok_v) == "table", label .. " 101 levels value") + + local off, v, err = asn1.decode(nest(tb, 102)) + assert(v == nil, label .. " 102 levels must not yield a value") + assert(off == nil, label .. " 102 levels must not yield an offset") + assert(err == "max decode depth exceeded", + label .. " 102 levels err = " .. tostring(err)) + end + + -- same bound from a leaf: 101 containers + OCTET STRING = 102 levels, refused + local _, okv, okerr = asn1.decode(nest(0x30, 100, string.char(0x04, 0x01, 0x41))) + assert(okerr == nil and type(okv) == "table", + "100 containers + leaf must decode: " .. tostring(okerr)) + local _, lv, lerr = asn1.decode(nest(0x30, 101, string.char(0x04, 0x01, 0x41))) + assert(lv == nil and lerr == "max decode depth exceeded", + "101 containers + leaf err = " .. tostring(lerr)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: the depth budget is per path, not cumulative across siblings +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function tlv(tagbyte, content) + local n = #content + local len + if n < 128 then len = string.char(n) + elseif n < 256 then len = string.char(0x81, n) + elseif n < 65536 then len = string.char(0x82, math.floor(n / 256), n % 256) + else len = string.char(0x83, math.floor(n / 65536), + math.floor(n / 256) % 256, n % 256) end + return string.char(tagbyte) .. len .. content + end + local function nest(tagbyte, n, inner) + local s = inner or "" + for _ = 1, n do s = tlv(tagbyte, s) end + return s + end + + local siblings = "" + for _ = 1, 50 do siblings = siblings .. nest(0x30, 99) end + local _, v, err = asn1.decode(tlv(0x30, siblings)) + assert(err == nil, "wide-and-deep-but-legal errored: " .. tostring(err)) + assert(type(v) == "table", "wide-and-deep value type") + assert(#v == 50, "branch count " .. #v) + assert(type(v[1]) == "table" and type(v[50]) == "table", "branches are tables") + + -- a single over-budget branch is still caught next to good siblings + local mixed = nest(0x30, 10) .. nest(0x30, 200) .. nest(0x30, 10) + local _, bv, berr = asn1.decode(tlv(0x30, mixed)) + assert(bv == nil, "over-budget branch must not yield a value") + assert(berr == "max decode depth exceeded", "err = " .. tostring(berr)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: structural empties decode as empty arrays and keep their positions +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local off, v, err = asn1.decode(ldap_hex("30 00")) + assert(err == nil, "empty SEQUENCE errored: " .. tostring(err)) + assert(type(v) == "table" and #v == 0, "empty SEQUENCE is an empty array") + assert(off == 2, "empty SEQUENCE next offset " .. tostring(off)) + + -- 31 00 : empty SET + local off2, v2, err2 = asn1.decode(ldap_hex("31 00")) + assert(err2 == nil, "empty SET errored: " .. tostring(err2)) + assert(type(v2) == "table" and #v2 == 0, "empty SET is an empty array") + assert(off2 == 2, "empty SET next offset " .. tostring(off2)) + + -- nesting of empties: SEQUENCE { SEQUENCE {} } + local _, v3 = asn1.decode(ldap_hex("30 02 30 00")) + assert(#v3 == 1, "SEQ{empty SEQ} count " .. #v3) + assert(type(v3[1]) == "table" and #v3[1] == 0, "inner empty preserved") + + -- empty members must occupy array slots: SEQ { SEQ{}, SET{}, SEQ{SET{}} } + local _, v4 = asn1.decode(ldap_hex("30 08 30 00 31 00 30 02 31 00")) + assert(#v4 == 3, "mixed empties count " .. #v4) + assert(type(v4[1]) == "table" and #v4[1] == 0, "slot 1") + assert(type(v4[2]) == "table" and #v4[2] == 0, "slot 2") + assert(type(v4[3]) == "table" and #v4[3] == 1, "slot 3 holds one child") + assert(type(v4[3][1]) == "table" and #v4[3][1] == 0, "slot 3 inner empty") + + -- an empty container remains addressable at the deepest legal level + local deep = ldap_hex("30 00") + for _ = 1, 99 do + local n = #deep + local len + if n < 128 then len = string.char(n) + elseif n < 256 then len = string.char(0x81, n) + else len = string.char(0x82, math.floor(n / 256), n % 256) end + deep = string.char(0x30) .. len .. deep + end + local _, dv, derr = asn1.decode(deep) + assert(derr == nil, "100 levels ending in an empty SEQ errored: " .. tostring(derr)) + local t, levels = dv, 0 + while type(t) == "table" and t[1] ~= nil do levels = levels + 1; t = t[1] end + assert(levels == 99, "walked levels " .. levels) + assert(type(t) == "table" and #t == 0, "innermost is the empty SEQUENCE") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: a wide, shallow document decodes completely and in linear time +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + local n = 100000 + local body = string.rep("\4\1X", n) + local der = string.char(0x30, 0x83, + math.floor(#body / 65536), + math.floor(#body / 256) % 256, + #body % 256) .. body + + local t0 = ngx.now() + local off, v, err = asn1.decode(der) + ngx.update_time() + local elapsed = ngx.now() - t0 + + assert(err == nil, "wide document errored: " .. tostring(err)) + assert(type(v) == "table", "wide document value type") + assert(#v == n, "member count " .. #v .. ", expected " .. n) + assert(v[1] == "X" and v[n] == "X", "first/last member") + assert(off == #der, "consumed " .. tostring(off) .. " of " .. #der) + assert(elapsed < 2, "took " .. elapsed .. "s -- decode_children is not linear") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_encode.t b/t/asn1_encode.t new file mode 100644 index 0000000..61f410e --- /dev/null +++ b/t/asn1_encode.t @@ -0,0 +1,349 @@ +# Test vectors: rasn v0.6.1 https://github.com/librasn/rasn/blob/v0.6.1/src/ber/de.rs + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: encode() reports an explicit error for values it cannot encode +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + local function must_fail(what, val, tag) + local ok, v, err = pcall(asn1.encode, val, tag) + if not ok then return end -- raised: loud enough + assert(v == nil, what .. ": encoded a value it should have rejected") + assert(err ~= nil, what .. ": returned a silent nil with no error") + end + + -- no tag, and the Lua type maps to no tag (asn1.lua:167-169) + must_fail("nil value", nil, nil) + must_fail("table value", {}, nil) + must_fail("function value", print, nil) + + -- explicit tag that has no encoder entry + must_fail("TAG.NULL", "x", asn1.TAG.NULL) + must_fail("TAG.EOC", "x", asn1.TAG.EOC) + must_fail("unknown tag 99", "x", 99) + + -- the supported paths still encode + assert(asn1.encode("abc") == "\4\3abc", "octet string") + assert(asn1.encode(5) == "\2\1\5", "integer") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: encode(INTEGER) must not silently truncate or saturate +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end + + -- ASN1_INTEGER_set takes a C long and LuaJIT would truncate/saturate, emitting a + -- DIFFERENT integer -- integer_encodable rejects non-integral values and |v| > 2^53 + -- in Lua before the FFI call, so the C-long hazard is prevented upstream. The + -- encoder must return (nil, err) for these -- never raise, never substitute. + local function must_fail(what, val) + local v, err = asn1.encode(val, asn1.TAG.INTEGER) + -- hex(v) must tolerate nil v: assert message is built eagerly + assert(v == nil, what .. ": encoded as " .. hex(v or "") .. " instead of erroring") + assert(err ~= nil, what .. ": silent nil") + end + + must_fail("3.7 (fraction truncated to 3)", 3.7) + must_fail("-3.7 (fraction truncated to -3)", -3.7) + must_fail("0.5 (fraction truncated to 0)", 0.5) + must_fail("2^63 (out of range)", 2^63) + must_fail("1e100 (clamped to LONG_MAX)", 1e100) + must_fail("1e300 (clamped to LONG_MAX)", 1e300) + must_fail("-1e300 (negative out-of-range)", -1e300) + must_fail("math.huge (clamped to LONG_MAX)", math.huge) + must_fail("NaN (becomes 0)", 0/0) + must_fail("2^53+2 (just past the exactness boundary)", 2^53 + 2) + + -- values that genuinely fit a long must keep working + for _, n in ipairs({0, 1, -1, 127, -128, 128, 65536, 2147483647, 2^31, -2^31, 2^53}) do + local enc = assert(asn1.encode(n, asn1.TAG.INTEGER), "encode " .. n) + local _, v, err = asn1.decode(enc) + assert(err == nil, "decode " .. n .. ": " .. tostring(err)) + assert(v == n, "round trip " .. n .. " -> " .. tostring(v)) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: encode -> decode round trip across the length-form boundaries +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + local lens = {0, 1, 2, 126, 127, 128, 129, 254, 255, 256, 257, 1000, 65535, 65536} + + for _, n in ipairs(lens) do + local s = string.rep("A", n) + local enc = assert(asn1.encode(s, asn1.TAG.OCTET_STRING), "OS encode n=" .. n) + local off, v, err = asn1.decode(enc) + assert(err == nil, "OS n=" .. n .. " err " .. tostring(err)) + assert(v == s, "OS n=" .. n .. " value mismatch (got " .. + (v and #v or -1) .. " bytes)") + assert(off == #enc, "OS n=" .. n .. " consumed " .. tostring(off) .. + " of " .. #enc) + end + + -- binary-safe: NUL, high bytes, and a byte that looks like a tag + for _, s in ipairs({"", "\0", "A\0B", "\255\254\0\1", "\4\3abc", string.rep("\0", 300)}) do + local enc = assert(asn1.encode(s, asn1.TAG.OCTET_STRING)) + local _, v = asn1.decode(enc) + assert(v == s, "binary round trip failed for " .. #s .. " bytes") + end + + -- constructed containers built with put_object round trip too + for _, kids in ipairs({0, 1, 42, 43, 85, 86, 200}) do + local body = string.rep("\4\1X", kids) -- 3 bytes each + for _, tag in ipairs({asn1.TAG.SEQUENCE, asn1.TAG.SET}) do + local enc = assert(asn1.encode(body, tag), "container encode") + local off, v, err = asn1.decode(enc) + assert(err == nil, "container err " .. tostring(err)) + assert(type(v) == "table", "container is not a table") + assert(#v == kids, "container kids " .. #v .. " ~= " .. kids) + assert(off == #enc, "container consumed " .. tostring(off) .. "/" .. #enc) + end + end + + -- ENUMERATED round trip (result codes, search scope/deref) + for _, n in ipairs({0, 1, 2, 3, 10, 32, 49, 127, 128, 255}) do + local enc = assert(asn1.encode(n, asn1.TAG.ENUMERATED)) + local _, v, err = asn1.decode(enc) + assert(err == nil and v == n, "ENUM " .. n .. " -> " .. tostring(v)) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: BOOLEAN encodes boolean-ness, not Lua truthiness, and survives a round trip +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local function hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end + + local function must_not_be_true(what, val) + local ok, v = pcall(asn1.encode, val, asn1.TAG.BOOLEAN) + if not ok then return end -- rejecting is fine + if v == nil then return end -- so is nil + assert(v ~= "\1\1\255", what .. " encoded as BOOLEAN TRUE (" .. hex(v) .. ")") + end + must_not_be_true("the number 0", 0) + must_not_be_true("the empty string", "") + must_not_be_true("the string 'false'", "false") + + assert(asn1.encode(true, asn1.TAG.BOOLEAN) == "\1\1\255", "true -> ff") + assert(asn1.encode(false, asn1.TAG.BOOLEAN) == "\1\1\0", "false -> 00") + + -- Round trip: a BOOLEAN the library encoded must not decode to a silent nil + for _, b in ipairs({true, false}) do + local enc = assert(asn1.encode(b, asn1.TAG.BOOLEAN)) + local off, v, err = asn1.decode(enc) + assert(off == #enc or err ~= nil, "boolean offset") + assert(v == b or err ~= nil, + "encode(" .. tostring(b) .. ") decoded to a silent nil") + end + + -- protocol consequence: types_only=0 must not encode typesOnly=TRUE + local req = assert(protocol.search_request("dc=x", 2, 0, 0, 0, 0, "(cn=a)", {})) + assert(not req:find("\1\1\255", 1, true), + "search_request(types_only=0) encoded typesOnly=TRUE: " .. hex(req)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: put_object never emits a header whose length disagrees with its payload +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local function hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end + + local function check(what, ...) + local ok, out, err = pcall(asn1.put_object, ...) + if not ok then return end -- raised: acceptable + if out == nil then + assert(err ~= nil, what .. ": silent nil from put_object") + return + end + local obj = asn1.get_object(out) + assert(obj, what .. ": emitted unparseable bytes " .. hex(out)) + assert(obj.hl + obj.len == #out, + what .. ": header declares " .. obj.len .. " content bytes but " .. + (#out - obj.hl) .. " follow (" .. hex(out) .. ")") + end + + check("number payload", 0, asn1.CLASS.CONTEXT_SPECIFIC, 0, 12345) + check("boolean payload", 0, asn1.CLASS.CONTEXT_SPECIFIC, 0, true) + check("table payload", 0, asn1.CLASS.CONTEXT_SPECIFIC, 0, {}) + + -- negative length is already rejected, and must stay rejected + local out, err = asn1.put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, nil, -1) + assert(out == nil and err ~= nil, "negative length rejected") + + -- string and header-only payloads stay correct + for _, n in ipairs({0, 1, 127, 128, 255, 256, 65535, 65536}) do + local enc = assert(asn1.put_object(asn1.TAG.SEQUENCE, asn1.CLASS.UNIVERSAL, 1, + string.rep("A", n))) + local obj = assert(asn1.get_object(enc), "n=" .. n) + assert(obj.len == n and obj.hl + n == #enc, "n=" .. n .. " header") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: simple_bind_request must not silently mis-encode a non-string dn/password +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local function hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end + + local function bind_fields(msg) + local env = assert(asn1.get_object(msg, 0), "envelope") + local op = assert(asn1.get_object(msg, env.offset + 3), "protocolOp") + assert(op.class == asn1.CLASS.APPLICATION and op.tag == 0, "not a BindRequest") + local stop = op.offset + op.len + local ver = assert(asn1.get_object(msg, op.offset, stop), "version") + local name = assert(asn1.get_object(msg, ver.offset + ver.len, stop), "name") + local auth = assert(asn1.get_object(msg, name.offset + name.len, stop), "auth") + return name, auth, stop + end + + -- baseline + local ok_msg = assert(protocol.simple_bind_request("cn=admin,dc=x", "s3cret")) + local name, auth, stop = bind_fields(ok_msg) + assert(name.tag == asn1.TAG.OCTET_STRING and name.class == asn1.CLASS.UNIVERSAL, "name tag") + assert(auth.offset + auth.len == stop, "auth covers the rest of the BindRequest") + + -- numeric password: put_object emits a zero-length (unauthenticated) bind + stray bytes + local numeric_pw = assert(protocol.simple_bind_request("cn=admin,dc=x", 12345)) + local _, a2, stop2 = bind_fields(numeric_pw) + assert(a2.len > 0, + "numeric password produced a ZERO-LENGTH (unauthenticated) simple bind: " .. + hex(numeric_pw)) + assert(a2.offset + a2.len == stop2, + "numeric password left " .. (stop2 - a2.offset - a2.len) .. + " stray bytes after the auth element: " .. hex(numeric_pw)) + + -- numeric dn: encode() dispatches on Lua type, emitting INTEGER not OCTET STRING + local ok3, numeric_dn = pcall(protocol.simple_bind_request, 12345, "s3cret") + if ok3 and numeric_dn then + local n3 = bind_fields(numeric_dn) + assert(n3.tag == asn1.TAG.OCTET_STRING, + "numeric dn encoded with tag " .. n3.tag .. + " (expected 4/OCTET STRING): " .. hex(numeric_dn)) + end + + -- a wrong-shape dn must be reported, not raise a bare Lua error + local ok4, res, err4 = pcall(protocol.simple_bind_request, {}, "s3cret") + assert(ok4, "table dn raised a raw Lua error: " .. tostring(res)) + assert(res == nil and err4 ~= nil, "table dn should return nil, err") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: the message ID wraps back to 1 past RFC 4511 maxInt +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local upvalue = require("upvalue") + local message_id_of = require("ldap_msgid") + + -- the counter is module-private; reach it through the upvalue + -- chain simple_bind_request -> ldap_message -> ldapMessageId + local ldap_message = assert( + upvalue.get(protocol.simple_bind_request, "ldap_message"), + "ldap_message upvalue not found") + assert(upvalue.set(ldap_message, "ldapMessageId", 2147483647)) + + local at_max = assert(protocol.simple_bind_request("cn=x", "pw")) + assert(message_id_of(at_max) == 2147483647, + "the request at maxInt itself is legal") + local wrapped = assert(protocol.simple_bind_request("cn=x", "pw")) + local id = message_id_of(wrapped) + assert(id == 1, "id must wrap to 1 past maxInt, got " .. tostring(id)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_encode_leak.t b/t/asn1_encode_leak.t new file mode 100644 index 0000000..1191f02 --- /dev/null +++ b/t/asn1_encode_leak.t @@ -0,0 +1,159 @@ +# Test vectors: original — FFI heap ownership, no upstream analogue. + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: the i2d_* encoders must not leak the buffer OpenSSL allocates for them +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + -- i2d with NULL *pp makes OpenSSL malloc a buffer the encoder must free itself + + local function rss_kb() + local f = assert(io.open("/proc/self/status")) + local kb = 0 + for line in f:lines() do + local v = line:match("^VmRSS:%s+(%d+) kB") + if v then kb = tonumber(v) end + end + f:close() + return kb + end + + local function growth_mb(n, fn) + for i = 1, 100 do fn(i) end -- warm up JIT + allocator + collectgarbage("collect"); collectgarbage("collect") + local before = rss_kb() + for i = 1, n do fn(i) end + collectgarbage("collect"); collectgarbage("collect") + return (rss_kb() - before) / 1024 + end + + local SZ = 4000 + local val = string.rep("A", SZ) + + -- thresholds sit under each encoder's expected leak; small encoders need more iters + + -- Control: the SEQUENCE encoder (ASN1_put_object) allocates nothing + local control = growth_mb(20000, function() + return asn1.encode(val, asn1.TAG.SEQUENCE) + end) + assert(control < 8, + ("control (put_object) path itself grew %.1f MB -- " .. + "measurement is unreliable"):format(control)) + + -- ~76 MB if every i2d buffer leaked; a correct encoder stays flat + local octet = growth_mb(20000, function() + return asn1.encode(val, asn1.TAG.OCTET_STRING) + end) + assert(octet < 8, + ("i2d_ASN1_OCTET_STRING leaked: RSS grew %.1f MB over " .. + "20000 encodes (control %.1f MB)"):format(octet, control)) + + -- INTEGER/ENUMERATED leak ~11 bytes/call; ~15 MB at 500000 iters, 4 MB threshold + local int = growth_mb(500000, function(i) + return asn1.encode(i, asn1.TAG.INTEGER) + end) + assert(int < 4, + ("i2d_ASN1_INTEGER leaked: RSS grew %.1f MB over " .. + "500000 encodes"):format(int)) + + local enum = growth_mb(500000, function(i) + return asn1.encode(i % 128, asn1.TAG.ENUMERATED) + end) + assert(enum < 4, + ("i2d_ASN1_ENUMERATED leaked: RSS grew %.1f MB over " .. + "500000 encodes"):format(enum)) + + -- Freeing must not change a single output byte. + assert(asn1.encode("A", asn1.TAG.OCTET_STRING) == "\4\1A") + assert(asn1.encode("", asn1.TAG.OCTET_STRING) == "\4\0") + assert(asn1.encode(5, asn1.TAG.INTEGER) == "\2\1\5") + assert(asn1.encode("\0\255\0", asn1.TAG.OCTET_STRING) == "\4\3\0\255\0") + + ngx.say("ok") + } + } +--- request +GET /t +--- timeout: 60 +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: the leak is reachable from the public request builders +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + + -- bind/search requests build messageIDs and strings via the leaking encoders + + local function rss_kb() + local f = assert(io.open("/proc/self/status")) + local kb = 0 + for line in f:lines() do + local v = line:match("^VmRSS:%s+(%d+) kB") + if v then kb = tonumber(v) end + end + f:close() + return kb + end + + local function growth_mb(n, fn) + for i = 1, 50 do assert(fn(i)) end + collectgarbage("collect"); collectgarbage("collect") + local before = rss_kb() + for i = 1, n do assert(fn(i)) end + collectgarbage("collect"); collectgarbage("collect") + return (rss_kb() - before) / 1024 + end + + local dn = "cn=" .. string.rep("u", 1000) .. ",dc=example,dc=com" + + local bind = growth_mb(20000, function(i) + return protocol.simple_bind_request(dn, "secret", i) + end) + assert(bind < 8, + ("simple_bind_request leaked: RSS grew %.1f MB over " .. + "20000 requests"):format(bind)) + + local search = growth_mb(20000, function(i) + return protocol.search_request(dn, 2, 0, 0, 0, false, + "(objectClass=" .. string.rep("x", 500) .. ")", + { "cn", "mail", "uid" }, i) + end) + assert(search < 8, + ("search_request leaked: RSS grew %.1f MB over " .. + "20000 requests"):format(search)) + + ngx.say("ok") + } + } +--- request +GET /t +--- timeout: 60 +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_ffi.t b/t/asn1_ffi.t new file mode 100644 index 0000000..10e19b5 --- /dev/null +++ b/t/asn1_ffi.t @@ -0,0 +1,176 @@ +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: put_object header length must agree with the bytes it returns +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + + -- Invariant: put_object returns exactly one TLV; reparsing consumes it all + local function self_consistent(res, what) + assert(type(res) == "string", what .. ": expected a string") + local o = assert(asn1.get_object(res), what .. ": result does not parse") + assert(o.hl + o.len == #res, + string.format("%s: header declares %d content bytes after a %d-byte " + .. "header but %d bytes were returned", + what, o.len, o.hl, #res)) + end + + -- Sanity: string payloads hold the invariant today. + for _, n in ipairs({0, 1, 127, 128, 300}) do + self_consistent(asn1.put_object(asn1.TAG.OCTET_STRING, asn1.CLASS.UNIVERSAL, + 0, string.rep("A", n)), + "string payload n=" .. n) + end + + -- a non-string payload is treated as length 0 but still appended: header disagrees + local res, err = asn1.put_object(asn1.TAG.OCTET_STRING, asn1.CLASS.UNIVERSAL, 0, 12345) + if res ~= nil then + self_consistent(res, "number payload 12345") + else + assert(err ~= nil, "rejecting a non-string payload must report an error") + end + + -- length above INT_MAX is silently clamped by FFI; the len < 0 guard misses it + for _, n in ipairs({2147483648, 4294967296, 2 ^ 53}) do + local r2, e2 = asn1.put_object(asn1.TAG.OCTET_STRING, asn1.CLASS.UNIVERSAL, 0, nil, n) + assert(r2 == nil, "length " .. string.format("%.0f", n) + .. " exceeds the int the C API accepts and must be rejected, " + .. "not silently clamped") + assert(e2 ~= nil, "over-large length must report an error") + end + + -- Non-integer lengths land in the same `int length` parameter. + local r3 = asn1.put_object(asn1.TAG.OCTET_STRING, asn1.CLASS.UNIVERSAL, 0, nil, 3.7) + assert(r3 == nil, "a fractional length must be rejected, not truncated to 3") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: high-tag-form encodings of low tags must not alias the short form +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- high-tag-number form aliases low tags (1f 04 == 04); RFC 4511 defines no tag >= 31 + local canonical = select(2, asn1.decode(ldap_hex("04 03 41 42 43"))) + assert(canonical == "ABC", "canonical OCTET STRING still decodes") + + for _, alias in ipairs({"1f 04 03 41 42 43", + "1f 80 04 03 41 42 43", + "1f 80 80 80 04 03 41 42 43"}) do + local o, gerr = asn1.get_object(ldap_hex(alias)) + assert(o == nil, "high-tag form '" .. alias .. "' must be rejected, not reported as tag " + .. tostring(o and o.tag)) + assert(gerr ~= nil, "high-tag form '" .. alias .. "' must report an error") + + local _, v, derr = asn1.decode(ldap_hex(alias)) + assert(v == nil, "high-tag form '" .. alias .. "' must not decode to " .. tostring(v)) + assert(derr ~= nil, "high-tag form '" .. alias .. "' must report a decode error") + end + + -- the alias reaches the LDAP message layer (envelope 30 -> 3f 80 10, op 61 -> 7f 01) + local ok_res = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok_res.result_code == 0, "canonical BindResponse still parses") + + local bad1, e1 = protocol.decode_message(ldap_hex("3f 80 10 0c 02 01 01 61 07 0a 01 00 04 00 04 00")) + assert(bad1 == nil and e1 ~= nil, + "an LDAPMessage envelope in high-tag form must be rejected") + + local bad2, e2 = protocol.decode_message(ldap_hex("30 0d 02 01 01 7f 01 07 0a 01 00 04 00 04 00")) + assert(bad2 == nil and e2 ~= nil, + "a protocolOp tag in high-tag form must be rejected") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: FFI marshalling invariants that currently hold (regression guard) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- (a) get_object return contract: reachable values 0x00/0x20/0x21/0x80|x; 0x01 test load-bearing + local o0 = assert(asn1.get_object(ldap_hex("04 03 41 42 43"))) + assert(o0.cons == false, "ret 0x00 -> primitive") + local o20 = assert(asn1.get_object(ldap_hex("30 03 02 01 05"))) + assert(o20.cons == true, "ret 0x20 -> constructed") + -- ret 0x21: constructed indefinite length, forbidden by RFC 4511 s5.1 + assert(asn1.get_object(ldap_hex("30 80 02 01 05 00 00")) == nil, + "ret 0x21 (indefinite length) must be rejected by the 0x01 bit") + -- ret 0x80: TOO_LONG. OpenSSL still writes the shared buffers; don't trust them + assert(asn1.get_object(ldap_hex("30 05 02 01")) == nil, "ret 0x80 must be rejected") + assert(asn1.get_object(ldap_hex("1f")) == nil, "truncated high-tag header must be rejected") + + -- (b) shared module-level buffers must be snapshotted so nested calls don't clobber + local leaf = ldap_hex("31 81 84") .. string.rep("\4\1X", 44) + local der = ldap_hex("30 81 87") .. leaf + local outer = assert(asn1.get_object(der)) + assert(outer.tag == 16 and outer.len == 135 and outer.hl == 3 and outer.offset == 3, + "outer header parsed") + local inner = assert(asn1.get_object(der, outer.offset, outer.offset + outer.len)) + local deep = assert(asn1.get_object(der, inner.offset, inner.offset + inner.len)) + assert(inner.tag == 17 and inner.hl == 3 and inner.len == 132, "inner header parsed") + assert(deep.tag == 4 and deep.hl == 2 and deep.len == 1, "leaf header parsed") + assert(outer.tag == 16 and outer.len == 135 and outer.hl == 3 and outer.offset == 3 + and outer.cons == true, + "nested get_object calls must not disturb an outer result") + assert(outer ~= inner and inner ~= deep, "each call returns a distinct table") + + -- (c) offset/hl/len must be plain Lua numbers, not cdata + assert(type(outer.len) == "number", "len is a Lua number") + assert(type(outer.offset) == "number", "offset is a Lua number") + assert(type(outer.hl) == "number", "hl is a Lua number") + + -- (d) hdrbuf is 16 bytes; put_object worst case is 11 (high-tag path), not 6 + local worst = asn1.put_object(2147483647, asn1.CLASS.PRIVATE, 1, nil, 2147483647) + assert(#worst == 11, + "worst-case ASN1_put_object header is 11 bytes, got " .. #worst) + assert(#worst <= 16, "worst-case header must fit hdrbuf") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_integer.t b/t/asn1_integer.t new file mode 100644 index 0000000..3bd7208 --- /dev/null +++ b/t/asn1_integer.t @@ -0,0 +1,285 @@ +# pyasn1 v0.6.4 IntegerDecoderTestCase: https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py +# Go go1.26.3 int64TestData: https://github.com/golang/go/blob/go1.26.3/src/encoding/asn1/asn1_test.go + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: INTEGER/ENUMERATED inside the exactly-representable range decode correctly +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local cases = { + {"02 01 00", 0}, + {"02 01 01", 1}, + {"02 01 ff", -1}, + {"02 01 7f", 127}, + {"02 02 00 80", 128}, + {"02 01 80", -128}, + {"02 02 00 ff", 255}, + {"02 02 01 00", 256}, + {"02 02 ff 7f", -129}, + {"02 04 7f ff ff ff", 2147483647}, -- 2^31-1 + {"02 05 00 80 00 00 00", 2147483648}, -- 2^31 + {"02 04 80 00 00 00", -2147483648}, + {"02 05 00 ff ff ff ff", 4294967295}, -- 2^32-1 + {"02 05 01 00 00 00 00", 4294967296}, -- 2^32 + {"02 07 1f ff ff ff ff ff ff", 9007199254740991}, -- 2^53-1 + {"02 07 20 00 00 00 00 00 00", 9007199254740992}, -- 2^53 + {"02 07 e0 00 00 00 00 00 00", -9007199254740992}, -- -2^53, negative boundary + -- ENUMERATED must agree with INTEGER over the same range + {"0a 01 00", 0}, + {"0a 01 ff", -1}, + {"0a 01 31", 49}, -- invalidCredentials + {"0a 01 20", 32}, -- noSuchObject + {"0a 04 7f ff ff ff", 2147483647}, + {"0a 05 00 ff ff ff ff", 4294967295}, + {"0a 05 01 00 00 00 00", 4294967296}, + } + for _, c in ipairs(cases) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err == nil, c[1] .. " unexpected error: " .. tostring(err)) + assert(v == c[2], c[1] .. " decoded " .. tostring(v) .. ", want " .. tostring(c[2])) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: no non-zero encoding may decode to 0 (resultCode success cannot be forged) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + for _, tag in ipairs({2, 10}) do + for b = 0, 255 do + local _, v = asn1.decode(string.char(tag, 1, b)) + if v == 0 then + assert(b == 0, string.format("%02x 01 %02x decoded to 0", tag, b)) + end + end + for b1 = 0, 255 do + for b2 = 0, 255 do + local _, v = asn1.decode(string.char(tag, 2, b1, b2)) + assert(v ~= 0, string.format("%02x 02 %02x %02x decoded to 0", tag, b1, b2)) + end + end + end + + -- LDAPMessage layer: overflowing resultCodes may never surface as 0 (success). + local hostile = { + "30 14 02 01 01 61 0f 0a 09 00 ff ff ff ff ff ff ff ff 04 00 04 00", -- 2^64-1 + "30 14 02 01 01 61 0f 0a 09 01 00 00 00 00 00 00 00 00 04 00 04 00", -- 2^64 + "30 14 02 01 01 61 0f 0a 09 ff 7f ff ff ff ff ff ff ff 04 00 04 00", -- -(2^63+1) + "30 14 02 01 01 61 0f 0a 09 fe 00 00 00 00 00 00 00 00 04 00 04 00", -- -2^65 + } + for _, pkt in ipairs(hostile) do + local res = protocol.decode_message(ldap_hex(pkt)) + if res then + assert(res.result_code ~= 0, "overflow forged a success resultCode: " .. pkt) + end + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: an INTEGER too large to represent exactly must error, not become -1 +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local overflow = { + {"02 09 00 80 00 00 00 00 00 00 00", "2^63"}, + {"02 09 00 80 00 00 00 00 00 00 01", "2^63+1"}, + {"02 09 00 ff ff ff ff ff ff ff ff", "2^64-1"}, + {"02 09 01 00 00 00 00 00 00 00 00", "2^64"}, + {"02 09 ff 7f ff ff ff ff ff ff ff", "-(2^63+1)"}, + {"02 09 fe 00 00 00 00 00 00 00 00", "-2^65"}, + {"02 0c 01 00 00 00 00 00 00 00 00 00 00 00", "2^88"}, + {"02 08 7f ff ff ff ff ff ff ff", "2^63-1 (maxint64, 8-octet: exact_number path, not the len>8 guard)"}, + {"02 09 80 00 00 00 00 00 00 00 00", "9-octet negative (Go encoding/asn1 int64 overflow vector)"}, + } + for _, c in ipairs(overflow) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err ~= nil, + c[2] .. " (" .. c[1] .. ") must be rejected, got " .. tostring(v)) + assert(v == nil, c[2] .. " must not yield a value alongside the error") + end + + -- and the sentinel must stay distinguishable from a genuine -1 + local _, real = asn1.decode(ldap_hex("02 01 ff")) + assert(real == -1, "genuine -1 must still decode as -1, got " .. tostring(real)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: an ENUMERATED too large to represent must error, and must never flip sign +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local overflow = { + {"0a 09 00 80 00 00 00 00 00 00 00", "2^63"}, + {"0a 09 00 ff ff ff ff ff ff ff ff", "2^64-1"}, + {"0a 09 01 00 00 00 00 00 00 00 00", "2^64"}, + {"0a 09 ff 7f ff ff ff ff ff ff ff", "-(2^63+1)"}, + {"0a 09 ff 00 00 00 00 00 00 00 00", "-2^64"}, + {"0a 09 fe 00 00 00 00 00 00 00 00", "-2^65"}, + {"0a 0c 01 00 00 00 00 00 00 00 00 00 00 00", "2^88"}, + {"0a 07 20 00 00 00 00 00 01", "2^53+1 (7-octet: ENUMERATED exact_number path, not the len>8 guard)"}, + } + for _, c in ipairs(overflow) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err ~= nil, + c[2] .. " (" .. c[1] .. ") must be rejected, got " .. tostring(v)) + assert(v == nil, c[2] .. " must not yield a value alongside the error") + end + + -- sign inversion: -2^65 is negative; decoder must never report positive. + local _, neg = asn1.decode(ldap_hex("0a 09 fe 00 00 00 00 00 00 00 00")) + assert(neg == nil or neg < 0, + "-2^65 decoded as positive " .. tostring(neg)) + + -- 4294967295 (< 2^53) must still decode; ENUM counterpart of TEST 3's -1 guard. + local _, legit = asn1.decode(ldap_hex("0a 05 00 ff ff ff ff")) + assert(legit == 4294967295, "genuine 4294967295 broke: " .. tostring(legit)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: distinct integers above 2^53 must not collapse onto the same value +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local pairs_ = { + {"02 07 20 00 00 00 00 00 00", "02 07 20 00 00 00 00 00 01", "2^53 vs 2^53+1"}, + {"02 08 7f ff ff ff ff ff ff fe", "02 08 7f ff ff ff ff ff ff ff", "2^63-2 vs 2^63-1"}, + {"0a 07 20 00 00 00 00 00 00", "0a 07 20 00 00 00 00 00 01", "ENUM 2^53 vs 2^53+1"}, + {"02 07 df ff ff ff ff ff ff", "02 07 e0 00 00 00 00 00 00", "-(2^53+1) vs -2^53"}, + } + for _, c in ipairs(pairs_) do + local _, a, aerr = asn1.decode(ldap_hex(c[1])) + local _, b, berr = asn1.decode(ldap_hex(c[2])) + assert(not (aerr == nil and berr == nil and a == b), + c[3] .. " both decoded to " .. tostring(a)) + end + + -- 2^53+1 is 9007199254740993; returning 9007199254740992 is silently wrong. + local _, v, err = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) + assert(err ~= nil or v ~= 9007199254740992, + "2^53+1 silently decoded as 2^53") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: overflowing messageID/resultCode is rejected at the LDAPMessage layer +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local bad = { + {"30 14 02 09 00 ff ff ff ff ff ff ff ff 61 07 0a 01 00 04 00 04 00", "messageID 2^64-1"}, + {"30 12 02 07 20 00 00 00 00 00 01 61 07 0a 01 00 04 00 04 00", "messageID 2^53+1"}, + {"30 14 02 01 01 61 0f 0a 09 00 ff ff ff ff ff ff ff ff 04 00 04 00", "resultCode 2^64-1 (must never surface as resultCode -1)"}, + {"30 14 02 01 01 61 0f 0a 09 fe 00 00 00 00 00 00 00 00 04 00 04 00", "resultCode -2^65"}, + } + for _, c in ipairs(bad) do + local res, err = protocol.decode_message(ldap_hex(c[1])) + assert(res == nil and err ~= nil, + c[2] .. " must be rejected; got code=" .. + tostring(res and res.result_code) .. " msgid=" .. + tostring(res and res.message_id)) + end + + -- distinct wire messageIDs (2^53+1 vs 2^53) must never correlate to the same in-process id + local r1 = protocol.decode_message(ldap_hex("30 12 02 07 20 00 00 00 00 00 01 61 07 0a 01 00 04 00 04 00")) + local r2 = protocol.decode_message(ldap_hex("30 12 02 07 20 00 00 00 00 00 00 61 07 0a 01 00 04 00 04 00")) + assert(not (r1 and r2 and r1.message_id == r2.message_id), + "distinct wire messageIDs collided on " .. tostring(r1 and r1.message_id)) + + -- a well-formed message still decodes + local ok_res = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok_res.result_code == 0 and ok_res.message_id == 1, "baseline intact") + + -- RFC 4511 maxInt messageID still decodes (Go go1.26.3 encoding/asn1 INTEGER-boundary vector) + local max_res = assert(protocol.decode_message(ldap_hex("30 0f 02 04 7f ff ff ff 61 07 0a 01 00 04 00 04 00"))) + assert(max_res.message_id == 2147483647, "maxInt messageID " .. tostring(max_res.message_id)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_nil_member.t b/t/asn1_nil_member.t new file mode 100644 index 0000000..f6e0a78 --- /dev/null +++ b/t/asn1_nil_member.t @@ -0,0 +1,208 @@ +# pyasn1 v0.6.4 Sequence/SetDecoderTestCase DefMode: https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py +# Vectors also cross-checked against rasn v0.6.1 and Go encoding/asn1. + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: an undecodable member must error, never shrink the array +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- SET { "A", BOOLEAN TRUE }: BOOLEAN has no decoder; slot silently dropped -> {"A"}. + local off, v, err = asn1.decode(ldap_hex("31 06 04 01 41 01 01 ff")) + assert(err ~= nil, "undecodable SET member must report an error") + assert(v == nil, "no value when a member cannot be decoded") + assert(off == nil, "no offset alongside the error") + + -- rasn v0.6.1 sequence() vector: the undecodable member is an IA5String (0x16) + local off2, v2, err2 = asn1.decode(ldap_hex("30 0a 16 05 53 6d 69 74 68 01 01 ff")) + assert(err2 ~= nil, "SEQUENCE with undecodable members must report an error, got value=" .. + tostring(v2) .. " n=" .. (type(v2) == "table" and #v2 or -1)) + assert(v2 == nil, "no partial value alongside the error") + assert(off2 == nil, "no offset alongside the error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: position is not silently reindexed, and all-undecodable is not "empty" +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Undecodable member FIRST: currently yields {"A"} at index 1 (member 2's value). + local off, v, err = asn1.decode(ldap_hex("31 06 01 01 ff 04 01 41")) + assert(err ~= nil, "leading undecodable member must report an error") + assert(v == nil, "no value when a member cannot be decoded") + assert(off == nil, "no offset alongside the error") + + -- pyasn1 v0.6.4: undecodable MIDDLE member (the suite's only middle-position case) + local off2, v2, err2 = asn1.decode(ldap_hex("30 09 02 01 01 01 01 ff 02 01 02")) + assert(err2 ~= nil, "undecodable middle member must error, not re-index the array") + assert(v2 == nil, "no partial array alongside the error") + assert(off2 == nil, "no offset alongside the error") + + -- SET with only an undecodable member currently decodes to {}, like a genuine empty SET. + local off3, v3, err3 = asn1.decode(ldap_hex("31 03 01 01 ff")) + assert(err3 ~= nil, "SET of one undecodable member must error") + assert(v3 == nil, "no value for an all-undecodable SET") + assert(off3 == nil, "no offset alongside the error") + + -- ...while a genuinely empty SET stays a clean empty array. + local _, v4, err4 = asn1.decode(ldap_hex("31 00")) + assert(err4 == nil, "empty SET is still valid: " .. tostring(err4)) + assert(type(v4) == "table" and #v4 == 0, "empty SET decodes to {}") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: NULL members and EOC filler inside a definite-length container +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- SEQUENCE { OCTET STRING "A", NULL }: the NULL slot vanishes -> {"A"} + local off, v, err = asn1.decode(ldap_hex("30 05 04 01 41 05 00")) + assert(err ~= nil, "NULL member must report an error, not vanish") + assert(v == nil, "no value when a NULL member is dropped") + assert(off == nil, "no offset alongside the error") + + -- Stray EOC filler in a definite-length container (30 02); tag-0 TLV that vanishes -> {}. + local off2, v2, err2 = asn1.decode(ldap_hex("30 02 00 00")) + assert(err2 ~= nil, "EOC filler in a definite-length SEQUENCE must error") + assert(v2 == nil, "no value for EOC filler") + assert(off2 == nil, "no offset alongside the error") + + -- pyasn1 v0.6.4 DefMode: NULL first with two decodable members after, SEQUENCE and SET + local _, v3, err3 = asn1.decode( + ldap_hex("30 12 05 00 04 0b 71 75 69 63 6b 20 62 72 6f 77 6e 02 01 01")) + assert(err3 ~= nil, "NULL-led SEQUENCE must error rather than silently shrink; got n=" .. + (type(v3) == "table" and #v3 or -1)) + assert(v3 == nil, "no partial array alongside the error") + local _, v4, err4 = asn1.decode( + ldap_hex("31 12 05 00 04 0b 71 75 69 63 6b 20 62 72 6f 77 6e 02 01 01")) + assert(err4 ~= nil, "NULL-led SET must error rather than silently shrink") + assert(v4 == nil, "no partial array alongside the error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: an attribute value must not silently vanish from a SearchResultEntry +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- Via protocol.lua: SearchResultEntry vals { "a", NULL } (msgID 1, dc=x, cn). + local pkt = ldap_hex([[30 1a 02 01 01 64 15 + 04 04 64 63 3d 78 + 30 0d 30 0b 04 02 63 6e + 31 05 04 01 61 05 00]]) + local res, err = protocol.decode_message(pkt) + assert(res == nil, "entry with an undecodable attribute value is rejected") + assert(err ~= nil, "undecodable attribute value reports an error") + + -- Same shape with a BOOLEAN instead of NULL in the vals SET. + local pkt2 = ldap_hex([[30 1b 02 01 01 64 16 + 04 04 64 63 3d 78 + 30 0e 30 0c 04 02 63 6e + 31 06 04 01 61 01 01 ff]]) + local res2, err2 = protocol.decode_message(pkt2) + assert(res2 == nil and err2 ~= nil, "BOOLEAN in vals SET reports an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: guard -- well-formed containers keep their full arity and stay error-free +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- Every member has a decoder: nothing may be lost and nothing may error. + local _, v, err = asn1.decode(ldap_hex("31 06 04 01 41 04 01 42")) + assert(err == nil, "well-formed SET must not error: " .. tostring(err)) + assert(#v == 2 and v[1] == "A" and v[2] == "B", "both members present") + + -- Mixed member types inside a SEQUENCE: INTEGER, OCTET STRING, ENUMERATED + local _, v2, err2 = asn1.decode(ldap_hex("30 09 02 01 07 04 01 41 0a 01 00")) + assert(err2 == nil, "mixed SEQUENCE must not error: " .. tostring(err2)) + assert(#v2 == 3 and v2[1] == 7 and v2[2] == "A" and v2[3] == 0, "three members present") + + -- A real multi-valued entry still round-trips with full arity. + local res = assert(protocol.decode_message(ldap_hex([[30 49 02 01 02 64 44 + 04 11 64 63 3d 65 78 61 6d 70 6c 65 2c 64 63 3d 63 6f 6d + 30 2f + 30 1c 04 0b 6f 62 6a 65 63 74 43 6c 61 73 73 + 31 0d 04 03 74 6f 70 04 06 64 6f 6d 61 69 6e + 30 0f 04 02 64 63 + 31 09 04 07 65 78 61 6d 70 6c 65]]))) + assert(#res.attributes.objectClass == 2, "objectClass keeps 2 values") + assert(#res.attributes.dc == 1, "dc keeps 1 value") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_oracle.t b/t/asn1_oracle.t new file mode 100644 index 0000000..a8010dc --- /dev/null +++ b/t/asn1_oracle.t @@ -0,0 +1,194 @@ +# Go go1.26.3 encoding/asn1 (differential oracle): https://github.com/golang/go/blob/go1.26.3/src/encoding/asn1/asn1_test.go +# pyasn1 v0.6.4: https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: INTEGER is exact or rejected, never silently rounded or wrapped +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- 2^53+1 = 9007199254740993; ASN1_INTEGER_get double-rounds to 9007199254740992. + local _, v1, e1 = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 01")) + assert(e1 ~= nil, "2^53+1 must be rejected, got " .. tostring(v1)) + assert(v1 ~= 9007199254740992, "2^53+1 must not round to 2^53") + + -- -(2^53+1) + local _, v2, e2 = asn1.decode(ldap_hex("02 07 df ff ff ff ff ff ff")) + assert(e2 ~= nil, "-(2^53+1) must be rejected, got " .. tostring(v2)) + + -- 2^63-1 = 9223372036854775807, rounds up to ...808 as a double + local _, v3, e3 = asn1.decode(ldap_hex("02 08 7f ff ff ff ff ff ff ff")) + assert(e3 ~= nil, "2^63-1 must be rejected, got " .. tostring(v3)) + + local _, v4, e4 = asn1.decode(ldap_hex("02 09 00 ff ff ff ff ff ff ff ff")) + assert(v4 ~= -1, "2^64-1 must not decode as -1") + assert(e4 ~= nil, "2^64-1 must be rejected, got " .. tostring(v4)) + + -- 20-byte INTEGER also overflows to -1 today + local _, v5, e5 = asn1.decode(ldap_hex("02 14 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff")) + assert(v5 ~= -1, "160-bit INTEGER must not decode as -1") + assert(e5 ~= nil, "160-bit INTEGER must be rejected") + + -- Exactly-representable values keep working; last is RFC 4511 s4.1.1 maxInt (messageID bound). + local _, ok1 = asn1.decode(ldap_hex("02 01 ff")) + assert(ok1 == -1, "genuine -1 still decodes, got " .. tostring(ok1)) + local _, ok2 = asn1.decode(ldap_hex("02 07 20 00 00 00 00 00 00")) + assert(ok2 == 9007199254740992, "2^53 exact, got " .. tostring(ok2)) + local _, ok3 = asn1.decode(ldap_hex("02 05 01 00 00 00 00")) + assert(ok3 == 4294967296, "2^32 exact, got " .. tostring(ok3)) + local _, ok4 = asn1.decode(ldap_hex("02 04 7f ff ff ff")) + assert(ok4 == 2147483647, "maxInt messageID exact, got " .. tostring(ok4)) + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: high-tag-number identifier octets are not an alias for a low tag +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local aliases = { + "1f 04 03 41 42 43", -- high-tag form for tag 4 + "1f 80 04 03 41 42 43", -- + one 0x80 padding octet + "1f 80 80 04 03 41 42 43", -- + two + "1f 80 80 80 04 03 41 42 43", -- + three + } + for _, hex in ipairs(aliases) do + local _, v, err = asn1.decode(ldap_hex(hex)) + assert(v ~= "ABC", "alias " .. hex .. " must not decode as OCTET STRING ABC") + assert(err ~= nil, "alias " .. hex .. " must report an error") + end + + -- same trick on SEQUENCE: 3f 10 aliases 30 + local _, sv, serr = asn1.decode(ldap_hex("3f 10 03 02 01 05")) + assert(type(sv) ~= "table", "3f 10 must not decode as a SEQUENCE") + assert(serr ~= nil, "3f 10 must report an error") + + -- and with a class bit set: 9f 04 aliases context [4] + local _, cv, cerr = asn1.decode(ldap_hex("9f 04 03 41 42 43")) + assert(cv ~= "ABC", "9f 04 must not decode as OCTET STRING ABC") + assert(cerr ~= nil, "9f 04 must report an error") + + -- canonical encodings are untouched + local _, good = asn1.decode(ldap_hex("04 03 41 42 43")) + assert(good == "ABC", "canonical OCTET STRING still decodes") + local _, goodseq = asn1.decode(ldap_hex("30 03 02 01 05")) + assert(type(goodseq) == "table" and goodseq[1] == 5, "canonical SEQUENCE still decodes") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: a non-canonical tag cannot impersonate the LDAPMessage envelope +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local bad, err = protocol.decode_message( + ldap_hex("3f 10 0c 02 01 01 61 07 0a 01 00 04 00 04 00")) + assert(bad == nil, "high-tag-aliased envelope must be rejected") + assert(err ~= nil, "high-tag-aliased envelope must report an error") + + -- the canonical form of the same message still parses + local ok = assert(protocol.decode_message( + ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok.result_code == 0, "canonical BindResponse still parses") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: differential agreements that must keep holding +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + local _, l1 = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(l1 == "ABC", "81-form length still accepted") + local _, l2 = asn1.decode(ldap_hex("04 82 00 03 41 42 43")) + assert(l2 == "ABC", "82-form length still accepted") + local _, l3 = asn1.decode(ldap_hex("04 84 00 00 00 03 41 42 43")) + assert(l3 == "ABC", "84-form length still accepted") + local _, l4 = asn1.decode(ldap_hex("30 81 05 04 03 41 42 43")) + assert(type(l4) == "table" and l4[1] == "ABC", "non-minimal SEQUENCE length accepted") + + -- indefinite length stays rejected; RFC 4511 s5.1 restricts LDAP to definite-length (Go agrees). + local _, i1, ierr = asn1.decode(ldap_hex("30 80 04 03 41 42 43 00 00")) + assert(i1 == nil and ierr ~= nil, "indefinite length rejected") + + local _, c1, cerr = asn1.decode(ldap_hex("24 07 04 02 41 42 04 01 43")) + assert(c1 == nil and cerr ~= nil, "constructed OCTET STRING rejected") + + -- binary payloads round-trip byte-exactly (matches the oracle). + local _, b1 = asn1.decode(ldap_hex("04 04 ff fe 00 80")) + assert(b1 == "\255\254\0\128", "high bytes and NUL preserved") + + local function der_len(n) + if n < 128 then return string.char(n) end + local b = "" + while n > 0 do b = string.char(n % 256) .. b; n = math.floor(n / 256) end + return string.char(0x80 + #b) .. b + end + local deep = "" + for _ = 1, 400 do deep = "\48" .. der_len(#deep) .. deep end + local pok, _, dv, derr = pcall(asn1.decode, deep) + assert(pok, "deep nesting must not raise") + assert(dv == nil and derr ~= nil, "deep nesting rejected with an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/asn1_vectors.t b/t/asn1_vectors.t new file mode 100644 index 0000000..7251c24 --- /dev/null +++ b/t/asn1_vectors.t @@ -0,0 +1,128 @@ +# Vectors cross-checked against pyasn1 v0.6.4, rasn v0.6.1, and Go encoding/asn1. + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: tags with no decoder must error, never return a silent nil +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Well-formed universal TLVs whose tag has no decoder entry must error, not return nil. + local cases = { + { "01 01 ff", "BOOLEAN true" }, + { "01 01 00", "BOOLEAN false" }, + { "01 01 01 00 78 32 32", "BOOLEAN with trailing bytes" }, + { "05 00", "NULL" }, + { "06 01 55", "OID 2.5" }, + { "06 04 55 02 c0 00", "OID 2.5.2.8192" }, + { "03 02 07 00", "BIT STRING" }, + { "1a 05 4a 6f 6e 65 73", "VisibleString \"Jones\"" }, + { "16 05 53 6d 69 74 68", "IA5String \"Smith\"" }, + } + for _, c in ipairs(cases) do + local off, v, err = asn1.decode(ldap_hex(c[1])) + assert(err ~= nil, c[2] .. " must report an explicit error, got err=nil value=" .. + tostring(v) .. " off=" .. tostring(off)) + assert(v == nil, c[2] .. " must not yield a value") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: ported vectors that must KEEP working (BER leniency preserved) +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local ldap_hex = require("ldap_hex") + + -- Guard rail for the hardening work: these must not become errors. + + local _, os1, e1 = asn1.decode( + ldap_hex("04 0f 51 75 69 63 6b 20 62 72 6f 77 6e 20 66 6f 78")) + assert(e1 == nil and os1 == "Quick brown fox", "primitive OCTET STRING") + + local _, os2, e2 = asn1.decode(ldap_hex("04 06 01 02 03 04 05 06")) + assert(e2 == nil and os2 == "\1\2\3\4\5\6", "binary OCTET STRING round-trips") + + local _, os3, e3 = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(e3 == nil and os3 == "ABC", "non-minimal length must remain accepted") + + local _, os4, e4 = asn1.decode(ldap_hex("04 88 00 00 00 00 00 00 00 05") .. "hello") + assert(e4 == nil and os4 == "hello", "8-byte long-form length must remain accepted") + + local ints = { + { "02 03 00 80 00", 32768 }, + { "02 02 7f ff", 32767 }, + { "02 02 01 00", 256 }, + { "02 01 7f", 127 }, + { "02 01 ff", -1 }, + { "02 03 ff 7f ff", -32769 }, + } + for _, c in ipairs(ints) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err == nil and v == c[2], + "rasn integer vector " .. c[1] .. " -> " .. tostring(v)) + end + + local go_ints = { + { "02 01 00", 0 }, + { "02 02 00 80", 128 }, + { "02 01 80", -128 }, + { "02 02 ff 7f", -129 }, + } + for _, c in ipairs(go_ints) do + local _, v, err = asn1.decode(ldap_hex(c[1])) + assert(err == nil and v == c[2], + "go int64 vector " .. c[1] .. " -> " .. tostring(v)) + end + + local _, p12 = asn1.decode(ldap_hex("02 01 0c")) + local _, n12 = asn1.decode(ldap_hex("02 01 f4")) + assert(p12 == 12 and n12 == -12, "pyasn1 12 / -12") + + local _, chunked, ec = asn1.decode( + ldap_hex("24 17 04 04 51 75 69 63 04 04 6b 20 62 72 04 04 6f 77 6e 20 04 03 66 6f 78")) + assert(chunked == nil and ec ~= nil, "constructed OCTET STRING stays rejected") + + local deep = ldap_hex("05 00") + for _ = 1, 150 do deep = asn1.put_object(16, asn1.CLASS.UNIVERSAL, 1, deep) end + local _, dv, de = asn1.decode(deep) + assert(dv == nil and de ~= nil, "deep nesting is bounded") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode_hostile.t b/t/decode_hostile.t new file mode 100644 index 0000000..d2c9e00 --- /dev/null +++ b/t/decode_hostile.t @@ -0,0 +1,373 @@ +# Vectors cross-checked against pyasn1 v0.6.4 and rasn v0.6.1. +# pyasn1 v0.6.4: https://github.com/pyasn1/pyasn1/blob/v0.6.4/tests/codec/ber/test_decoder.py + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: bytes after the LDAPMessage envelope must be rejected, not ignored +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local r1, e1 = protocol.decode_message(ldap_hex( + "30 0c 02 01 02 61 07 0a 01 00 04 00 04 00 de ad be ef")) + assert(r1 == nil, "trailing garbage after envelope must be rejected") + assert(e1 ~= nil, "trailing garbage after envelope must report an error") + + -- Two LDAPMessages in one buffer: forged success consumed, genuine reply dropped. + local r2, e2 = protocol.decode_message(ldap_hex( + "30 0c 02 01 02 61 07 0a 01 00 04 00 04 00" .. + "30 0c 02 01 01 61 07 0a 01 31 04 00 04 00")) + assert(r2 == nil, "appended second LDAPMessage must be rejected") + assert(e2 ~= nil, "appended second LDAPMessage must report an error") + + -- The exact-fit packet is unaffected. + local ok = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(ok.result_code == 0, "well-formed packet still decodes") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: a second APPLICATION protocolOp inside the envelope must be rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local r, e = protocol.decode_message(ldap_hex( + "30 15 02 01 01 61 07 0a 01 31 04 00 04 00 61 07 0a 01 00 04 00 04 00")) + assert(r == nil, "second APPLICATION protocolOp must be rejected") + assert(e ~= nil, "second APPLICATION protocolOp must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: attribute values are always an array, never a bare string +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local pkt = ldap_hex("30 21 02 01 03 64 1c 04 01 78 30 17 30 15" .. + "04 08 6d 65 6d 62 65 72 4f 66" .. + "04 09 63 6e 3d 61 64 6d 69 6e 73") + local r, e = protocol.decode_message(pkt) + assert(r == nil, "non-SET vals must be rejected") + assert(e ~= nil, "non-SET vals must report an error") + + -- Well-formed SET OF still yields an array. + local good = assert(protocol.decode_message(ldap_hex( + "30 23 02 01 03 64 1e 04 01 78 30 19 30 17" .. + "04 08 6d 65 6d 62 65 72 4f 66" .. + "31 0b 04 09 63 6e 3d 61 64 6d 69 6e 73"))) + assert(type(good.attributes.memberOf) == "table", "vals is an array") + assert(good.attributes.memberOf[1] == "cn=admins", "vals content") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: a duplicate attribute type is decoded, not treated as a protocol error +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- PartialAttributeList: memberOf={"cn=admins"} followed by memberOf={}. + local pkt = ldap_hex("30 31 02 01 03 64 2c 04 01 78 30 27" .. + "30 17 04 08 6d 65 6d 62 65 72 4f 66 31 0b 04 09 63 6e 3d 61 64 6d 69 6e 73" .. + "30 0c 04 08 6d 65 6d 62 65 72 4f 66 31 00") + local r, e = protocol.decode_message(pkt) + assert(r ~= nil, "duplicate attribute type must decode: " .. tostring(e)) + assert(type(r.attributes.memberOf) == "table", + "memberOf must still be an array, got " .. type(r.attributes.memberOf)) + for k in pairs(r.attributes) do + assert(type(k) == "string", "attribute key is a " .. type(k)) + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: an undecodable SET/SEQUENCE member must error, not shrink the array +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local cases = { + { "vals with EOC member", + "30 20 02 01 03 64 1b 04 01 78 30 16 30 14" .. + "04 08 6d 65 6d 62 65 72 4f 66 31 08 04 01 61 00 00 04 01 62" }, + { "vals with BOOLEAN member", + "30 21 02 01 03 64 1c 04 01 78 30 17 30 15" .. + "04 08 6d 65 6d 62 65 72 4f 66 31 09 04 01 61 01 01 ff 04 01 62" }, + { "SearchResRef with BOOLEAN member", + "30 0e 02 01 02 73 09 04 01 61 01 01 ff 04 01 62" }, + { "SearchResRef with EOC member", + "30 0d 02 01 02 73 08 04 01 61 00 00 04 01 62" }, + } + for _, c in ipairs(cases) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + assert(r == nil, c[1] .. " must be rejected, got a result") + assert(e ~= nil, c[1] .. " must report an error") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: constructed OCTET STRING (0x24) is rejected at every nesting level +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local cases = { + { "matchedDN", "30 13 02 01 01 61 0e 0a 01 31 24 07 04 02 41 42 04 01 43 04 00" }, + { "diagnosticMsg", "30 10 02 01 01 61 0b 0a 01 31 04 00 24 04 04 02 41 42" }, + { "objectName", "30 10 02 01 03 64 0b 24 07 04 02 41 42 04 01 43 30 00" }, + { "attribute value", "30 17 02 01 03 64 12 04 01 78 30 0d 30 0b 04 01 6d 31 06 24 04 04 02 41 42" }, + { "SearchResRef URI","30 0a 02 01 02 73 05 24 03 04 01 61" }, + } + for _, c in ipairs(cases) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + assert(r == nil, c[1] .. ": constructed OCTET STRING must be rejected") + assert(e ~= nil, c[1] .. ": constructed OCTET STRING must report an error") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 7: PartialAttributeList / PartialAttribute containers must be SEQUENCEs +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local cases = { + { "PartialAttributeList as SET", + "30 14 02 01 03 64 0f 04 01 78 31 0a 30 08 04 01 6d 31 03 04 01 76" }, + { "PartialAttribute as CONTEXT [16]", + "30 14 02 01 03 64 0f 04 01 78 30 0a b0 08 04 01 6d 31 03 04 01 76" }, + { "primitive APPLICATION protocolOp", + "30 0c 02 01 01 41 07 0a 01 00 04 00 04 00" }, + } + for _, c in ipairs(cases) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + assert(r == nil, c[1] .. " must be rejected, got a result") + assert(e ~= nil, c[1] .. " must report an error") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 8: truncation and byte-flip sweep never raises a Lua exception +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local pkt = ldap_hex("30 12 02 01 01 61 0d 0a 01 00 04 04 64 63 3d 78 04 02 68 69") + assert(#pkt == 20, "packet length " .. #pkt) + local base = assert(protocol.decode_message(pkt)) + assert(base.result_code == 0 and base.matched_dn == "dc=x", "baseline") + + -- Every proper prefix must fail cleanly. + for k = 0, #pkt - 1 do + local pok, r, e = pcall(protocol.decode_message, pkt:sub(1, k)) + assert(pok, "truncation at " .. k .. " raised: " .. tostring(r)) + assert(r == nil, "truncation at " .. k .. " decoded to a result") + assert(e ~= nil, "truncation at " .. k .. " returned nil with no error") + end + + -- Single-byte corruption must never escape as an exception. + local flips = { 0x00, 0x01, 0x24, 0x30, 0x7f, 0x80, 0x84, 0xff } + for i = 1, #pkt do + for _, b in ipairs(flips) do + local m = pkt:sub(1, i - 1) .. string.char(b) .. pkt:sub(i + 1) + local pok, r, e = pcall(protocol.decode_message, m) + assert(pok, string.format("byte %d -> %02x raised: %s", i, b, tostring(r))) + assert(r ~= nil or e ~= nil, + string.format("byte %d -> %02x returned nil with no error", i, b)) + end + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 9: BER leniency and hard limits are preserved +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local asn1 = require("resty.ldap.asn1") + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local _, v = asn1.decode(ldap_hex("04 81 03 41 42 43")) + assert(v == "ABC", "non-minimal length must still decode, got " .. tostring(v)) + local _, v2 = asn1.decode(ldap_hex("04 83 00 00 03 41 42 43")) + assert(v2 == "ABC", "3-octet non-minimal length must still decode, got " .. tostring(v2)) + -- matchedDN "dc=x" and diagnosticMessage "hi" with long-form lengths + local ok = assert(protocol.decode_message(ldap_hex( + "30 14 02 01 01 61 0f 0a 01 00 04 81 04 64 63 3d 78 04 81 02 68 69"))) + assert(ok.result_code == 0, "non-minimal LDAPMessage result_code") + assert(ok.matched_dn == "dc=x" and ok.diagnostic_msg == "hi", + "non-minimal LDAPMessage fields") + -- long-form envelope and protocolOp headers + local ok2 = assert(protocol.decode_message(ldap_hex( + "30 81 13 02 01 01 61 81 0d 0a 01 00 04 04 64 63 3d 78 04 02 68 69"))) + assert(ok2.result_code == 0 and ok2.matched_dn == "dc=x", + "long-form envelope/protocolOp headers decode") + + local rejected = { + { "indefinite length", "30 80 02 01 01 61 07 0a 01 00 04 00 04 00 00 00" }, + { "length 84 ffffffff", "30 84 ff ff ff ff 02 01 01" }, + { "length 88 (8 octets)", "30 88 ff ff ff ff ff ff ff ff 02 01 01" }, + { "empty buffer", "" }, + { "envelope of length 0", "30 00" }, + } + for _, c in ipairs(rejected) do + local r, e = protocol.decode_message(ldap_hex(c[2])) + assert(r == nil and e ~= nil, c[1] .. " must be rejected") + end + + local function enclen(n) + if n < 128 then return string.char(n) end + local out = "" + while n > 0 do out = string.char(n % 256) .. out; n = math.floor(n / 256) end + return string.char(0x80 + #out) .. out + end + local deep = ldap_hex("04 01 41") + for _ = 1, 500 do deep = "\48" .. enclen(#deep) .. deep end + local pok, _, dv, derr = pcall(asn1.decode, deep) + assert(pok, "deep nesting raised: " .. tostring(dv)) + assert(dv == nil and derr ~= nil, "deep nesting must be refused") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 10: regression guard -- genuinely empty SETs stay empty, not errors +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- typesOnly zero-member vals SET (31 00) must stay empty, not become an error + local res = assert(protocol.decode_message(ldap_hex( + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 04 01 73 31 00"))) + assert(type(res.attributes.s) == "table", "s is an array") + assert(#res.attributes.s == 0, "s is empty, not an error") + + -- And a well-formed multi-valued SET still returns every member. + local res2 = assert(protocol.decode_message(ldap_hex( + "30 1e 02 01 03 64 19 04 01 78 30 14 30 12 04 08 6d 65 6d 62 65 72 4f 66" .. + " 31 06 04 01 61 04 01 62"))) + assert(#res2.attributes.memberOf == 2, "both members returned") + assert(res2.attributes.memberOf[1] == "a", "member 1") + assert(res2.attributes.memberOf[2] == "b", "member 2") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode_op_dispatch.t b/t/decode_op_dispatch.t new file mode 100644 index 0000000..503ed9f --- /dev/null +++ b/t/decode_op_dispatch.t @@ -0,0 +1,106 @@ +# Test vectors: original, RFC 4511 protocolOp tags — https://www.rfc-editor.org/rfc/rfc4511.txt + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: a protocolOp that is not a decodable response must error, never fabricate an LDAPResult +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- Each body is a valid LDAPResult; only the protocolOp tag varies -- non-response ops must be refused + local cases = { + -- [APPLICATION 30] constructed: unassigned in RFC 4511 + {"unassigned op 30", "30 0c 02 01 01 7e 07 0a 01 00 04 00 04 00"}, + -- request-side PDUs a server must never send (UnbindRequest is NULL, RFC 4511 s4.3) + {"BindRequest op 0", "30 0c 02 01 01 60 07 0a 01 00 04 00 04 00"}, + {"UnbindRequest op 2", "30 0c 02 01 01 62 07 0a 01 00 04 00 04 00"}, + {"SearchRequest op 3", "30 0c 02 01 01 63 07 0a 01 00 04 00 04 00"}, + {"ExtendedRequest op 23", "30 0c 02 01 01 77 07 0a 01 00 04 00 04 00"}, + -- primitive [APPLICATION 1]: every protocolOp is a SEQUENCE, so this is malformed + {"primitive BindResponse", "30 0c 02 01 01 41 07 0a 01 00 04 00 04 00"}, + } + + for _, c in ipairs(cases) do + local ok, res, err = pcall(protocol.decode_message, ldap_hex(c[2])) + assert(ok, c[1] .. ": decode_message raised instead of returning an error: " .. tostring(res)) + assert(res == nil, + c[1] .. ": must be rejected, but decoded to result_code=" .. + tostring(type(res) == "table" and res.result_code or "?")) + assert(type(err) == "string" and #err > 0, c[1] .. ": missing error message") + end + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + + + +=== TEST 2: the allowlist must not overreach -- all six response ops the library supports still decode +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local APP = protocol.APP_NO + local ldap_hex = require("ldap_hex") + + local ldap_result = { + {"BindResponse", APP.BindResponse, "30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"}, + {"SearchResultDone", APP.SearchResultDone, "30 0c 02 01 02 65 07 0a 01 00 04 00 04 00"}, + {"ModifyResponse", APP.ModifyResponse, "30 0c 02 01 02 67 07 0a 01 00 04 00 04 00"}, + {"ExtendedResponse", APP.ExtendedResponse, "30 0c 02 01 01 78 07 0a 01 00 04 00 04 00"}, + } + for _, c in ipairs(ldap_result) do + local res, err = protocol.decode_message(ldap_hex(c[3])) + assert(res, c[1] .. ": must still decode, got: " .. tostring(err)) + assert(res.protocol_op == c[2], c[1] .. ": op " .. tostring(res.protocol_op)) + assert(res.result_code == 0, c[1] .. ": result_code " .. tostring(res.result_code)) + assert(res.matched_dn == "" and res.diagnostic_msg == "", c[1] .. ": LDAPResult fields") + end + + local e, eerr = protocol.decode_message(ldap_hex("30 0a 02 01 03 64 05 04 01 78 30 00")) + assert(e, "SearchResultEntry must still decode, got: " .. tostring(eerr)) + assert(e.protocol_op == APP.SearchResultEntry and e.entry_dn == "x", "entry") + -- a real entry carries no LDAPResult, so none may be invented for it + assert(e.result_code == nil, "SearchResultEntry must not carry a result_code") + + local r, rerr = protocol.decode_message(ldap_hex("30 0f 02 01 02 73 0a 04 08 6c 64 61 70 3a 2f 2f 78")) + assert(r, "SearchResultReference must still decode, got: " .. tostring(rerr)) + assert(r.protocol_op == APP.SearchResultReference, "ref op") + assert(r.uris and r.uris[1] == "ldap://x", "ref uri") + assert(r.result_code == nil, "SearchResultReference must not carry a result_code") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode_protocol.t b/t/decode_protocol.t new file mode 100644 index 0000000..0930d09 --- /dev/null +++ b/t/decode_protocol.t @@ -0,0 +1,97 @@ +# Test vectors: original, hand-built from RFC 4511 grammar — https://www.rfc-editor.org/rfc/rfc4511.txt + +use Test::Nginx::Socket::Lua; + +log_level('info'); +no_shuffle(); +no_long_string(); +repeat_each(1); +plan 'no_plan'; + +our $HttpConfig = <<'_EOC_'; + lua_package_path 'lib/?.lua;t/lib/?.lua;/usr/share/lua/5.1/?.lua;;'; + lua_package_cpath 'deps/lib/lua/5.1/?.so;;'; + resolver 127.0.0.53; +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: LDAPResult fields must have the right ASN.1 types +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- a wrong ASN.1 type in the resultCode slot is rejected strictly: nil result AND an + -- error, never a mistyped result table (an OCTET STRING resultCode becomes "\0", + -- compares ~= 0, so a successful bind would read as failure) + local cases = { + { "NULL", "30 0b 02 01 01 61 06 05 00 04 00 04 00" }, + { "BOOLEAN", "30 0c 02 01 01 61 07 01 01 ff 04 00 04 00" }, + { "OCTET STRING", "30 0c 02 01 01 61 07 04 01 00 04 00 04 00" }, + { "SEQUENCE", "30 0b 02 01 01 61 06 30 00 04 00 04 00" }, + } + for _, c in ipairs(cases) do + local res, err = protocol.decode_message(ldap_hex(c[2])) + assert(res == nil, c[1] .. " resultCode must not produce a result table") + assert(err ~= nil, c[1] .. " resultCode must report an error") + end + + -- matchedDN slot holds an INTEGER: matched_dn becomes the number 5 + local bad3, err3 = protocol.decode_message(ldap_hex("30 0d 02 01 01 61 08 0a 01 00 02 01 05 04 00")) + assert(bad3 == nil and err3 ~= nil, "non-string matchedDN must be rejected") + + -- consequence guard: a decoded result_code must survive the client error formatter, + -- which string-concatenates it into "Unknown error occurred (code: ...)" + local good = assert(protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00"))) + assert(type(good.result_code) == "number", "result_code is a number") + local built = pcall(function() + return "Unknown error occurred (code: " .. good.result_code .. ")" + end) + assert(built, "client error formatter throws on this resultCode") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 2: a repeated attribute type still decodes to a well-formed attribute map +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- PartialAttributeList carries "uid" twice: {"a"} then {"b"} + local pkt = ldap_hex([[30 22 02 01 03 64 1d + 04 01 78 + 30 18 + 30 0a 04 03 75 69 64 31 03 04 01 61 + 30 0a 04 03 75 69 64 31 03 04 01 62]]) + local res, err = protocol.decode_message(pkt) + assert(res ~= nil, "duplicate attribute type must decode: " .. tostring(err)) + assert(res.entry_dn == "x", "entry_dn") + local uid = res.attributes.uid + assert(type(uid) == "table", "uid must be an array, got " .. type(uid)) + assert(#uid == 1 and (uid[1] == "a" or uid[1] == "b"), + "uid must hold one of the values actually sent") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/decode_rfc4511.t b/t/decode_rfc4511.t new file mode 100644 index 0000000..f8bdec9 --- /dev/null +++ b/t/decode_rfc4511.t @@ -0,0 +1,355 @@ +# 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") + + -- multi-value order is wire order, not sorted + assert(#r.attributes.objectClass == 4, "objectClass count " .. #r.attributes.objectClass) + assert(r.attributes.objectClass[1] == "top", "objectClass[1]") + assert(r.attributes.objectClass[2] == "person", "objectClass[2]") + assert(r.attributes.objectClass[3] == "organizationalPerson", "objectClass[3]") + assert(r.attributes.objectClass[4] == "user", "objectClass[4]") + + local root = assert(protocol.decode_message(ldap_hex([[30 4d 02 01 03 64 48 04 00 30 44 + 30 1b 04 14 73 75 70 70 6f 72 74 65 64 4c 44 41 50 56 65 72 73 69 6f 6e + 31 03 04 01 33 + 30 25 04 0e 6e 61 6d 69 6e 67 43 6f 6e 74 65 78 74 73 + 31 13 04 11 44 43 3d 65 78 61 6d 70 6c 65 2c 44 43 3d 63 6f 6d]]))) + assert(root.entry_dn == "", "root DSE dn must be the empty string, got " .. tostring(root.entry_dn)) + assert(root.attributes.namingContexts[1] == "DC=example,DC=com", "namingContexts") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 3: optional trailing LDAPResult components are tolerated; only envelope-level trailing bytes are rejected +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + local r = assert(protocol.decode_message(ldap_hex([[30 1b 02 01 01 61 16 + 0a 01 0a 04 00 04 00 + a3 0d 04 0b 6c 64 61 70 3a 2f 2f 72 2f 78 79]])), + "BindResponse with referral [3] must parse") + assert(r.result_code == 10, "referral result code " .. tostring(r.result_code)) + assert(r.matched_dn == "" and r.diagnostic_msg == "", "mandatory fields intact") + + -- SearchResultDone carrying an AD-style referral URI + r = assert(protocol.decode_message(ldap_hex([[30 34 02 01 02 65 2f + 0a 01 0a 04 00 04 00 + a3 24 04 22 6c 64 61 70 3a 2f 2f 44 6f 6d 61 69 6e 44 6e 73 5a 6f + 6e 65 73 2e 65 78 61 6d 70 6c 65 2e 63 6f 6d 2f 64 63]])), + "SearchResultDone with referral [3] must parse") + assert(r.protocol_op == protocol.APP_NO.SearchResultDone and r.result_code == 10, "done+referral") + + r = assert(protocol.decode_message(ldap_hex("30 12 02 01 01 61 0d 0a 01 0e 04 00 04 00 87 04 de ad be ef")), + "BindResponse with serverSaslCreds [7] must parse") + assert(r.result_code == 14, "saslBindInProgress " .. tostring(r.result_code)) + + r = assert(protocol.decode_message(ldap_hex([[30 29 02 01 01 78 24 + 0a 01 00 04 00 04 00 + 8a 16 31 2e 33 2e 36 2e 31 2e 34 2e 31 2e 31 34 36 36 2e 32 30 30 33 37 + 8b 03 41 42 43]])), + "ExtendedResponse with responseName/responseValue must parse") + assert(r.protocol_op == protocol.APP_NO.ExtendedResponse and r.result_code == 0, "ext w/ optionals") + + r = assert(protocol.decode_message(ldap_hex("30 81 0c 02 01 01 61 07 0a 01 00 04 00 04 00")), + "non-minimal envelope length must still decode") + assert(r.result_code == 0, "non-minimal envelope result") + + -- non-minimal protocolOp length: BER length leniency is the deliberate AD/slapd + -- interop contract (decode_hostile.t pins the same leniency with long-form op + -- headers and non-empty fields, so the two files cross-reference, not duplicate) + r = assert(protocol.decode_message(ldap_hex("30 81 0d 02 01 01 61 81 07 0a 01 00 04 00 04 00")), + "non-minimal protocolOp length must still decode") + assert(r.protocol_op == protocol.APP_NO.BindResponse, "non-minimal op") + assert(r.result_code == 0, "non-minimal op result_code") + assert(r.matched_dn == "" and r.diagnostic_msg == "", "non-minimal op fields") + + local bad, err = protocol.decode_message(ldap_hex("30 0c 02 01 01 61 07 0a 01 00 04 00 04 00 de ad be ef")) + assert(bad == nil, "trailing bytes after the envelope must be rejected") + assert(err ~= nil, "trailing bytes must report an error") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 4: an undecodable SearchResultEntry field must return an error, never raise +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- protocol.lua keys the attribute map on the decoded atype, so a nil or mistyped + -- atype formerly raised "table index is nil" -- every hostile case must return + -- (nil, err), never raise. AttributeDescription must be an LDAPString and + -- objectName an LDAPDN (RFC 4511 s4.1.4). + local cases = { + { "BOOLEAN attribute type", + "30 11 02 01 03 64 0c 04 01 78 30 07 30 05 01 01 ff 31 00" }, + { "NULL attribute type", + "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 05 00 31 00" }, + -- non-string AttributeDescription (INTEGER 5) would key attributes by number + { "INTEGER attribute type", + "30 14 02 01 03 64 0f 04 01 78 30 0a 30 08 02 01 05 31 03 04 01 41" }, + -- constructed SEQUENCE attribute type: the key must never become a table + { "SEQUENCE attribute type", + "30 10 02 01 03 64 0b 04 01 78 30 06 30 04 30 00 31 00" }, + -- objectName that is not an LDAPDN must be an error, not entry_dn == 5 + { "INTEGER objectName", + "30 0a 02 01 03 64 05 02 01 05 30 00" }, + -- an unreadable objectName must be an error, never a silent nil entry_dn + { "NULL objectName", + "30 0f 02 01 03 64 0a 05 00 30 06 30 04 04 00 31 00" }, + } + for _, c in ipairs(cases) do + local pok, res, err = pcall(protocol.decode_message, ldap_hex(c[2])) + assert(pok, c[1] .. " raised instead of returning an error: " .. tostring(res)) + assert(res == nil, c[1] .. " must not yield a result") + assert(err ~= nil, c[1] .. " must report an error") + end + + -- positive control: the same well-formed shape still decodes + local good = assert(protocol.decode_message( + ldap_hex("30 11 02 01 03 64 0c 04 01 78 30 07 30 05 04 01 73 31 00"))) + assert(good.entry_dn == "x", "well-formed entry_dn") + assert(type(good.attributes.s) == "table" and #good.attributes.s == 0, "empty vals array") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 5: SearchResultReference must not silently drop an undecodable URI +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- one vector per member category; each must reject with no partial uris list + local cases = { + -- corruption AFTER valid members: BOOLEAN between two real URIs + { "BOOLEAN middle member", [[30 1c 02 01 02 73 17 + 04 08 6c 64 61 70 3a 2f 2f 78 + 01 01 ff + 04 08 6c 64 61 70 3a 2f 2f 79]] }, + -- no-registered-decoder category: NULL member after a valid URI + { "NULL member after a URI", "30 0a 02 01 02 73 05 04 01 78 05 00" }, + -- decodable-but-non-string category: a nested SEQUENCE would land a table in + -- uris[1]; URI (RFC 4511 s4.1.10) is an OCTET STRING on the wire + { "nested SEQUENCE member", "30 09 02 01 02 73 04 30 02 04 00" }, + } + for _, c in ipairs(cases) do + local bad, err = protocol.decode_message(ldap_hex(c[2])) + assert(bad == nil, c[1] .. " must be rejected; got " .. + tostring(bad and #bad.uris) .. " uris") + assert(err ~= nil, c[1] .. " must report an error") + end + + -- a well-formed multi-URI reference still decodes in full + local r = assert(protocol.decode_message(ldap_hex([[30 23 02 01 02 73 1e + 04 08 6c 64 61 70 3a 2f 2f 78 + 04 08 6c 64 61 70 3a 2f 2f 79 + 04 08 6c 64 61 70 3a 2f 2f 7a]]))) + assert(#r.uris == 3, "3 uris, got " .. #r.uris) + assert(r.uris[3] == "ldap://z", "third uri") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] + +=== TEST 6: PartialAttributeList containers must actually be SEQUENCEs +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local protocol = require("resty.ldap.protocol") + local ldap_hex = require("ldap_hex") + + -- attributes slot is an OCTET STRING (04) holding a PartialAttribute + local bad, err = protocol.decode_message(ldap_hex([[30 18 02 01 03 64 13 + 04 01 78 + 04 0e 30 0c 04 02 63 6e 31 06 04 04 4a 61 6e 65]])) + assert(bad == nil, "attributes must be a SEQUENCE; OCTET STRING yielded cn=" .. + tostring(bad and bad.attributes and bad.attributes.cn and bad.attributes.cn[1])) + assert(err ~= nil, "non-SEQUENCE attributes list must report an error") + + -- attributes slot is context-specific [16] (b0): same tag number as SEQUENCE, different class + local bad2, err2 = protocol.decode_message(ldap_hex([[30 14 02 01 03 64 0f + 04 01 78 + b0 0a 30 08 04 02 63 6e 31 02 04 00]])) + assert(bad2 == nil, "context-specific [16] is not a SEQUENCE") + assert(err2 ~= nil, "context-specific [16] attributes list must report an error") + + -- an individual PartialAttribute encoded as an OCTET STRING + local bad3, err3 = protocol.decode_message(ldap_hex([[30 18 02 01 03 64 13 + 04 01 78 + 30 0e 04 0c 04 02 63 6e 31 06 04 04 4a 61 6e 65]])) + assert(bad3 == nil, "PartialAttribute must be a SEQUENCE; got cn=" .. + tostring(bad3 and bad3.attributes and bad3.attributes.cn and bad3.attributes.cn[1])) + assert(err3 ~= nil, "non-SEQUENCE PartialAttribute must report an error") + + -- the well-formed equivalent still decodes + local r = assert(protocol.decode_message(ldap_hex([[30 16 02 01 03 64 11 + 04 01 78 + 30 0c 30 0a 04 02 63 6e 31 04 04 02 4a 44]]))) + assert(r.attributes.cn[1] == "JD", "well-formed entry still decodes") + + ngx.say("ok") + } + } +--- request +GET /t +--- response_body +ok +--- no_error_log +[error] diff --git a/t/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