Skip to content

ldap2: SNI-aware connections and a shared JNDI socket-factory classloader - #6396

Open
beanuwave wants to merge 3 commits into
opensearch-project:mainfrom
sternadsoftware:fips-split/3-ldap-sni
Open

beanuwave wants to merge 3 commits into
opensearch-project:mainfrom
sternadsoftware:fips-split/3-ldap-sni

Conversation

@beanuwave

@beanuwave beanuwave commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

Category: Bug fix, Enhancement

Two LDAPS defects plus the TLS plumbing they need. Independent of FIPS, though FIPS is what surfaced them.

Key changes

  • SNI for LDAPS. Two sequential (not duplicate) concerns: SNISettingTLSSocketFactory sets the ClientHello SNI before the handshake so a multi-cert server serves the right certificate; HostnameVerifyingTrustManager checks the returned certificate after. For IP targets the SNI factory early-returns (no SNI) and the trust manager is the only hostname check; for DNS it sets SNI plus endpoint identification, making the trust-manager check redundant. HostnameAwareConnectionFactory threads the target hostname through so SNI works on both the pooled and unpooled paths. This works around the JNDI LDAP provider resolving hostnames to IPs before socket creation (bcgit/bc-java#460).
  • ldap2 LDAPS reconnect ClassNotFoundException (bug fix). The Java9CL classloader that lets the JNDI provider resolve ldaptive's socket factory was private to LDAPAuthorizationBackend, so a reconnect from the ldap2 backend failed. Extracted as SocketFactoryClassLoader and set on ldap2's JNDI provider config, so PrivilegedProvider's thread-context swap resolves the factory.
  • Bypassing ldaptive's internal PKCS#12 key copy (both authentication and authorization paths). ldaptive's create*CredentialConfig with key aliases routes through KeyStoreSSLContextInitializer.getKeyManagers(), which copies the private key into a fresh in-memory PKCS#12 store protected by SunJCE's PBEWithHmacSHA256AndAES_256 — unavailable in FIPS. Fix: build keystores from PEM via PemKeyReader.toTruststore/toKeystore and pass null key aliases, so kmf.init(keystore, password) is called directly and the PKCS#12 branch is bypassed. (LDAPAuthorizationBackend previously used createX509CredentialConfig, which hit that path unconditionally.)
  • Both LDAP backends now reuse SSLConfigConstants.ALLOWED_SSL_PROTOCOLS for their default enabled_ssl_protocols.

Reviewer call-outs

  1. Dropped the automatic System.setProperty(disableEndpointIdentification, true) from the default ldap backend — now warn-only. Two reasons: (a) it aligns both backends on the same logic (ldap2 never set it); (b) mutating a global JVM property from application code is process-wide and load-order-dependent, so one auth domain's verify_hostnames: false silently reconfigured hostname checking for the entire JVM. Operators who need it must now set the -D deliberately. This is a behaviour change for existing configs.
  2. Why are verify_hostnames and trust_all coupled to the same verifier? verifyHostnames = !trustAll && <setting>, so trust_all: true forces AllowAnyHostnameVerifier on top of AllowAnyTrustManager. Chain validation and hostname matching are orthogonal; collapsing them means you cannot relax one without the other, and it hides which layer a config change actually touches. Worth untangling (not done here).
  3. ldaptive 2.x's native (Netty) transport opens sockets with the real hostname, which would let this entire SNI stack be deleted. Out of scope here.

Testing

./gradlew test integrationTest
Manual test: LDAP authentication over LDAPS (SNI, hostname verification, mTLS), non-FIPS

Self-contained manual tests for the LDAP TLS changes: SNI / hostname verification and mutual TLS. Authentication only — these changes don't touch authz code, so role resolution is out of scope (verify that against a real directory).

Run them against any LDAPS directory that supports mTLS (a client cert is required). The walkthrough uses a local UnboundID in-memory stand-in only because it's repeatable and trivial to set up — a convenience, not a requirement; substitute your own server anywhere it appears. Its setup lives in LDAP_UNBOUNDID_STANDIN_GUIDE.md.

TLS material is the OpenSearch install's own demo certs in $OPENSEARCH_HOME/config/esnode (server, SAN includes localhost), root-ca.pem (trust anchor), kirk (client). The node runs as a plain non-FIPS node (no OPENSEARCH_FIPS_MODE), so TLS runs on SunJSSE and the default protocol list is [TLSv1.3, TLSv1.2, TLSv1.1].

Matrix. Run every scenario once per backend — flip the type: line.

DEFAULT ldap LDAP2 (...ldap2.LDAPAuthenticationBackend2)
yes yes
cd $OPENSEARCH_HOME
export LDAP_USER="testuser"; export LDAP_PASS="testpassword"   # cn=Test User,ou=people,o=TEST

# Optional trace logging for the "what to look for" lines (config/log4j2.properties):
#   logger.ldap.name=org.opensearch.security.auth.ldap
#   logger.ldap.level=trace
#   logger.ldap2.name=org.opensearch.security.auth.ldap2
#   logger.ldap2.level=trace
#   logger.ldaptive.name=org.ldaptive
#   logger.ldaptive.level=debug

# === Baseline config (authc.ldap.authentication_backend.config; o=TEST, no authz block) ======
# Edit this first, then apply below. Bare *_filepath resolve against config/; kirk + root-ca are the
# shipped demo certs, so as shipped this IS scenario 1a / 2a -> 200.
#   type: ldap                  # DEFAULT | LDAP2: org.opensearch.security.auth.ldap2.LDAPAuthenticationBackend2
#   config:
#     enable_ssl: true
#     enable_ssl_client_auth: true          # mTLS - client cert required
#     verify_hostnames: true
#     hosts: [localhost:8636]
#     pemtrustedcas_filepath: root-ca.pem
#     pemcert_filepath: kirk.pem
#     pemkey_filepath: kirk-key.pem
#     bind_dn: "cn=opensearch-bind,ou=people,o=TEST"
#     password: "bindpassword"
#     userbase: "ou=people,o=TEST"
#     usersearch: '(uid={0})'
#     username_attribute: uid

# === Apply / authenticate (verify loop - re-run after each config.yml edit) ==
# apply: push the authc.ldap block (-t config, live; TLS-material/host edits need a node restart).
sh ./plugins/opensearch-security/tools/securityadmin.sh \
  -f ./config/opensearch-security/config.yml \
  -t config \
  -icl \
  -nhnv \
  -cacert config/root-ca.pem \
  -cert config/kirk.pem \
  -key config/kirk-key.pem \
  -h localhost \
  -p 9200

# authenticate: 200 + user_name=testuser (backend_roles empty - no authz) = LDAPS + mTLS bind OK.
curl -sk -u "$LDAP_USER:$LDAP_PASS" https://localhost:9200/_plugins/_security/authinfo?pretty

# What to look for (baseline 200):
#   Configuring SNI for hostname: localhost ...            # SNI server_name set (the fix)
#   checkServerTrusted ... succeeded                       # esnode chains to root-ca
#   verifyDNS found hostname match: localhost              # hostname layer 1 pass
#   Opened a connection, total count is now 1              # DEFAULT | LDAP2: Authenticated username testuser

Scenario 1 — hostname verification. Same trusted esnode cert throughout; 1b–1d dial a name not in its SAN (echo "127.0.0.1 ldap-wrong.example.com" | sudo tee -a /etc/hosts, then set hosts: [ldap-wrong.example.com:8636]), so the only thing that can object is one of the two hostname guards: (1) ldaptive's verifier (verify_hostnames), (2) JNDI endpoint-id (-Dcom.sun.jndi.ldap.object.disableEndpointIdentification=true in config/jvm.options; the plugin no longer sets it, only warns — see call-out 1). Chain trust is valid throughout, so this isolates hostname checking — the untrusted-cert case is 2c. Apply + restart per row.

verify_hostnames JNDI endpoint-id Result / what it proves
1a true on correct name → 200 (baseline; SNI fix works)
1b true on rejected, layer 1 — ldaptive DefaultHostnameVerifier
1c false on rejected, layer 2 — JNDI/JSSE endpoint-id (verify_hostnames: false alone isn't enough; before this PR the default backend silently disabled layer 2 JVM-wide)
1d false off accepted (200) — hostname unenforced only when both guards are off
1b  HostnameVerifyingTrustManager ... hostnames=[ldap-wrong.example.com] failed         # layer 1 (ldaptive)
    CertificateException: Hostname '[ldap-wrong.example.com]' does not match 'CN=node-0.example.com...'
    (for ldap2 the reject fires inside SniAwareConnection.open() = no fail-open)
1c  AllowAnyHostnameVerifier ... succeeded                                              # layer 1 off
    CertificateException: No subject alternative DNS name matching ldap-wrong.example.com found.  # layer 2 (SunJSSE)
1d  (no hostname reject) -> Opened a connection / Authenticated username testuser

Cleanup: remove the /etc/hosts line + the jvm.options flag, restore hosts: + verify_hostnames: true. Never set disableEndpointIdentification=true in production (it's process-wide).

Scenario 2 — mTLS client authentication. Vary only the client cert / trust anchor; apply + restart per row, and restore pemtrustedcas_filepath: root-ca.pem after 2c. First generate the two "bad" credentials once — both self-signed, so neither chains to the demo root-ca — into config/:

cd $OPENSEARCH_HOME/config
# 2b: untrusted client cert + key (self-signed; does NOT chain to root-ca)
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=untrusted-client" \
  -keyout untrusted-client.key -out untrusted-client.pem
# 2c: untrusted CA - a trust anchor that did NOT sign esnode
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=untrusted-ca" \
  -keyout untrusted-ca.key -out untrusted-ca.pem
change (in config.yml) Result / what it proves
2a pemcert/pemkey_filepath: kirk.* (baseline) accepted — PEM client key loads via the keystore path (no ldaptive PKCS#12 copy) and signs the handshake
2b pemcert/pemkey_filepath: untrusted-client.* rejected — client withholds the cert → server aborts mandatory mTLS
2c pemtrustedcas_filepath: untrusted-ca.pem fails — server cert no longer chains to the trust anchor
2b  checkServerTrusted ... succeeded                        # server side fine
    SSLHandshakeException: Received fatal alert: certificate_required   # client cert withheld
    must NOT log: ClassNotFoundException ...SNISettingTLSSocketFactory  (ldap2 reconnect bug, fixed here)
2c  checkServerTrusted ... failed
    ValidatorException: PKIX path building failed ... unable to find valid certification path to requested target
    ->  Authentication finally failed

Check List

  • New functionality includes testing
  • New functionality has been documented
  • New Roles/Permissions have a corresponding security dashboards plugin PR
  • API changes companion pull request created
  • Commits are signed per the DCO using --signoff

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit 16faadb.

Hard block: Issues at High severity or above will block this PR from merging.

PathLineSeverityDescription
build.gradle606highNew dependency org.bouncycastle:bctls-fips added in three places (integrationTestImplementation, compileOnly, testImplementation). Mandatory supply-chain flagging rule applies — cannot verify artifact authenticity regardless of how legitimate the name appears.
src/test/resources/fips-jvm-truststore.bcfks1mediumBinary BCFKS truststore file added to test resources. Binary content cannot be reviewed in a diff; an attacker could embed unexpected trust anchors or other material. Maintainers should independently verify the file's origin and content (e.g., keytool -list).
src/main/java/org/opensearch/security/support/FipsMode.java18mediumFipsMode.envSupplier is a non-private static field intentionally left mutable for tests, but it is package-accessible in production code. Any class in the same package can replace the supplier and silently disable FIPS mode detection at runtime, effectively bypassing the FIPS enforcement path.
gradle/fips.gradle80lowJVM argument -Djavax.net.ssl.trustStorePassword=changeit is passed to test workers as a command-line flag, making the truststore password visible in process listings and CI logs. Low impact given this is a test-only truststore with a well-known default password, but the pattern is worth noting.

The table above displays the top 10 most important findings.

Total: 4 | Critical: 0 | High: 1 | Medium: 2 | Low: 1


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

iigonin and others added 3 commits September 16, 2026 18:25
Introduces gradle/fips.gradle as the single place where FIPS mode is decided
and applied to the build's test surface: which test classes exist in each mode
and the JVM arguments test workers need to actually be in FIPS mode. Mode is
driven by the OPENSEARCH_FIPS_MODE environment variable, surfaced to production
code through the new FipsMode enum.

BC FIPS artifacts move to compileOnly in both modes (they are provided by
OpenSearch core), bctls-fips is added, and the securityadmin standalone bundles
now ship the BC FIPS jars in deps/.

Because java_test.security registers BouncyCastleFipsProvider in every test JVM
-- including non-FIPS runs -- any suite that touches JCA now leaves a
"BC FIPS Entropy Daemon" thread behind, which RandomizedRunner reports as a
leak. BCFipsEntropyDaemonFilter covers it; the framework's BouncyCastleThreadFilter
does not. It is applied to the suites that trip over it here, and reused by
later FIPS work.

No FIPS variant test classes exist yet, so this otherwise lands inert: the
default build is unchanged and fips.gradle currently selects nothing.

Signed-off-by: Iwan Igonin <iigonin@sternad.de>
Co-authored-by: Benny Goerzig <benny.goerzig@sap.com>
Co-authored-by: Karsten Schnitter <k.schnitter@sap.com>
Co-authored-by: Kai Sternad <k.sternad@sternad.de>
Replaces the isPkcs11()-style branching in the SSL configuration layer with
sealed pem/jdk/pkcs11 records for both key stores and trust stores, and moves
PKCS#11 dispatch into those records. Store passwords are wrapped in a
StorePassword type so they are redacted in toString() rather than leaking into
logs.

A PKCS#11 store lives on the token rather than on disk, so the path becomes
optional throughout: KeyStoreUtils loads such stores with a null stream, and
error messages name the token instead of a file. PemKeyReader learns the
PKCS11 store type and validates that a PKCS#11 provider is actually registered.
Trust store settings that a PKCS#11 configuration ignores now produce a warning
instead of being silently dropped.

Signed-off-by: Iwan Igonin <iigonin@sternad.de>
Co-authored-by: Benny Goerzig <benny.goerzig@sap.com>
Co-authored-by: Karsten Schnitter <k.schnitter@sap.com>
Co-authored-by: Kai Sternad <k.sternad@sternad.de>
…ader

JNDI's LDAP provider never passes the target hostname to the SSLSocketFactory
it instantiates (bcgit/bc-java#460), so an ldaps connection could not present
an SNI extension and servers doing name-based virtual hosting returned the
wrong certificate. SNISettingTLSSocketFactory carries the hostname through a
ThreadLocal for the duration of the connect and sets it on the socket's SSL
parameters; SniAwareConnection and HostnameAwareConnectionFactory drive it for
the pooled and unpooled paths.

The Java9CL classloader that worked around the provider's inability to see
ldaptive's socket factory was private to LDAPAuthorizationBackend, so a
reconnect from the ldap2 backend raised ClassNotFoundException. It is extracted
as SocketFactoryClassLoader and shared by both backends.

LDAPAuthorizationBackend also builds its PEM credentials through a keystore
rather than createX509CredentialConfig, and stops setting the global
com.sun.jndi.ldap.object.disableEndpointIdentification system property, which
disabled hostname verification process-wide as a side effect of one connection.

Signed-off-by: Iwan Igonin <iigonin@sternad.de>
Co-authored-by: Benny Goerzig <benny.goerzig@sap.com>
Co-authored-by: Karsten Schnitter <k.schnitter@sap.com>
Co-authored-by: Kai Sternad <k.sternad@sternad.de>
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.77994% with 69 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.04%. Comparing base (9d74eb9) to head (16faadb).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
.../opensearch/security/ssl/config/KeyStoreUtils.java 55.17% 15 Missing and 11 partials ⚠️
.../org/opensearch/security/support/PemKeyReader.java 26.66% 7 Missing and 4 partials ⚠️
...rg/opensearch/security/ssl/SslSettingsManager.java 30.76% 1 Missing and 8 partials ⚠️
...rch/security/ssl/config/KeyStoreConfiguration.java 69.56% 7 Missing ⚠️
...ty/auth/ldap/backend/LDAPAuthorizationBackend.java 83.78% 4 Missing and 2 partials ⚠️
...urity/auth/ldap2/LDAPConnectionFactoryFactory.java 84.61% 3 Missing and 1 partial ⚠️
...h/security/ssl/config/TrustStoreConfiguration.java 84.00% 4 Missing ⚠️
.../opensearch/security/ssl/config/StorePassword.java 89.47% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #6396      +/-   ##
==========================================
+ Coverage   75.90%   76.04%   +0.14%     
==========================================
  Files         461      467       +6     
  Lines       30942    31107     +165     
  Branches     4668     4674       +6     
==========================================
+ Hits        23485    23654     +169     
+ Misses       5295     5284      -11     
- Partials     2162     2169       +7     
Files with missing lines Coverage Δ
...y/auth/ldap/backend/LDAPAuthenticationBackend.java 87.20% <100.00%> (ø)
...ity/auth/ldap2/HostnameAwareConnectionFactory.java 100.00% <100.00%> (ø)
...ecurity/auth/ldap2/SNISettingTLSSocketFactory.java 100.00% <100.00%> (ø)
...search/security/auth/ldap2/SniAwareConnection.java 100.00% <100.00%> (ø)
.../security/auth/ldap2/SocketFactoryClassLoader.java 100.00% <100.00%> (ø)
.../org/opensearch/security/ssl/SslConfiguration.java 83.05% <100.00%> (+0.29%) ⬆️
...rch/security/ssl/config/SslCertificatesLoader.java 94.28% <100.00%> (+6.78%) ⬆️
...java/org/opensearch/security/support/FipsMode.java 100.00% <100.00%> (ø)
.../opensearch/security/ssl/config/StorePassword.java 89.47% <89.47%> (ø)
...urity/auth/ldap2/LDAPConnectionFactoryFactory.java 62.25% <84.61%> (+2.10%) ⬆️
... and 6 more

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

2 participants