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