Skip to content

feat: pure-Lua LDAP search (drop Rust rasn) - #32

Open
janiussyafiq wants to merge 27 commits into
api7:mainfrom
janiussyafiq:slice1-pure-lua-search
Open

feat: pure-Lua LDAP search (drop Rust rasn)#32
janiussyafiq wants to merge 27 commits into
api7:mainfrom
janiussyafiq:slice1-pure-lua-search

Conversation

@janiussyafiq

@janiussyafiq janiussyafiq commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Replaces the Rust rasn decoder with pure Lua and drops the Rust toolchain entirely — installing now needs only lua_pack and lpeg (build.type = "builtin", v0.3.0 rockspec).

Changes

  • Pure-Lua decoding: new BER primitives (asn1.get_object / asn1.decode) and message decoder (protocol.decode_message) producing the same output shape as the Rust path; SearchResultReference is now actually decoded.
  • Hardened decoding: dispatch on class+tag+form (no more context/universal confusion), constructed OCTET STRING rejected (RFC 4511 §5.1), INTEGER exact-or-error beyond 2^53, recursion-depth and 16 MiB message caps, trailing bytes rejected, decoder errors surface instead of returning empty success. Non-minimal BER lengths stay accepted on purpose — AD/slapd interop.
  • Encoding fixes: put_object no longer truncates length headers containing 0x00 octets (corrupted requests at 256-byte boundaries).
  • New API: pinned-connection session (client:connect() / set_keepalive() / close()) so bind + search share one socket; filter.escape() per RFC 4515 §3, including < and >.
  • Compat: resty.ldap / resty.ldap.ldap restored so the existing ldap-auth plugin keeps working; ldap_authenticate hardened (DN-metacharacter escaping, TLS verification options).
  • CI: retired runner/image and expired certs fixed; AD-shaped fixture (login attribute ≠ RDN, binary values with embedded NULs).

Tests

Unit suites cover the ASN.1 primitives, message decoder, hardening regressions, and encoding invariants; e2e suites run against a live slapd. The suite was deduplicated into one canonical home per invariant (−1,324 lines), with zero coverage loss verified by mutation testing: re-introducing each hardened-away bug still fails a surviving test.

Restore get_object/decode for the pure-Lua LDAP response path (RFC
api7/rfcs#116, slice 1): NUL-safe octet-string slicing, header-length
based structural walk, indefinite-length rejection, and a recursion
depth guard. Adds t/asn1.t (12 assertions).
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change replaces the Rust LDAP codec with Lua ASN.1 and LDAP processing, adds authentication and pinned-session APIs, strengthens malformed-input handling, updates packaging and CI provisioning, and expands integration coverage for TLS, searches, decoding, filtering, and binary attributes.

Changes

LDAP protocol and ASN.1 implementation

Layer / File(s) Summary
ASN.1 and protocol decoding
lib/resty/ldap/asn1.lua, lib/resty/ldap/protocol.lua, t/asn1*.t, t/decode*.t
Adds Lua ASN.1 encoding/decoding, strict BER validation, LDAP response decoding, depth and size boundaries, binary-safe values, and extensive malformed-input coverage.
LDAP transport and pinned sessions
lib/resty/ldap/client.lua, lib/resty/ldap/ldap.lua, t/session.t, t/compat_ldap.t
Adds bind, unbind, StartTLS, socket reset handling, message-size validation, and explicit connect/keepalive/close lifecycle methods.
Authentication and filtering
lib/resty/ldap.lua, lib/resty/ldap/filter.lua, README.md, t/client.t, t/filter.t, t/search_ad.t
Adds compatibility authentication, TLS verification options, DN escaping, LDAP filter escaping, and integration coverage for user searches and binary attributes.
Packaging and CI fixtures
rockspec/*, .github/workflows/ci.yml, Makefile, .gitignore, t/fixtures/*, t/search.t
Switches rockspecs to builtin Lua module mappings, updates CI provisioning and LDAP fixture loading, simplifies development targets, and updates search expectations.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning SearchResultReference accepts 0 URIs, and keepalive failures are still ignored/logged; the new E2E suite misses these failure paths. Add a zero-URI negative test for SearchResultReference and handle setkeepalive failures by closing/resetting the socket instead of only logging.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed No secret logging, plaintext storage, authz/ownership, or secret-resolution issues found; TLS verification is normalized and tested.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and relevant to the main direction of the PR, even if it understates broader LDAP and test changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

decode_message replaces the Rust rasn.decode_ldap, reproducing its exact
output shape (LDAPResult ops; SearchResultEntry with always-array
attributes) so client.lua and the existing tests are unchanged. Adds real
SearchResultReference (op 19) handling, ModifyResponse/SearchResultReference
to APP_NO, and envelope/tag-class guards. Adds t/decode.t (18 assertions).
…cation

Two issues found in review:
- decode: a constructed OCTET STRING (0x24) was accepted and its inner TLV
  bytes returned as the value. RFC 4511 s5.1 mandates primitive-only OCTET
  STRINGs; a constructed one is a BER-smuggling vector against auth parsers.
  Reject it, mirroring the existing indefinite-length rejection.
- encode: asn1.put_object read its header back with a strlen-based ffi_string,
  truncating any length octet containing 0x00 (content length 256 -> 82 01 00),
  corrupting requests at 256-byte boundaries. Read exactly the bytes written.
  This also makes a zero-length value encode correctly as 80 00, so
  protocol.simple_bind_request no longer needs its manual anonymous-bind
  length-byte workaround.

Adds t/asn1.t TEST 6 (constructed reject) and TEST 7 (length round-trip).
The LPeg grammar rejects raw < > (and =) inside a value, so escape()'s
round-trip failed for those bytes; a username containing them would fail the
(sAMAccountName=<input>) search. Escaping any octet is RFC 4515-legal and
round-trips, so add \3c/\3e. Valid multi-byte UTF-8 still passes raw; a lone
invalid-UTF-8 byte stays fail-closed. Adds t/filter.t TEST 102.
@janiussyafiq janiussyafiq changed the title feat: pure-Lua LDAP search (drop Rust rasn) — RFC #116 slice 1 feat: pure-Lua LDAP search (drop Rust rasn) Jul 20, 2026
@janiussyafiq
janiussyafiq marked this pull request as ready for review July 20, 2026 00:29
- runs-on ubuntu-20.04 was retired by GitHub Actions; use ubuntu-latest
- bitnami/openldap:2.6 is gone from Docker Hub; pin
  bitnamilegacy/openldap:2.6.10-debian-12-r4
- replace removed apt-key with a signed-by keyring for the OpenResty repo
- drop the docker install block (preinstalled on runners); install
  Test::Nginx via cpanm --notest instead of cpan
- replace the fixed sleep with a slapd readiness probe so loading
  ad.ldif does not race container bootstrap
- regenerate t/certs as a set (slapd cert expired 2023-07-27; new
  certs valid to 2036) so the ldaps ssl_verify test passes in CI
- drop internal-doc and old-implementation references from comments
  (plan section citation, hardcoded-+2 note, Rust decoder narration);
  reword the strlen regression-test notes to present tense
- clarify the put_object header-buffer size comment
- renumber the new filter.t/client.t tests to follow the existing
  sequence (100-102 -> 5-7, 100 -> 7)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/resty/ldap/client.lua (1)

174-197: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Pinned session state (cli.pinned/cli.socket) is left inconsistent after a hard socket error.

Lines 224-228 document that a pinned session is only released via set_keepalive()/close(). But the socket:close() calls on the timeout (176-178) and body-read-failure (195-196) paths close the underlying socket directly without clearing cli.pinned/cli.socket. A pinned client that hits one of these errors will keep pinned = true pointing at a now-closed socket; every subsequent call on that client will fail again against the dead socket until the caller happens to call close(), which isn't obviously required by the contract described at 224-228.

🔧 Proposed fix
         local len, err = reader(2)
         if not len then
             if err == "timeout" then
                 socket:close()
+                cli.socket = nil
+                cli.pinned = nil
                 return nil, fmt("receive response failed: %s", err)
             end
             break
         end
@@
         local packet, err = socket:receive(packet_len)
         if not packet then
             socket:close()
+            cli.socket = nil
+            cli.pinned = nil
             return nil, err
         end

Also applies to: 224-228

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/resty/ldap/client.lua` around lines 174 - 197, Update the timeout and
packet-body read failure paths in the response-reading method to release the
pinned session through the client’s existing cleanup mechanism, rather than
directly closing the socket. Ensure both paths clear cli.pinned and cli.socket
while preserving their current error returns, and keep the documented
set_keepalive()/close() lifecycle consistent.
🧹 Nitpick comments (6)
.github/workflows/ci.yml (1)

17-17: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Prevent credential persistence in the checkout action.

To improve security hygiene, it is recommended to set persist-credentials: false unless the workflow explicitly needs to push changes back to the repository. This prevents the GitHub token from remaining in the local .git/config within the runner.

🛡️ Proposed fix
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml at line 17, Update the actions/checkout@v4 step to
disable credential persistence by setting persist-credentials to false, without
changing other workflow behavior.

Source: Linters/SAST tools

Makefile (1)

8-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ensure the test patch is reverted if prove fails.

In make, if a command fails, the execution stops immediately. If prove -r t/ fails, the git apply -R command will never be executed, leaving the local working tree modified and potentially causing subsequent test runs to fail.

Modify the target to ensure the patch is reverted regardless of the test outcome, while preserving the exit code.

♻️ Proposed fix
 test:
 	git apply t/patch/unknown_op.patch
-	prove -r t/
-	git apply t/patch/unknown_op.patch -R
+	`@prove` -r t/; RET=$$?; \
+	git apply t/patch/unknown_op.patch -R; \
+	exit $$RET
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 8 - 11, Update the Makefile test target around the git
apply, prove, and reverse-apply commands so cleanup runs even when prove fails.
Ensure the reverse patch command always executes, then preserve and return
prove’s original exit status.
lib/resty/ldap/asn1.lua (2)

273-292: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Unrecognized tags silently decode to nil with no error.

When decoder[obj.tag] is nil (no registered decoder for that tag), decode() still returns obj.offset + obj.len, nil with no error — a "successful" decode that yields no value. In a hardened parser explicitly designed to surface decode errors rather than fail silently (per TEST 5's intent), this branch could let a caller mistake a missing decoder for a legitimate nil/absent value.

♻️ Suggested fix: fail closed for unmapped tags
         local d = decoder[obj.tag]
         local value, derr
         if d then
             value, derr = d(der, offset, obj, depth)
             if derr then
                 return nil, nil, derr
             end
+        else
+            return nil, nil, "no decoder for tag " .. tostring(obj.tag)
         end
         return obj.offset + obj.len, value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/resty/ldap/asn1.lua` around lines 273 - 292, Update decode so an
unrecognized obj.tag with no entry in decoder fails closed by returning a
descriptive decode error instead of returning a successful offset with nil.
Preserve the existing decoder invocation and error propagation for registered
tags.

232-252: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

ASN1_INTEGER_get/ASN1_ENUMERATED_get have an ambiguous -1-on-error return.

OpenSSL's docs state ASN1_INTEGER_get() also returns the value of a but it returns 0 if a is NULL and -1 on error (which is ambiguous because -1 is a legitimate value for an ASN1_INTEGER), and this ambiguity is called out explicitly as a reason to avoid the function: The ambiguous return values of ASN1_INTEGER_get() and ASN1_ENUMERATED_get() mean these functions should be avoided if possible. Here, tonumber(v) is returned directly with no way to distinguish a legitimate -1 value from a decode/overflow error. For LDAP messageID/resultCode this is low-risk in practice (small, well-bounded values), but it's a real correctness gap for any INTEGER whose true value happens to be -1 or that overflows a C long.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/resty/ldap/asn1.lua` around lines 232 - 252, Replace the ASN1_INTEGER_get
and ASN1_ENUMERATED_get conversions in the decoder[TAG.INTEGER] and
decoder[TAG.ENUMERATED] handlers with an unambiguous value-decoding approach
that preserves legitimate -1 values and detects overflow or conversion errors.
Return a decoding error instead of silently accepting an ambiguous or
out-of-range result, while retaining the existing ASN.1 object cleanup.
t/asn1.t (1)

132-165: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a regression test for parent-boundary overrun.

TEST 5 covers truncated children (buffer too short overall) and zero-length INTEGER/ENUMERATED, but doesn't cover a child TLV that fits within the full buffer yet overruns its immediate parent's declared len (e.g. 30 03 04 05 41 42 43 44 45: outer SEQUENCE declares 3 bytes, inner OCTET STRING claims 5). See the related finding on decode_children in lib/resty/ldap/asn1.lua.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@t/asn1.t` around lines 132 - 165, Extend TEST 5 in t/asn1.t with a regression
case where a child TLV exceeds its immediate parent’s declared length while
remaining within the overall buffer, such as an outer SEQUENCE length of 3
containing an OCTET STRING claiming 5 bytes. Assert that asn1.decode returns no
value and a non-nil error, preserving the existing success response and
no-error-log expectations.
t/session.t (1)

19-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding coverage for client:close().

Only connect()/set_keepalive() are exercised. Since close() is a new part of the pinned-session contract (and interacts with the same pinned/socket fields flagged in client.lua), a small test asserting close() unpins and prevents reuse would round out coverage for this new feature.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@t/session.t` around lines 19 - 60, Add a focused test alongside “bind and
search share one pinned connection” that connects and pins a client, calls
client:close(), then verifies the pinned socket state is cleared and a
subsequent operation does not reuse that closed session. Assert the expected
close behavior and keep the test isolated from the existing set_keepalive flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/resty/ldap.lua`:
- Around line 85-86: Secure the bind DN construction in the authentication flow
by validating or RFC 4514-escaping the attacker-controlled given_username before
concatenating it in who. Use DN-specific escaping for metacharacters and
leading/trailing whitespace or reject invalid usernames, while preserving the
existing ldap.bind_request call and configured conf.attribute/conf.base_dn
components.

In `@lib/resty/ldap/asn1.lua`:
- Around line 254-267: Update decode_children to validate each child’s returned
position against the parent boundary stop immediately after decode returns; if
pos exceeds stop, return a decode error instead of accepting the value or
exiting the loop. Preserve normal child collection when pos remains within the
declared boundary, and use the module’s existing error-construction conventions.

In `@lib/resty/ldap/client.lua`:
- Around line 68-71: Remove the raw packet contents from decode-failure errors
in both _start_tls and _send_recieve. Update the decode_ldap failure handling to
return only a generic descriptive message plus the decoder error (or “unknown”),
without calling to_hex(packet), while preserving the existing failure return
behavior.
- Around line 4-15: Extend the MAX_LDAP_MESSAGE_SIZE protection to every receive
using calculate_payload_length: in lib/resty/ldap/client.lua lines 59-61 within
_start_tls, and lib/resty/ldap/ldap.lua lines 75-77 within bind_request and
132-134 within start_tls, reject oversized packet_len values before
socket:receive by closing the socket and returning an error; keep the existing
_send_recieve guard and shared 16 MiB limit unchanged.

In `@lib/resty/ldap/ldap.lua`:
- Around line 35-56: Update calculate_payload_length to reject the BER
indefinite-length marker 0x80 before interpreting long-form lengths, rather than
treating it as a 128-byte length. Apply the same validation in the corresponding
length-parsing logic in the LDAP client module, preserving existing handling for
valid definite lengths.

In `@lib/resty/ldap/protocol.lua`:
- Around line 205-229: Add bounds validation in parse_search_entry for every
decoded PartialAttribute: reject the attribute if pa.offset + pa.len exceeds
astop, and ensure the type and values decodes remain within the attribute’s
declared range before accepting them. Return the relevant parse error on
violations, while preserving normal attribute accumulation and iteration for
valid entries.
- Around line 231-244: Update parse_search_reference so each asn1_decode result
is validated against stop before accepting the decoded URI and adding it to
uris; return an error when the decoded element advances beyond the operation
boundary, matching the bounds handling used by
parse_search_entry/decode_children.
- Around line 191-203: Update parse_ldap_result to enforce the operation
boundary op.offset + op.len while decoding resultCode, matchedDN, and
diagnosticMessage. Validate each returned position before continuing, reject any
TLV that extends beyond the boundary, and ensure the final diagnosticMessage
position remains within the same limit.

---

Outside diff comments:
In `@lib/resty/ldap/client.lua`:
- Around line 174-197: Update the timeout and packet-body read failure paths in
the response-reading method to release the pinned session through the client’s
existing cleanup mechanism, rather than directly closing the socket. Ensure both
paths clear cli.pinned and cli.socket while preserving their current error
returns, and keep the documented set_keepalive()/close() lifecycle consistent.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Line 17: Update the actions/checkout@v4 step to disable credential persistence
by setting persist-credentials to false, without changing other workflow
behavior.

In `@lib/resty/ldap/asn1.lua`:
- Around line 273-292: Update decode so an unrecognized obj.tag with no entry in
decoder fails closed by returning a descriptive decode error instead of
returning a successful offset with nil. Preserve the existing decoder invocation
and error propagation for registered tags.
- Around line 232-252: Replace the ASN1_INTEGER_get and ASN1_ENUMERATED_get
conversions in the decoder[TAG.INTEGER] and decoder[TAG.ENUMERATED] handlers
with an unambiguous value-decoding approach that preserves legitimate -1 values
and detects overflow or conversion errors. Return a decoding error instead of
silently accepting an ambiguous or out-of-range result, while retaining the
existing ASN.1 object cleanup.

In `@Makefile`:
- Around line 8-11: Update the Makefile test target around the git apply, prove,
and reverse-apply commands so cleanup runs even when prove fails. Ensure the
reverse patch command always executes, then preserve and return prove’s original
exit status.

In `@t/asn1.t`:
- Around line 132-165: Extend TEST 5 in t/asn1.t with a regression case where a
child TLV exceeds its immediate parent’s declared length while remaining within
the overall buffer, such as an outer SEQUENCE length of 3 containing an OCTET
STRING claiming 5 bytes. Assert that asn1.decode returns no value and a non-nil
error, preserving the existing success response and no-error-log expectations.

In `@t/session.t`:
- Around line 19-60: Add a focused test alongside “bind and search share one
pinned connection” that connects and pins a client, calls client:close(), then
verifies the pinned socket state is cleared and a subsequent operation does not
reuse that closed session. Assert the expected close behavior and keep the test
isolated from the existing set_keepalive flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cf980dfe-f183-4ba9-89be-f0dff13a9ccf

📥 Commits

Reviewing files that changed from the base of the PR and between fe29c1b and 4586aa0.

⛔ Files ignored due to path filters (2)
  • t/certs/localhost_slapd_cert.pem is excluded by !**/*.pem
  • t/certs/localhost_slapd_key.pem is excluded by !**/*.pem
📒 Files selected for processing (29)
  • .cargo/config.toml
  • .github/workflows/ci.yml
  • .gitignore
  • Cargo.toml
  • Makefile
  • README.md
  • lib/resty/ldap.lua
  • lib/resty/ldap/asn1.lua
  • lib/resty/ldap/client.lua
  • lib/resty/ldap/filter.lua
  • lib/resty/ldap/ldap.lua
  • lib/resty/ldap/protocol.lua
  • rockspec/lua-resty-ldap-0.3.0-0.rockspec
  • rockspec/lua-resty-ldap-local-0.rockspec
  • rockspec/lua-resty-ldap-main-0.rockspec
  • src/ldap_codec/decoder.rs
  • src/ldap_codec/encoder.rs
  • src/ldap_codec/mod.rs
  • src/lib.rs
  • t/asn1.t
  • t/certs/mycacert.crt
  • t/client.t
  • t/decode.t
  • t/filter.t
  • t/fixtures/ad.ldif
  • t/search.t
  • t/search_ad.t
  • t/session.t
  • utils/check-rust.sh
💤 Files with no reviewable changes (8)
  • Cargo.toml
  • .cargo/config.toml
  • src/ldap_codec/encoder.rs
  • utils/check-rust.sh
  • .gitignore
  • src/ldap_codec/mod.rs
  • src/ldap_codec/decoder.rs
  • src/lib.rs

Comment thread lib/resty/ldap.lua Outdated
Comment thread lib/resty/ldap/asn1.lua
Comment thread lib/resty/ldap/client.lua
Comment thread lib/resty/ldap/client.lua Outdated
Comment thread lib/resty/ldap/ldap.lua Outdated
Comment thread lib/resty/ldap/protocol.lua
Comment thread lib/resty/ldap/protocol.lua
Comment thread lib/resty/ldap/protocol.lua

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/resty/ldap/client.lua (2)

222-230: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Packet-body receive failure returns a bare error, unlike the sibling timeout branch.

Line 229 returns err directly, while the header-timeout branch a few lines above (211) wraps it with fmt("receive response failed: %s", err). Same failure category, inconsistent context in the message.

🐛 Proposed fix
         local packet, err = socket:receive(packet_len)
         if not packet then
             -- When the packet header is read but the packet body cannot be read,
             -- this error is considered unacceptable and therefore an error is
             -- returned directly instead of processing the received data.
             _reset_socket(cli)
-            return nil, err
+            return nil, fmt("receive response failed: %s", err)
         end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/resty/ldap/client.lua` around lines 222 - 230, Update the packet-body
receive failure branch in the LDAP response-reading flow to wrap the receive
error with the same “receive response failed” context used by the header-timeout
branch, while preserving socket reset and nil return behavior.

86-105: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Socket not closed on STARTTLS decode/op-mismatch/result-code failure.

Every other failure path in _start_tls (send failure, header-receive failure, body-receive failure) closes sock before returning. The decode-failure, op-mismatch, and result-code branches at lines 88-105 don't, leaving the socket open until GC or end of request. This runs pre-TLS, so a malformed/malicious STARTTLS response repeatedly triggers this leak path.

🐛 Proposed fix
     local packet = packet_header .. packet
     local res, err = decode_ldap(packet)
     if not res then
         -- the body can carry DNs and attribute values; keep it out of the error
+        sock:close()
         return fmt("failed to decode ldap message: %s (%d bytes)",
                    err or "unknown", `#packet`)
     end
 
     if res.protocol_op ~= protocol.APP_NO.ExtendedResponse then
+        sock:close()
         return fmt("received incorrect op in packet: %d, expected %d",
                     res.protocol_op, protocol.APP_NO.ExtendedResponse)
     end
 
     if res.result_code ~= 0 then
         local error_msg = protocol.ERROR_MSG[res.result_code]
+        sock:close()
 
         return fmt("error: %s, details: %s",
                     error_msg or ("Unknown error occurred (code: " .. res.result_code .. ")"),
                     res.diagnostic_msg or "")
     end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/resty/ldap/client.lua` around lines 86 - 105, Update the failure branches
in _start_tls after decode_ldap, protocol_op validation, and result_code
validation to close sock before returning. Preserve each existing error message
and ensure all three pre-TLS STARTTLS response failure paths explicitly release
the socket, matching the existing send and receive failure handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@lib/resty/ldap/client.lua`:
- Around line 222-230: Update the packet-body receive failure branch in the LDAP
response-reading flow to wrap the receive error with the same “receive response
failed” context used by the header-timeout branch, while preserving socket reset
and nil return behavior.
- Around line 86-105: Update the failure branches in _start_tls after
decode_ldap, protocol_op validation, and result_code validation to close sock
before returning. Preserve each existing error message and ensure all three
pre-TLS STARTTLS response failure paths explicitly release the socket, matching
the existing send and receive failure handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: edd2de19-26f7-48a9-a2df-3294a5696e3e

📥 Commits

Reviewing files that changed from the base of the PR and between 4586aa0 and 08553fa.

📒 Files selected for processing (11)
  • Makefile
  • README.md
  • lib/resty/ldap.lua
  • lib/resty/ldap/asn1.lua
  • lib/resty/ldap/client.lua
  • lib/resty/ldap/ldap.lua
  • lib/resty/ldap/protocol.lua
  • t/asn1.t
  • t/decode.t
  • t/search.t
  • t/session.t
🚧 Files skipped from review as they are similar to previous changes (9)
  • t/search.t
  • t/asn1.t
  • lib/resty/ldap.lua
  • t/decode.t
  • lib/resty/ldap/asn1.lua
  • lib/resty/ldap/ldap.lua
  • lib/resty/ldap/protocol.lua
  • Makefile
  • README.md

@membphis

Copy link
Copy Markdown

[P1] TLS certificate verification is silently disabled for APISIX ldap-auth

This is a merge blocker.

APISIX passes the route option as tls_verify in apisix/plugins/ldap-auth.lua, but the restored compatibility module defaults and reads verify_ldap_host in lib/resty/ldap.lua. Therefore, even when a route sets use_tls=true and tls_verify=true, the value passed to sslhandshake remains false.

This silently defeats the requested certificate validation and can expose LDAP credentials to a man-in-the-middle server. Please normalize the compatibility option to tls_verify (with explicit backward-compatible precedence if verify_ldap_host must remain), pass the normalized value to sslhandshake, and add a regression test that verifies the third handshake argument is true.

Additional blockers found on the current head 08553fa8f92f2c26707782c7d323c10e49f695da:

  • [P2] DN identity mismatch: the library RFC 4514-escapes the username for Bind, while APISIX builds the Consumer user_dn from the raw username. Usernames containing DN metacharacters can fail Consumer lookup or resolve to a different DN string. The authentication API and APISIX Consumer lookup need to share the same canonical DN construction.
  • [P2] Unhandled socket failures in the compatibility path: lib/resty/ldap/ldap.lua does not check several socket:send and socket:receive results before parsing or concatenating them. Normal timeout, EOF, or truncated-response failures can become uncaught Lua errors instead of controlled authentication failures. Please check every cosocket result, close unusable connections, and add failure-path tests.

Harden the ASN.1 and LDAP message layers against malformed and hostile
input, and add a pure-Lua test suite covering the new behaviour.

asn1.lua:
- reject non-canonical tag forms, enforce primitive/constructed form per
  universal type, and validate decode offsets before pointer arithmetic
- refuse integers outside the exactly-representable 2^53 range on both
  encode and decode
- reject non-string/non-boolean payloads instead of emitting a header that
  disagrees with the bytes; return errors rather than bare nils
- free the buffer i2d_* allocates (fixes a per-encode leak in the worker)

protocol.lua:
- pin the RFC 4511 ASN.1 type of every decoded field
- enforce a single protocolOp per LDAPMessage (plus optional controls) and
  an explicit response-op allowlist
- type-guard the bind and search request builders

client.lua / ldap.lua:
- validate the BER length header, drop and unpin the socket on error, and
  guard bind argument types

Also gitignore the local deps/ luarocks build tree.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@t/lib/ldap_hex.lua`:
- Around line 4-6: Update the hex-decoding function in t/lib/ldap_hex.lua to
validate the whitespace-stripped input before decoding: reject odd-length
strings and any non-hex characters instead of preserving unmatched text. Keep
valid pairs decoded through string.char and surface malformed input immediately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e037b39f-4c81-4e94-8176-c5cb4d9180d2

📥 Commits

Reviewing files that changed from the base of the PR and between 08553fa and abc9191.

📒 Files selected for processing (25)
  • .gitignore
  • lib/resty/ldap/asn1.lua
  • lib/resty/ldap/client.lua
  • lib/resty/ldap/ldap.lua
  • lib/resty/ldap/protocol.lua
  • t/asn1.t
  • t/asn1_bounds.t
  • t/asn1_class.t
  • t/asn1_constructed.t
  • t/asn1_depth.t
  • t/asn1_encode.t
  • t/asn1_encode_leak.t
  • t/asn1_ffi.t
  • t/asn1_int_precision.t
  • t/asn1_integer.t
  • t/asn1_nil_member.t
  • t/asn1_oracle.t
  • t/asn1_vectors.t
  • t/decode.t
  • t/decode_hostile.t
  • t/decode_member_count.t
  • t/decode_op_dispatch.t
  • t/decode_protocol.t
  • t/decode_rfc4511.t
  • t/lib/ldap_hex.lua
🚧 Files skipped from review as they are similar to previous changes (5)
  • t/decode.t
  • t/asn1.t
  • lib/resty/ldap/client.lua
  • lib/resty/ldap/ldap.lua
  • lib/resty/ldap/asn1.lua

Comment thread t/lib/ldap_hex.lua Outdated
Delete 41 duplicate test blocks and dissolve t/decode_member_count.t and
t/asn1_int_precision.t into their canonical homes; strengthen each home
first (offset assertions, migrated vectors, provenance headers) so no
coverage is lost. Verified by mutation testing: re-introducing each
hardened-away bug still fails a surviving test.
@membphis

Copy link
Copy Markdown

[P1] Keepalive pool reuse can bypass TLS certificate verification

This is a merge blocker on the current head f90c6f11ac0843907875ecaa31c44846c11a8505.

Both the compatibility path and resty.ldap.client build their cosocket pool keys without the certificate-verification policy. A request using tls_verify=false / ssl_verify=false can therefore establish a TLS connection and return it to the pool, and a later request to the same endpoint with verification enabled can reuse that already-handshaken connection. OpenResty documents that sslhandshake returns immediately for connections that have already completed the TLS handshake, so the certificate is not re-verified under the stricter policy.

As a result, a route that explicitly requires certificate verification can unknowingly send LDAP credentials over a connection that was established without certificate validation.

Please partition the pool by both transport mode and verification policy (for example, plain / starttls / ldaps plus verify / noverify) in both paths. Please also add a same-worker regression test that establishes and pools a connection with verification disabled, then enables verification for the same endpoint and proves that the unverified connection is not reused.

@membphis

Copy link
Copy Markdown

[P2] Bind DN and APISIX Consumer DN still diverge

Follow-up on the current head f90c6f11ac0843907875ecaa31c44846c11a8505: the TLS verification and compatibility-path socket handling issues from my previous comment appear addressed, but this DN identity mismatch remains.

lib/resty/ldap.lua RFC 4514-escapes the username before constructing the Bind DN, while APISIX still constructs the Consumer user_dn from the raw username. For usernames containing DN metacharacters, authentication and Consumer lookup therefore use different identity keys.

Please use one canonical DN construction on both sides, or return and use the actual LDAP entry DN instead of reconstructing it independently.

@membphis

Copy link
Copy Markdown

no ci?
pls add a ci to run the test case

Loosen the TLS verify error_log pattern to a substring stable across
lua-nginx-module versions, and raise the client timeout on the encode
leak tests, which exceed Test::Nginx's 3s default on slow runners.
@janiussyafiq

Copy link
Copy Markdown
Contributor Author

@membphis addressed all comments, CI just need approval to run

LDAP framing is BER length-driven; receiveuntil("\0") silently consumed
0x00 bytes at the header position and crashed calculate_payload_length on
hostile responses.
@membphis

Copy link
Copy Markdown

[P1][New] Authenticated LDAP sockets can leak into the generic keepalive pool

This is a merge blocker on the current head bf897a5db24cb1b88720acde98c757b5f8719c1d.

LDAP Bind state persists on a connection, but both the single-shot simple_bind() path and client:set_keepalive() can return an authenticated socket to a pool keyed only by endpoint, transport mode, and TLS verification policy. A later client can therefore check out a socket that is still bound as the previous identity and call search() without rebinding. If the previous Bind used an administrative DN, this becomes a cross-request authorization leak.

Please track whether a socket has been bound and never return an authenticated connection to the generic pool. Closing bound connections is the safest default. Please also add a same-worker regression covering: admin Bind -> release -> new anonymous client -> search must not inherit the admin identity.

Non-blocking follow-ups for awareness:

  • [P2] Bind DN / Consumer DN mismatch remains: the library now returns the canonical escaped user_dn, but APISIX still ignores that third return value and rebuilds the Consumer key from the raw username. RFC 4514 metacharacters can therefore authenticate successfully but fail Consumer lookup.
  • [P2] LDAP message IDs never wrap: the module-level counters continue past RFC 4511 maxInt, so a long-lived high-traffic worker can eventually emit out-of-range message IDs. Please wrap safely back to 1 and add a boundary test.

The P2 items are FYI and do not need to block this PR.

A Bind pins an identity to the connection, but the keepalive pool key
only encodes endpoint and TLS policy, so a pooled bound socket could
serve a later client's requests under the previous identity. Track
bind state and close such sockets on release instead of pooling them.

Both module-level message ID counters now wrap back to 1 past RFC 4511
maxInt. Also renames the file-local _send_recieve to _send_receive.

@membphis membphis left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use the canonical authenticated DN for APISIX Consumer lookup

The latest head fixes the bound-socket pooling and message-ID issues, but this identity mismatch remains. ldap_authenticate() now RFC 4514-escapes the username and returns the canonical Bind DN as its third result. APISIX still ignores that value and rebuilds user_dn from the raw username, so a username containing DN metacharacters can bind successfully but fail Consumer lookup.

Please have APISIX consume the returned canonical DN (or the actual LDAP entry DN) and add an end-to-end case covering a DN-special-character username.

@janiussyafiq

Copy link
Copy Markdown
Contributor Author

@membphis will track that issue in APISIX repo upon version release.

Comment thread .github/workflows/ci.yml Outdated

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the latest version and pin it using a hash.

For example:

https://github.com/api7/adc/blob/main/.github/workflows/e2e.yaml#L18

Comment thread lib/resty/ldap/ldap.lua Outdated

@bzp2010 bzp2010 Jul 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between this and client.lua?

It looks like we have a lot of entrypoint files right now:

resty.ldap

resty.ldap.ldap

resty.ldap.client

I'm a little confused.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For backward compatibality since I'm restoring file from v0.1.0 to ensure zero config change for existing ldap-auth, but i do agree it seems confusing. I think this can be refactored.

janiussyafiq added a commit to janiussyafiq/apisix that referenced this pull request Jul 23, 2026
…rk-only)

Temporary fork-CI branch: overlay api7/lua-resty-ldap#32 into deps after
make deps, and trigger CI on push of this branch. Not for upstream.
janiussyafiq added a commit to janiussyafiq/apisix that referenced this pull request Jul 23, 2026
…rk-only)

Temporary fork-CI branch: overlay api7/lua-resty-ldap#32 into deps after
make deps, and trigger CI on push of this branch. Not for upstream.
resty.ldap.ldap duplicated the client transport stack line for line
(calculate_payload_length, bind/start_tls send-receive loops, error
tables, message-id counter). ldap_authenticate is now a thin wrapper
over resty.ldap.client, leaving two entrypoints: resty.ldap.client
(the API) and resty.ldap (compat facade for APISIX ldap-auth).

- a socket that carried a Bind is always closed, never pooled,
  whether the bind succeeded or failed
- client:simple_bind returns nil on transport/decode failures and
  false only when the server rejects the bind, so the facade keeps
  its nil=unreachable / false=rejected contract
- header-timeout and truncated-body seams ported to client.t; the
  dead-socket and message-id-wrap seams were already covered by
  session.t and asn1_encode.t

@membphis membphis left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@membphis

Copy link
Copy Markdown

Post-merge follow-ups

After this PR is merged, two follow-up areas remain:

  1. Library operation state and result contract: tracked in fix: make LDAP operation completion and result states unambiguous #33. The library should correlate every response with the request message ID, require the expected terminal response, fail closed on EOF/RST/truncation, and expose machine-readable result categories that downstream callers can consume without parsing error strings.

  2. Downstream APISIX integration: APISIX should use the canonical DN returned by ldap_authenticate() for Consumer lookup; distinguish invalid credentials (401) from DNS/connect/TLS/timeout/protocol or LDAP availability failures (a service error such as 503); preserve the complete Basic password after the first : without stripping password whitespace; prevent Authorization, Base64, or decoded credential material from reaching logs; and make timeout, CA, StartTLS, and HA settings configurable.

These are post-merge production-readiness follow-ups and do not block this refactor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants