diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 442dbfc..8ce3642 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -1,6 +1,5 @@ local bunpack = require("lua_pack").unpack local protocol = require("resty.ldap.protocol") -local to_hex = require("resty.string").to_hex local ok, rasn = pcall(require, "rasn") if not ok then @@ -13,6 +12,9 @@ local tcp = ngx.socket.tcp local table_insert = table.insert local string_char = string.char local rasn_decode = rasn.decode_ldap +local to_hex = function(s) + return (s:gsub(".", function(c) return ("%02x"):format(c:byte()) end)) +end local _M = {} diff --git a/lib/resty/ldap/filter.lua b/lib/resty/ldap/filter.lua index 524deb1..f270ce9 100644 --- a/lib/resty/ldap/filter.lua +++ b/lib/resty/ldap/filter.lua @@ -1,9 +1,8 @@ local lpeg = require("lpeg") local P, R, S, V = lpeg.P, lpeg.R, lpeg.S, lpeg.V -local C, Ct, Cmt, Cg, Cp, Cc, Cf = lpeg.C, lpeg.Ct, lpeg.Cmt, lpeg.Cg, lpeg.Cp, lpeg.Cc, lpeg.Cf +local C, Ct, Cg, Cp, Cc, Cf = lpeg.C, lpeg.Ct, lpeg.Cg, lpeg.Cp, lpeg.Cc, lpeg.Cf local string_char = string.char -local string_sub = string.sub local table_insert = table.insert local table_concat = table.concat @@ -108,7 +107,7 @@ local filter = P{ ATTRIBUTE_DESCRIPTION = V'RAW_VALUE' / function(value) return { attribute_description = value } end, - ATTRIBUTE_VALUE = V'RAW_VALUE' / function(value) + ATTRIBUTE_VALUE = V'ASSERTION_VALUE' / function(value) return { attribute_value = value } end, ATTRIBUTE_VALUE_SUBSTRING = ( @@ -135,6 +134,15 @@ local filter = P{ end, RAW_VALUE = list(V'ESCAPED_CHARACTER' + V'UTF8_FILTERED_CHARACTER') / table_concat, -- UTF8 sequence + -- RFC 4515 UTF1SUBSET (%x01-27 / %x2B-5B / %x5D-7F) plus multi-byte UTF-8. + -- Unlike RAW_VALUE, this permits =, <, >, ~ in assertion values, as the RFC allows. + ASSERTION_VALUE = list( + V'ESCAPED_CHARACTER' + + R("\1\39", "\43\91", "\93\127") / "%0" -- 1-byte UTF1SUBSET + + R("\194\223") * V'UTF8_CONT' / "%0" -- 2-byte UTF-8 + + R("\224\239") * V'UTF8_CONT' * V'UTF8_CONT' / "%0" -- 3-byte UTF-8 + + R("\240\244") * V'UTF8_CONT' * V'UTF8_CONT' * V'UTF8_CONT' / "%0" -- 4-byte UTF-8 + ) / table_concat, DIGIT = R'09', WILDCARD = P'*' / function() return { attribute_value = "*" } @@ -153,30 +161,9 @@ local filter = P{ UTF8_CONT = R'\128\191', -- continuation byte UTF8_FILTERED_CHARACTER = - -- Handle some cases where exclusion symbols are not need. - -- If attribute and value contain struck-out characters, but they are not part of any - -- operator, capture and skip it without enabling subsequent HEX-based matches - -- (since these characters are part of value, but HEX matches will unconditionally - -- strike them out) - Cmt(R('\0\127'), function(s, i, prev) -- match all ASCII character - -- Make sure that this character is not the last character, only then it is - -- possible to compose the operator. - if #s <= i + 1 then - return false - end - - -- Ensures that the value is parsed properly if there is a separate ~ symbol in the value. - if string_sub(s, i, i) == '~' and string_sub(s, i + 1, i + 1) ~= "=" then - -- Captures and skips that ~ symbol. The previous capture element needs to be - -- added back manually. - return i + 1, prev .. '~' - end - - return false -- No capture by default - end) - + R("\0\39", "\43\59", "\63\125", "\127\127") / "%0" -- 1-byte character, remove (, ), <, >, *, =, ~ - + R("\194\223") * V'UTF8_CONT' / "%0" -- 2-byte character - + R("\224\239") * V'UTF8_CONT' * V'UTF8_CONT' / "%0" -- 3-byte character + R("\0\39", "\43\59", "\63\125", "\127\127") / "%0" -- 1-byte character, remove (, ), *, =, <, >, ~ + + R("\194\223") * V'UTF8_CONT' / "%0" -- 2-byte character + + R("\224\239") * V'UTF8_CONT' * V'UTF8_CONT' / "%0" -- 3-byte character + R("\240\244") * V'UTF8_CONT' * V'UTF8_CONT' * V'UTF8_CONT' / "%0", -- 4-byte character } diff --git a/t/filter.t b/t/filter.t index 7737fde..cecda75 100644 --- a/t/filter.t +++ b/t/filter.t @@ -430,7 +430,7 @@ removing the extensible match implementation from it. local result, err = filter.compile(case.filter) if not result then if case.expect_err then - assert(case.expect_err == err, + assert(case.expect_err == err, 'case#' .. i .. ' error content does not match expectations, expect: ' .. case.expect_err .. ', actual: ' .. err) else @@ -449,3 +449,70 @@ GET /t --- no_error_log [error] --- error_code: 200 + + + +=== TEST 5: assertion values containing =, <, >, ~ +RFC 4515 UTF1SUBSET permits =, <, >, ~ inside assertion values even though +they are used as filter operators at the outer level. The most common +real-world case is a DN-valued attribute (e.g. member=uid=john,...). +--- http_config eval: $::HttpConfig +--- config + location /t { + content_by_lua_block { + local filter = require("resty.ldap.filter") + + local cases = { + {-- #1: DN assertion value with embedded '=' + filter = '(member=uid=john,dc=example,dc=com)', + test = (function(f, m) + assert(f.item_type == 'simple', m .. 'item_type != simple, ' .. f.item_type) + assert(f.attribute_description == 'member', m .. 'attribute_description != member, ' .. f.attribute_description) + assert(f.attribute_value == 'uid=john,dc=example,dc=com', m .. 'attribute_value != uid=john,dc=example,dc=com, ' .. f.attribute_value) + end), + }, + {-- #2: assertion value with embedded '<' + filter = '(cn=a' + filter = '(cn=a>b)', + test = (function(f, m) + assert(f.item_type == 'simple', m .. 'item_type != simple, ' .. f.item_type) + assert(f.attribute_value == 'a>b', m .. 'attribute_value != a>b, ' .. f.attribute_value) + end), + }, + {-- #4: assertion value with embedded '~' + filter = '(mail=user~name@example.com)', + test = (function(f, m) + assert(f.item_type == 'simple', m .. 'item_type != simple, ' .. f.item_type) + assert(f.attribute_value == 'user~name@example.com', m .. 'attribute_value != user~name@example.com, ' .. f.attribute_value) + end), + }, + {-- #5: DN assertion value inside a compound filter + filter = '(&(objectClass=groupOfNames)(member=cn=alice,ou=people,dc=example,dc=com))', + test = (function(f, m) + assert(f.op_type == 'and', m .. 'op_type != and, ' .. f.op_type) + assert(f.items[2].attribute_description == 'member', m .. 'items[2].attribute_description != member, ' .. f.items[2].attribute_description) + assert(f.items[2].attribute_value == 'cn=alice,ou=people,dc=example,dc=com', m .. 'items[2].attribute_value != cn=alice,ou=people,dc=example,dc=com, ' .. f.items[2].attribute_value) + end), + }, + } + + for i, case in ipairs(cases) do + local result, err = filter.compile(case.filter) + if not result then + assert(false, 'case#' .. i .. ' compile error: ' .. err) + end + case.test(result, 'case#' .. i .. ' error: ') + end + } + } +--- request +GET /t +--- no_error_log +[error] +--- error_code: 200