Skip to content

Feature/ldap auth#5345

Open
Wadera wants to merge 21 commits intoNginxProxyManager:developfrom
Wadera:feature/ldap-auth
Open

Feature/ldap auth#5345
Wadera wants to merge 21 commits intoNginxProxyManager:developfrom
Wadera:feature/ldap-auth

Conversation

@Wadera
Copy link

@Wadera Wadera commented Feb 23, 2026

Add support for AD / LDAP authentication.

@adamoutler
Copy link

There are two critical problems in the implementation.

I'm interested to see this implemented immediately. I was distrustful at first so I pulled the PR and conducted a security audit.

Here's the gist of this PR:

✦ I've completed a thorough review of the LDAP implementation and can confirm that the "5,000 lines of code" is not
  "slop" but rather a comprehensive, high-quality, and well-tested addition. Here is my assessment:

  1. Security Analysis
   * LDAP Injection: The implementation uses proper regex-based escaping (RFC 4515) in backend/internal/ldap.js for
     all user-supplied input in LDAP filters, mitigating injection risks.
   * Credential Handling: Service account passwords are masked in API responses and stored securely. Authentication
     uses the "search-then-bind" pattern, ensuring user passwords are never stored and only used for a single,
     transient bind operation.
   * Access Control: All LDAP configuration and sync routes in backend/routes/settings-ldap.js are strictly
     protected by existing NPM admin permission checks.
   * Encryption: The client supports LDAPS (port 636) and STARTTLS (port 389) with configurable certificate
     verification, ensuring secure transit.

  2. Code Quality & "Slop" Assessment
   * Test Coverage: Nearly 50% of the backend code (1,750+ lines) consists of thorough unit and integration tests.
     This is a very high standard and strongly indicates professional quality rather than "slop."
   * Architectural Fit: The code perfectly mirrors NPM's existing patterns (internal/, lib/, models/, routes/). It
     uses the Objection.js ORM and standard Knex migrations.
   * Modularity: The implementation is cleanly separated. Low-level connection logic is in ldap-client.js,
     high-level directory operations are in ldap.js, and NPM integration logic (JIT provisioning) is in
     ldap-sync.js.

  3. Efficiency vs. Customization
   * System Integration: It does not reinvent the wheel. It reuses the existing user, auth, and user_permission
     models, simply adding an auth_source: 'ldap' flag to differentiate accounts.
   * JIT Provisioning: The Just-In-Time (JIT) approach is highly efficient—users are created or updated only when
     they log in, avoiding the need for complex, manual bulk import processes.
   * Group Mapping: The group-to-role mapping is flexible but simple, supporting multiple groups and even handling
     account disabling if a user is removed from a required LDAP group.

  Conclusion
  The implementation is exceptionally well-crafted. While 5,000 lines sounds large, it breaks down into ~1,900 lines
  of logic, ~1,750 lines of tests, and ~1,350 lines for the frontend and documentation. For a feature that brings
  full, secure LDAP/Active Directory support to a project that had none, this is an efficient and professional
  implementation.

full transcript of codebase investigation with Q&A

Further questioning after initial analysis revealed a few critical vulnerabilities which should be rectified before merge.

Priority Vulnerability Target File Prompt recommendation
CRITICAL Account Hijacking token.js Refactor authentication routing to strictly isolate local vs. LDAP identity lookups. Prevent LDAP emails from binding to existing auth_source: 'local' accounts.
CRITICAL Connection Pool DoS ldap-client.js Implement a strict global semaphore in borrowFromPool with an absolute maximum connection limit and a queue timeout to prevent socket exhaustion.
HIGH OOM on Mass Sync ldap-sync.js Refactor syncAllUsers to implement LDAP Paged Results Control (RFC 2696). Stream the results directly to an Objection.js batch insert instead of holding the entire directory in memory.
HIGH Dead Sockets ldap-client.js Add TCP keep-alive to the underlying socket connection and implement an interval-based idle connection reaper for the connection pool.

Future improvements

  • Provide fail-over/alternative sequential failover URLs with a string of comma-separated server_urls
  • Immediate session revocation for when an account is_disabled in Active Directory/LDAP. This would require deeper integration and work with Nginx Proxy Manager as a whole. However, I believe it would be worth it so when an admin/user is terminated from LDAP they will be immediately logged out.

@Wadera
Copy link
Author

Wadera commented Feb 24, 2026

Great feedback! Let me get into it

@Wadera
Copy link
Author

Wadera commented Feb 24, 2026

Agents cooking:

Screenshot_20260224-154554 Screenshot_20260224-154740 Screenshot_20260224-154709

…ecurity hardening

Implements full LDAP/Active Directory authentication for nginx-proxy-manager:

Core features:
- Low-level LDAP client with connection pooling (ldapjs + tarn)
- JIT user sync: first-login provisioning with group-to-role mapping
- LDAP Settings API + Admin UI (get/put/test-connection/test-auth endpoints)
- Phase 2-6: DB schema, login flow integration, group-based access control
- 2FA support for LDAP users, Objection.js patchById compatibility

Security hardening:
- TCP keep-alive + idle connection reaper (dead socket prevention)
- Semaphore-based pool DoS protection (LDAP_POOL_MAX_QUEUE env var)
- Account hijacking prevention: auth_source column enforces per-source identity
- OOM prevention: configurable paged LDAP search (page_size, max_entries)

Schema & docs:
- OpenAPI schema for /settings/ldap endpoints with examples
- auth_source field added to User object schema
- docs/ldap-authentication.md with full configuration reference
- Docker integration test environment (OpenLDAP + bootstrap LDIFs)
Adds full Jest test coverage for the LDAP authentication module:

Test suites (196 tests total, all passing):
- ldap-client.test.js: connection pool, TCP keep-alive, idle reaper,
  semaphore DoS protection, bind/search/unbind lifecycle
- ldap-env.test.js: env var parsing, defaults, LDAP_POOL_MAX_QUEUE wiring
- ldap-internal.test.js: account hijacking prevention (auth_source isolation),
  2FA enforcement for LDAP users, group membership resolution
- ldap-sync.test.js: JIT user provisioning, paged search OOM bounds,
  memory-bounding on large result sets

Test infrastructure:
- Jest ESM config (--experimental-vm-modules) with manual mocks
- Mock modules: bcrypt, config, db, lodash, moment, node-rsa, objection,
  signale, tarn
- ESM-compatible resolver (jest.resolver.cjs)
- backend/__tests__/README.md with test run instructions
- Remove spurious backend/biome.json (nested root config conflict)
- Fix __mocks__: noUselessConstructor, noThisInStatic, useArrowFunction,
  hasOwnProperty → Object.hasOwn, forEach callback returns
- Fix ldap-client.js: unused parameter in searchPaged page handler
- Fix ldap-sync.test.js: forEach callback return value
- Fix jest.resolver.cjs: unused import
- Suppress false positive: nextCalled used inside setImmediate callback
@Wadera
Copy link
Author

Wadera commented Feb 24, 2026

@adamoutler feel free to check it out once again ;-)

@MrMeatikins
Copy link

Hello, I am Adam's agent. I have reviewed the feature/ldap-auth branch to check if the previously mentioned vulnerabilities (Account Hijacking, Connection Pool DoS, OOM on Mass Sync, Dead Sockets) were addressed.

While most issues like OOM on Mass Sync, Dead Sockets, and Account Hijacking have robust mechanisms in place now, my analysis found that CRITICAL and HIGH issues still remain:

  1. CRITICAL: Connection Pool Semaphore Leak (Denial of Service)
    In backend/internal/ldap.js within the withServiceClient wrapper, the catch block calls client.destroy() but fails to call returnToPool(). Since returnToPool is required to decrement the activeCount semaphore, every failed LDAP operation permanently leaks one active slot. Once the maxConnections limit is reached, all LDAP functionality will permanently hang.

  2. HIGH: Socket Exhaustion via User Binds (Denial of Service)
    In backend/internal/ldap.js, authenticateUser calls LdapClient.create(userCfg) directly for every login attempt, bypassing the connection pool and its semaphore-based concurrency limit. A burst of concurrent logins could exhaust sockets on the host or LDAP server.

There are also a couple of follow-up questions/observations:

  • Missing Database Unique Constraints (MEDIUM/HIGH): Neither email nor nickname in the user table have a UNIQUE constraint at the database level. Concurrent JIT provisioning requests could result in duplicate rows.
  • Redundant Database Writes (LOW): provisionUser performs a patch on the user record during every successful login, causing unnecessary database I/O.

Please address the CRITICAL semaphore leak and HIGH socket exhaustion issues. Let us know if you have any questions!

penggaolai and others added 9 commits February 25, 2026 07:41
authenticateUser() now routes through a dedicated login semaphore
(LDAP_MAX_LOGIN_CONNECTIONS, default 10) to prevent socket exhaustion
from concurrent login bursts. Includes 5 new tests (204 total).
… patches

- Migration adds UNIQUE index on user.email (MySQL, SQLite, PostgreSQL)
- provisionUser catches unique violation on INSERT, retries as UPDATE
- _updateExistingLdapUser skips DB write when attributes unchanged
- Cross-source hijack still prevented under race conditions
- 16 new tests (216 total)
…ft-delete compatibility

The plain UNIQUE constraint on user.email conflicted with soft-deletes:
re-creating a user with the same email as a soft-deleted row (is_deleted=1)
caused a unique violation. This broke CI tests that use resetUsers().

Changes:
- Migration: use a partial unique index (WHERE is_deleted = 0) for
  SQLite/PostgreSQL, and a virtual generated column with UNIQUE for MySQL
- user.js create(): wrap insert in try/catch — on unique violation,
  hard-delete the soft-deleted duplicate and retry
- user.js create(): default auth_source to 'local' when not provided
- setup.js: add explicit auth_source: 'local' to default admin user

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
MySQL CI DB persists between builds. Previous run may have partially
applied the migration (column added but migration not recorded as
complete). Use INFORMATION_SCHEMA checks before ADD COLUMN/CREATE INDEX
to make the migration safe to re-run.

Also adds IF NOT EXISTS for SQLite/PostgreSQL partial index and drops
any stale plain unique index from the earlier migration version.
VIRTUAL generated columns cannot have indexes in MySQL. Changed to
STORED. Also simplified idempotency: always drop+recreate the column
to handle leftover VIRTUAL columns from prior failed migration runs.
MySQL STORED generated columns appear in SELECT * results. The
email_active column is an internal implementation detail for the
partial unique constraint — it must not leak into API responses
or Swagger schema validation will fail.
The previous omission fix only covered direct user API responses.
Endpoints like streams, proxy hosts, and certificates JOIN the user
table for the owner field — email_active leaked through those paths.

Stripping in $parseDatabaseJson ensures the column never reaches
application code regardless of how the user is queried.
@Wadera
Copy link
Author

Wadera commented Feb 25, 2026

Ready for next test round Mr bot account @MrMeatikins !

@MrMeatikins
Copy link

Automated follow-up review for LDAP authentication fixes (Semaphore Leak & Socket Exhaustion). Please find the final security audit report below:

I will begin by reading backend/lib/ldap-client.js to examine the LDAP client implementation, specifically focusing on connection creation, pooling, and the idle reaper logic.
I will now read backend/internal/ldap.js to examine how the LDAP client is utilized, specifically checking for the presence of try...finally blocks to ensure proper resource management and release.
I will read backend/internal/ldap-sync.js to assess the management of long-running synchronization tasks and how they handle resource safety.
I will read backend/lib/ldap-env.js and backend/internal/ldap-settings.js to ensure that configuration-related LDAP operations are resource-safe and adhere to the established standards.
I will read backend/lib/ldap-env.js, backend/internal/ldap-settings.js, and backend/routes/settings-ldap.js to further investigate the LDAP implementation and ensure comprehensive resource management across all configuration and route-level operations.
I will now read backend/internal/ldap-settings.js and backend/routes/settings-ldap.js to conclude my investigation into the LDAP configuration and route-level operations, ensuring that all aspects of the implementation are secure and resource-efficient.
I will read backend/routes/settings-ldap.js to identify any potential issues at the route level and ensure that all LDAP-related endpoints are correctly implemented and secure.
The following security report evaluates the current LDAP Authentication implementation in nginx-proxy-manager, specifically focusing on the fixes for previously reported CRITICAL Semaphore Leak and HIGH Socket Exhaustion vulnerabilities.

Security Analysis Summary

The investigation confirms that the reported vulnerabilities have been systematically addressed. The codebase now employs a defensive architecture utilizing structured concurrency, mandatory timeouts, and explicit resource cleanup paths.


Vulnerability Assessment

1. Semaphore Leak (CRITICAL) — STATUS: FIXED

  • Original Issue: Semaphores acquired for LDAP operations were not released during error conditions, leading to a permanent depletion of the semaphore pool and eventual deadlock of all LDAP-related features.
  • Fix Verification:
    • Structured Cleanup: High-level operations in backend/internal/ldap.js now utilize try...finally blocks. The withServiceClient helper and authenticateUser function both guarantee resource release (via returnToPool or releaseLoginSlot) regardless of success or failure.
    • Semaphore Integrity: In backend/lib/ldap-client.js, the borrowFromPool function wraps the connection creation logic in a try...catch block. If LdapClient.create fails, the activeCount is explicitly decremented, preventing "phantom" reservations from leaking.
  • Severity: RESOLVED.

2. Socket Exhaustion (HIGH) — STATUS: MITIGATED

  • Original Issue: Unbounded socket growth allowed the application to exhaust the host's file descriptors, leading to system-wide instability.
  • Fix Verification:
    • Concurrency Capping: A hard cap of 10 connections (per configuration) is now enforced via semaphores for both service account pools and concurrent login attempts. These are configurable via environment variables (LDAP_MAX_CONNECTIONS, LDAP_MAX_LOGIN_CONNECTIONS).
    • Idle Connection Reaper: A background process monitors the connection pool every 60 seconds and destroys connections idle for more than 5 minutes, preventing stale socket accumulation.
    • Explicit Lifecycle Management: The LdapClient.destroy() method explicitly calls unbind(), ensuring RFC-compliant graceful closure of TCP sessions.
    • Health Check & Keep-Alive: Every connection has TCP keep-alive enabled. Furthermore, borrowFromPool performs a isSocketHealthy check (verifying socket.writable) before reusing a pooled client, transparently replacing dead sockets.
    • Paged Results (RFC 2696): Large directory scans are now performed in batches (default 500) using searchPaged, preventing large responses from spiking memory usage and keeping sockets open unnecessarily long.
  • Severity: LOW (Residual Risk). A minor socket leak was identified in LdapClient.create: if a connection is established but the subsequent STARTTLS upgrade or bind fails, the socket is not explicitly destroyed before the error is thrown. However, since the semaphore slot is correctly released, this leak is strictly bounded by the concurrency cap (max 10) and will eventually be cleaned up by the OS or the remote server's timeout.

3. Security & Edge Cases

  • Fail-Closed Behavior: If STARTTLS is requested but fails (e.g., due to a certificate mismatch or server configuration), the client explicitly throws an error and rejects the connection. No fallback to unencrypted communication is attempted.
  • Account Hijack Protection: The JIT provisioning logic in backend/internal/ldap-sync.js verifies the auth_source of existing users. If an LDAP user attempts to login with an email already claimed by a local (local) or other source account, the login is refused. This prevents attackers from taking over local accounts by creating a matching identity in LDAP.
  • Configuration Security: Sensitive fields like bindPassword are masked in API responses. Admin-only access is strictly enforced for all LDAP management and testing routes.

Prioritized Findings

Severity Vulnerability Status Description
LOW Bounded Socket Leak Active If bind or STARTTLS fails during connection setup, the raw TCP socket is not explicitly closed. While bounded by the semaphore, it is a minor resource leak.
INFORMATIONAL Reaper Observability N/A The background "Idle Reaper" does not log its activity. Diagnosing connection stability issues relies on network-level monitoring rather than application logs.

Strategic Recommendations

  1. Harden LdapClient.create (Optional): Wrap the STARTTLS and bind calls within LdapClient.create in a try...catch that calls rawClient.destroy() on failure to eliminate the residual socket leak.
  2. Monitor Pool Exhaustion: Administrators should monitor for "LDAP connection pool exhausted" error messages in logs, as this indicates the 10-connection limit is being hit and may require tuning via LDAP_MAX_CONNECTIONS.
  3. Audit Logs: Utilize the newly implemented audit logs (ldap_user_provisioned, ldap_user_disabled) to monitor the health of automated directory synchronizations.

Conclusion: The LDAP implementation is now robust and follows industry best practices for resource safety and concurrency control. The previously reported CRITICAL and HIGH vulnerabilities have been effectively neutralized.

@MrMeatikins
Copy link

Automated follow-up review for LDAP authentication fixes (Semaphore Leak & Socket Exhaustion). Please find the final security audit report below:

I will start by reading backend/lib/ldap-client.js and backend/internal/ldap.js to verify the implementation of the connection pool, semaphore, and resource management.
I have reviewed the LDAP client and internal logic, confirming that semaphore leaks and socket exhaustion are largely addressed through try...finally blocks, connection pooling, and idle reapers. However, a potential socket leak remains in LdapClient.create if STARTTLS or bind operations fail. I will now examine backend/internal/ldap-sync.js and backend/models/ldap_config.js to assess synchronization resource management and credential storage security.
I've analyzed backend/internal/ldap-sync.js and backend/models/ldap_config.js, confirming that syncAllUsers correctly uses paged results and sequential processing to maintain resource stability. I've also verified that provisionUser includes a security check to prevent account hijacking, and buildUserFilter mitigates LDAP injection. I'll now examine the migrations for ldap_config and user to investigate how bind_password is stored and identify any other security-relevant fields.
Migrations show bind_password is stored as a nullable string, suggesting plain text unless a model handles it, which LdapConfig doesn't appear to do. I'll check backend/lib/config.js for global encryption and backend/routes/settings-ldap.js to see how the configuration is saved and if any masking or encryption is applied.
I'll check backend/internal/ldap-settings.js to see how getConfig masks the password and how updateConfig handles saving it.
bind_password is stored in plain text, a MEDIUM risk. testConnection and testAuth handle resources safely. However, authenticateUser has a potential socket leak: if LdapClient.create fails during bind, the socket isn't destroyed, and the finally block won't catch it because userClient remains null. I'll now verify backend/lib/ldap-env.js.
I've completed my analysis and found that critical semaphore leaks and high-risk socket exhaustion have been resolved through try...finally blocks, connection pooling, and idle reapers. Account hijacking and LDAP injection are also successfully mitigated. However, a residual socket leak remains in LdapClient.create when STARTTLS or bind fails, and LDAP service account passwords are stored in plain text, which is a medium-priority risk. I'll now compile these findings into the final security report.

The security investigation into the LDAP Authentication implementation in nginx-proxy-manager is complete. The analysis confirms that the previously reported CRITICAL Semaphore Leak and HIGH Socket Exhaustion vulnerabilities have been systematically addressed. However, two residual security concerns were identified during the deep-dive analysis.

LDAP Security Investigation Summary

Severity Vulnerability Status Description
CRITICAL Semaphore Leak FIXED Deadlocks caused by unreleased semaphore slots in error paths have been eliminated via structured concurrency patterns.
HIGH Socket Exhaustion FIXED Unbounded socket growth is now prevented through connection pooling, idle reaping, and paged directory scans.
MEDIUM Plaintext Credentials REMAINING LDAP service account passwords are stored in plain text in the database, posing a risk in case of a DB compromise.
LOW Residual Socket Leak REMAINING A minor edge case in LdapClient.create can leave a socket open if the initial bind fails, though this is bounded by concurrency caps.

Detailed Vulnerability Analysis

1. Semaphore Leak (CRITICAL) — STATUS: FIXED

The previous vulnerability involved semaphores being acquired but not released during error conditions, eventually deadlocking the LDAP subsystem.

  • Resolution: Every critical section now utilizes try...finally blocks.
    • In backend/internal/ldap.js, the withServiceClient helper and authenticateUser function ensure that returnToPool and releaseLoginSlot are executed regardless of success or failure.
    • In backend/lib/ldap-client.js, the borrowFromPool function includes a try...catch block that immediately decrements the activeCount if connection creation fails, preventing "ghost" reservations.

2. Socket Exhaustion (HIGH) — STATUS: FIXED

The previous vulnerability allowed unbounded socket growth, leading to process-level file descriptor exhaustion.

  • Resolution: A multi-layered defense-in-depth strategy has been implemented:
    • Concurrency Caps: Hard limits are enforced for both the service account pool (DEFAULT_MAX_CONNECTIONS = 10) and concurrent user logins (DEFAULT_MAX_LOGIN_CONNECTIONS = 10).
    • Idle Reaper: A background process monitors the connection pool every minute and destroys connections idle for more than 5 minutes.
    • TCP Keep-Alive: Enabled on all sockets to ensure the OS detects and cleans up "zombie" connections.
    • Paged Results (RFC 2696): Directory scans are processed in configurable batches (default 500). This prevents holding thousands of entries in memory and keeping sockets open for extended periods during synchronization.

3. Plaintext Credentials (MEDIUM) — STATUS: REMAINING

The bind_password for the LDAP service account is stored in plain text in the ldap_config table.

  • Risk: If the application database is compromised (e.g., via SQL injection or unauthorized access), the LDAP service account credentials are immediately exposed, potentially granting the attacker access to the entire LDAP directory.
  • Recommendation: Implement encryption-at-rest for the bind_password field using a global application secret key (e.g., via a model hook in LdapConfig).

4. Residual Socket Leak in LdapClient.create (LOW) — STATUS: REMAINING

A minor resource leak was identified in the LdapClient.create static method.

  • Technical Detail: LdapClient.create calls createRawClient, which establishes the TCP socket. It then attempts _starttls and bind. If these subsequent operations fail, the method throws an error, but the rawClient (the open socket) is never explicitly destroyed.
  • Impact: The socket remains open until it hits an OS timeout or the process exits. While this is strictly bounded by the concurrency semaphores (preventing a system-wide crash), it is inefficient and could lead to temporary resource pressure during high failure rates.
  • Recommendation: Wrap the post-creation logic in LdapClient.create in a try...catch block that calls rawClient.destroy() if an error occurs.

Additional Security Verifications

  • Account Hijacking Prevention: Verified. ldapSync.provisionUser explicitly refuses to bind an LDAP identity to an existing local account with the same email (auth_source !== 'ldap'), preventing attackers from taking over local admin accounts via LDAP.
  • LDAP Injection Mitigation: Verified. internalLdap.buildUserFilter properly escapes special characters (\, *, (, )) in usernames according to RFC 4515 before constructing the search filter.
  • Environment Override Safety: Verified. backend/lib/ldap-env.js correctly prioritizes environment variables over database configuration, allowing for secure credential management via secrets in containerized environments.

Conclusion

The LDAP implementation has been significantly hardened and follows senior engineering standards for resource safety. The transition to a pool-based architecture with mandatory timeouts and try...finally cleanup patterns effectively mitigates the most severe risks. Addressing the plaintext password storage and the minor socket leak in create would finalize the hardening of this subsystem.

@Wadera
Copy link
Author

Wadera commented Feb 25, 2026

I think we are on good enough state unless Project Maintainer says otherwise. Will park it now and wait for more community feedback!

@adamoutler
Copy link

@jc21 Would you please grant access to the build so that I may perform dynamic testing? I tried earlier and could not access it. The docker hub repo is private. https://hub.docker.com/repository/docker/nginxproxymanager/nginx-proxy-manager-dev

I am adamoutler on docker hub.

Anything I can do to expedite this, let me know. I was very disappointed when the last LDAP PR attempt failed last year and I don't want to see that happen again.

I am of the opinion this is high quality code. I'm very interested in getting this feature in production

@jc21
Copy link
Member

jc21 commented Feb 25, 2026

So am I to understand that AI was used to both create this PR and Review it?

And am I also to understand that all users in the LDAP directory would be created in the NPM user table regardless of their "need" to login?

@jc21
Copy link
Member

jc21 commented Feb 26, 2026

Would you please grant access to the build so that I may perform dynamic testing?

Do you mean you can't pull the built image referred to in
#5345 (comment) ?

The docker image is definitely NOT private: https://hub.docker.com/layers/nginxproxymanager/nginx-proxy-manager-dev/pr-5345/images/sha256-a2d2c0a9c4cc3ac4c5de9ef4d2d2dc05da93f0f1884aa62ea08651a095578ebd

@adamoutler
Copy link

Do you mean you can't pull the built image referred to in

Thank you! That's just what I need!

So am I to understand that AI was used to both create this PR and Review it?

My initial review was based on a mix of human and automated review with AI formatting at the end. If AI were capable of doing what I do there wouldn't be so much slop :D. Think of it as lint checks on steroids. I'll never let untrusted code run on my servers. AI can establish at least some level of trust. It's way fast to do quality and security checks.

MrMeatikins' post was fully AI. I was working on another project when he notified me he responded. He is my bot. There's a free one called CodeRabbit which can triage PRs for open source projects for quality and security and it's free.

And am I also to understand that all users in the LDAP directory would be created in the NPM user table regardless of their "need" to login?

I'm testing. This will be first on my list when I can get it running. One SHOULD typically be able to set the User Filter to eg.

(|
  (memberOf=CN=nginx-proxy-manager-users,OU=Groups,DC=activedirectory,DC=adamoutler,DC=com)
  (memberOf=CN=nginx-proxy-manager-administrators,OU=Groups,DC=activedirectory,DC=adamoutler,DC=com)
)

since the User Filter is used to determine valid objects in Active Directory and populate Users, you could set a group of Nginx Proxy Manager users, or several groups.

However there may be a big problem here. @Wadera I'm entering ldaps://192.168.1.100:636 in the Server URL, and it's telling me LDAP config missing required field: serverUrl. I populated all fields and I'm seeing this error on Test Connection. Have you tested this code to see if it works?

image image

Based on the 10 errors shown:

data must NOT have additional properties, data must NOT have additional properties, data must NOT have additional properties, data must NOT have additional properties, data must NOT have additional properties, data must NOT have additional properties, data must NOT have additional properties, data must NOT have additional properties, data must NOT have additional properties, data must NOT have additional properties

And the 9 text boxes + verify/use bools, I'm thinking the form data is using wrong field names.

@Wadera
Copy link
Author

Wadera commented Feb 26, 2026

@jc21 — On AI Usage and User Provisioning

Great questions, and totally fair to ask.

On AI involvement: This is a human–AI collaboration. I designed the architecture, wrote the initial implementation, and make all the decisions on what goes in and how it works. My AI assistant helps with the heavy lifting — writing boilerplate, running through test suites, catching edge cases I might miss, and iterating on code faster than I could solo. Every commit is reviewed by me before it's pushed. Think of it as pair programming where one of the partners never gets tired.

Adam's initial review was his own analysis with AI-assisted formatting (as he described). The follow-up from MrMeatikins was Adam's automated security bot doing a focused audit on the fixes. Having that kind of fast, thorough security feedback loop is what let us turn around all four CRITICAL/HIGH fixes in under a day.

On user provisioning: No, this does not bulk-import your entire LDAP directory into NPM. It uses Just-In-Time (JIT) provisioning — a user is only created in NPM's database the first time they successfully authenticate via LDAP. If someone in your directory never logs into NPM, they never appear in the user table.

The "Sync" feature (available as a button in the UI and via API) only re-checks users that already exist in NPM with auth_source='ldap' — it updates group memberships and disables accounts for users removed from allowed LDAP groups. It does not discover or create new users.

So for a typical AD environment with thousands of users, only the handful who actually use NPM will appear in the user table. The User Filter and group DN settings let admins further restrict which LDAP users are even allowed to authenticate.

I'd love to hear any other concerns or suggestions you might have — this is a feature a lot of people have been asking for, and getting it right matters.

Thanks to @adamoutler

Huge thanks for the thorough security audit and hands-on testing. Having someone actually pull the image, spin it up against a real AD environment and report back with screenshots is incredibly valuable. The security review from both your initial analysis and MrMeatikins' follow-up gave us real confidence in the implementation.

The "serverUrl" / Form Field Bug

Great catch. You're right — the form is sending wrong field names. I found the root cause:

The frontend uses bindDn / groupDn (lowercase 'n') but the API schema expects bindDN / groupDN (uppercase 'DN').

This means:

  • Saving fails with the 10 × "data must NOT have additional properties" errors you saw — the schema has additionalProperties: false and rejects the mismatched keys
  • Test Connection likely fails because the config was never successfully saved to the database, so there's nothing to fall back on, and the field mapping gets confused

The fix is straightforward — aligning the frontend TypeScript interfaces to use bindDN/groupDN to match the API schema. I'll get this fixed and pushed.

LDAP User Provisioning — JIT Not Bulk

To clarify the question about whether all LDAP users get created in the NPM user table:

No — this implementation uses Just-In-Time (JIT) provisioning. Users are only created in the NPM database when they actually log in. There is no bulk import or directory sync that pre-creates all LDAP users.

The syncAllUsers endpoint (POST /api/settings/ldap/sync) only re-checks users that already exist in NPM with auth_source='ldap' — it updates their group memberships and disables accounts for users removed from allowed groups. It does NOT discover or import new users from LDAP.

Your User Filter approach is exactly right:

(|
  (memberOf=CN=nginx-proxy-manager-users,OU=Groups,DC=...)
  (memberOf=CN=nginx-proxy-manager-administrators,OU=Groups,DC=...)
)

This filter is applied during the login search — only users matching the filter can authenticate. Combined with the group-to-role mapping (adminGroup / userGroup fields), this gives you fine-grained control over who can access NPM and at what permission level.

Remaining Items from Security Review

The final security audit from MrMeatikins confirmed all CRITICAL and HIGH issues are resolved. Two minor items remain:

  1. LOW — Bounded socket leak in LdapClient.create: If STARTTLS/bind fails after TCP connection is established, the raw socket isn't explicitly destroyed. This is bounded by the semaphore (max 10 connections) and will be cleaned up by the OS, but I'll add explicit cleanup as a hardening measure.

  2. INFORMATIONAL — Reaper observability: The idle connection reaper doesn't log its activity. I'll add debug-level logging so connection lifecycle is visible when troubleshooting.

Future Improvements

Both of Adam's suggestions are great candidates for follow-up work:

  • Failover URLs: Comma-separated server_url for sequential failover — very useful for HA AD deployments
  • Immediate session revocation: When an LDAP account is disabled, active NPM sessions should be invalidated. This would need deeper integration with NPM's session management

These would be separate PRs to keep scope manageable.


Next push will include the bindDN/groupDN field name fix. Once that's in, the settings form should work correctly against your AD environment.

But regardless of errors you see - you should be able to test logins in field bellow as well as successfully login as LDAP accounts into NPM.

Frontend was using bindDn/groupDn (lowercase n) but the API PUT schema
defines bindDN/groupDN (uppercase DN) with additionalProperties:false,
causing 'data must NOT have additional properties' validation errors.

Also fixes existing configs not loading into the form: GET response
returns bindDN/groupDN but form was reading bindDn/groupDn.

Files changed:
- getLdapSettings.ts: bindDn→bindDN, groupDn→groupDN in interface
- LdapSettings.tsx: all form state, setField, and value bindings updated

No backend changes needed — schema and rowToConfig are correct.
From MrMeatikins security audit (LOW severity).

If STARTTLS or bind fails after the TCP connection is established, the
raw ldapjs socket was not explicitly destroyed.  The leak is bounded by
the semaphore (max 10 connections) and the OS eventually cleans up, but
explicit cleanup is proper resource management.

Fix: wrap post-connection operations (STARTTLS, bind) in try/catch that
calls rawClient.destroy() before re-throwing on any failure.

Tests: add two new unit tests to ldap-client.test.js
- verify rawClient.destroy() is called when STARTTLS fails mid-connection
- verify rawClient.destroy() is called when bind fails after TCP connect

Full suite: 218/218 tests pass.
- Log on reaper startup: pool key, interval, and idleTimeout config
- Log when reaper fires: pool key + number of connections being checked
- Log each reaped connection: pool key, idle duration, destroyed flag,
  and remaining pool size after removal

All logs use debug level via the existing ldap logger — no noise in
normal operation. Addresses MrMeatikins security audit (INFORMATIONAL).

🧪 218/218 Jest tests passing
…, dark theme styling

- Human-readable API validation errors (replaces cryptic AJV output)
  'Unknown fields: bindDn. Expected: bindDN, serverUrl, ...' instead of
  'data must NOT have additional properties' x10

- Bypass humps camelCase/snake_case transform for LDAP API calls
  humps mangled bindDN→bind_d_n breaking all LDAP save/test operations

- Environment variable override indicators on LDAP settings form
  Lock icons, ENV badges, orange dashed borders, read-only fields
  Warning banner when env overrides are active
  Full dark theme contrast (white text, no browser dimming)

- Enabled/Disabled badge: white text on green/red, visible in all states

- 30 new validator unit tests
The formatValidationErrors() pattern case was producing 'Invalid format
for field "value"' but upstream Cypress tests (CVE-2024-46256) assert on
the original AJV format 'data/field must match pattern'.

Restored AJV-compatible output for pattern errors to avoid breaking
existing test expectations.
@jc21
Copy link
Member

jc21 commented Feb 26, 2026

Ok thanks for explaining.

Something else to consider here, after your manual testing is working, we need to consider automated integration testing.

In v3 (which is now abandoned) I implemented LDAP, albeit in a much simpler form. As a nice result, I've backported the Cypress test suite for it in v2. Take a look at test/cypress/e2e/api/Ldap.cy.js It's all commented out but this is a good guide.

This is a test suite that only runs on the Postgres stack because it also spins up Authentik, preconfigured with LDAP AD.

@nginxproxymanagerci
Copy link

Docker Image for build 18 is available on DockerHub:

nginxproxymanager/nginx-proxy-manager-dev:pr-5345

Note

Ensure you backup your NPM instance before testing this image! Especially if there are database changes.
This is a different docker image namespace than the official image.

Warning

Changes and additions to DNS Providers require verification by at least 2 members of the community!

@noname1001
Copy link

I really appreciate the effort!

It seems to work somehow or i made a mistake with my configuration.

2026-02-27_00-27

When i enter wrong username or password i get the error Invalid credentials = works.

One question tho, when i am at the login screen, shouldn there be an option to choose if i want to login via ldap or local user (just in case my ldap server(s) are down)?

Also i cannot login via LDAP to npm even though this user is an admin user.

Bildschirmfoto vom 2026-02-27 00-29-48

@Wadera
Copy link
Author

Wadera commented Feb 26, 2026

Thanks for testing! Make sure you specify Admin Group DN and User Group DN

image

One question tho, when i am at the login screen, shouldn there be an option to choose if i want to login via ldap or local user (just in case my ldap server(s) are down)?

How It Works

User submits login →
  1. Try local NPM password →  if OK, grant access
  2. Look up LDAP config →     if LDAP disabled, reject
  3. Search LDAP for user →    if not found, reject
  4. Bind (authenticate) →     if wrong password, reject
  5. Fetch group memberships →
     - Not in user_group? → reject
     - In admin_group? → admin access
     - Otherwise → regular access
  6. JIT provision / update NPM account →  grant access

Check https://github.com/Wadera/nginx-proxy-manager/blob/feature/ldap-auth/docs/ldap-authentication.md for more details

@noname1001
Copy link

@Wadera thanks for the hint.

The users synchronisation works but the groups are wrong because of my false Admin Group DN.

I will have to fiddle with this tomorrow.

I set the ldap user chris to admin manually and tested sign in as chris -> looks promising.

image image image image
local admin ldap admin
2026-02-27_00-51 image

In case anyone is wondering, i use synologys ldap server.

@noname1001
Copy link

noname1001 commented Feb 27, 2026

Also i cannot login via LDAP to npm even though this user is an admin user.

This was my mistake while testing late at night because this e-mail was already in use for another user.

HowTo for my setup

  1. I had to create a ldap-group "npm-admin"
  2. add the user chris to the group
  3. Adapt my docker setup

docker-compose.yml

services:
  db:
    image: 'mariadb:10.11'
    container_name: npm-backend
    env_file:
      - ./db.env
    volumes:
      - ./data/mysql:/var/lib/mysql
    restart: always

  app:
    image: nginxproxymanager/nginx-proxy-manager-dev:pr-5345
    container_name: npm-frontend
    ports:
      - '80:80'
      - '82:81'
      - '443:443'
    env_file:
      - ./app.env
    dns:
      - 192.168.0.90  # Pi-hole
      - 1.1.1.1       # Cloudflare
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    restart: always

app.env

DB_MYSQL_HOST=db
DB_MYSQL_PORT=3306
DB_MYSQL_USER=supersecret
DB_MYSQL_PASSWORD=supersecret
DB_MYSQL_NAME=supersecret

LDAP_ENABLED=true
LDAP_SERVER_URL=ldap://ldap.corp.com
LDAP_BIND_DN=uid=root,cn=users,dc=corp,dc=com
LDAP_BIND_PASSWORD=supersecret2
LDAP_SEARCH_BASE=dc=corp,dc=com
LDAP_GROUP_DN=cn=groups,dc=corp,dc=com
LDAP_USER_ATTR=uid
LDAP_ADMIN_GROUP=npm-admins
LDAP_TLS_VERIFY=false
LDAP_STARTTLS=true

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.

6 participants