Moderate severityOSV Advisory· Published Dec 15, 2025· Updated Feb 26, 2026
Elasticsearch Improper Authentication
CVE-2025-37731
Description
Improper Authentication in Elasticsearch PKI realm can lead to user impersonation via specially crafted client certificates. A malicious actor would need to have such a crafted client certificate signed by a legitimate, trusted Certificate Authority.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.elasticsearch:elasticsearchMaven | >= 7.0.0-alpha1, < 8.19.8 | 8.19.8 |
org.elasticsearch:elasticsearchMaven | >= 9.0.0-beta1, < 9.1.8 | 9.1.8 |
org.elasticsearch:elasticsearchMaven | >= 9.2.0, < 9.2.2 | 9.2.2 |
Affected products
1- Range: v9.2.0, v9.2.1
Patches
3e519fe4c51a3Extract principal from certificate RDN (#137230) (#138385)
8 files changed · +352 −18
docs/changelog/137230.yaml+5 −0 added@@ -0,0 +1,5 @@ +pr: 137230 +summary: Principal Extraction from Certificate RDN Attribute Value in PKI Realm +area: Security +type: bug +issues: []
docs/reference/elasticsearch/configuration-reference/security-settings.md+12 −0 modified@@ -769,6 +769,18 @@ In addition to the [settings that are valid for all realms](#ref-realm-settings) `username_pattern` : ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The regular expression pattern used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The first match group is the used as the username. Defaults to `CN=(.*?)(?:,|$)`. + This setting is ignored if either `username_rdn_oid` or `username_rdn_name` is set. + +`username_rdn_oid` +: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The relative distinguished name (RDN) attribute OID used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The value of the most specific RDN matching this attribute OID is used as the username. + + This setting takes precedent over `username_pattern`. You cannot use this setting and `username_rdn_name` at the same time. + +`username_rdn_name` +: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The relative distinguished name (RDN) attribute name used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The value of the most specific RDN matching this attribute name is used as the username. + + This setting takes precedent over `username_pattern`. You cannot use this setting and `username_rdn_oid` at the same time. + `certificate_authorities` : ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) List of paths to the PEM certificate files that should be used to authenticate a user’s certificate as trusted. Defaults to the trusted certificates configured for SSL. This setting cannot be used with `truststore.path`.
libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/DerParser.java+16 −15 modified@@ -36,21 +36,22 @@ public final class DerParser { private static final int CONSTRUCTED = 0x20; // Tag and data types - static final class Type { - static final int INTEGER = 0x02; - static final int OCTET_STRING = 0x04; - static final int OBJECT_OID = 0x06; - static final int SEQUENCE = 0x10; - static final int NUMERIC_STRING = 0x12; - static final int PRINTABLE_STRING = 0x13; - static final int VIDEOTEX_STRING = 0x15; - static final int IA5_STRING = 0x16; - static final int GRAPHIC_STRING = 0x19; - static final int ISO646_STRING = 0x1A; - static final int GENERAL_STRING = 0x1B; - static final int UTF8_STRING = 0x0C; - static final int UNIVERSAL_STRING = 0x1C; - static final int BMP_STRING = 0x1E; + public static final class Type { + public static final int INTEGER = 0x02; + public static final int OCTET_STRING = 0x04; + public static final int OBJECT_OID = 0x06; + public static final int SEQUENCE = 0x10; + public static final int SET = 0x11; + public static final int NUMERIC_STRING = 0x12; + public static final int PRINTABLE_STRING = 0x13; + public static final int VIDEOTEX_STRING = 0x15; + public static final int IA5_STRING = 0x16; + public static final int GRAPHIC_STRING = 0x19; + public static final int ISO646_STRING = 0x1A; + public static final int GENERAL_STRING = 0x1B; + public static final int UTF8_STRING = 0x0C; + public static final int UNIVERSAL_STRING = 0x1C; + public static final int BMP_STRING = 0x1E; } private InputStream derInputStream;
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java+33 −0 modified@@ -6,6 +6,10 @@ */ package org.elasticsearch.xpack.core.security.authc.pki; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.schema.AttributeTypeDefinition; +import com.unboundid.ldap.sdk.schema.Schema; + import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.core.TimeValue; @@ -29,6 +33,33 @@ public final class PkiRealmSettings { key -> new Setting<>(key, DEFAULT_USERNAME_PATTERN, s -> Pattern.compile(s, Pattern.CASE_INSENSITIVE), Setting.Property.NodeScope) ); + public static final Setting.AffixSetting<String> USERNAME_RDN_OID_SETTING = Setting.affixKeySetting( + RealmSettings.realmSettingPrefix(TYPE), + "username_rdn_oid", + key -> Setting.simpleString(key, Setting.Property.NodeScope) + ); + + public static final Setting.AffixSetting<String> USERNAME_RDN_NAME_SETTING = Setting.affixKeySetting( + RealmSettings.realmSettingPrefix(TYPE), + "username_rdn_name", + key -> new Setting<>(key, (String) null, s -> { + if (s == null) { + return ""; + } + Schema schema; + try { + schema = Schema.getDefaultStandardSchema(); + } catch (LDAPException e) { + throw new IllegalStateException("Unexpected error occurred obtaining default LDAP schema", e); + } + AttributeTypeDefinition atd = schema.getAttributeType(s); + if (atd == null) { + throw new IllegalArgumentException("Unknown RDN name [" + s + "] for setting [" + key + "]"); + } + return atd.getOID(); + }, Setting.Property.NodeScope) + ); + private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20); public static final Setting.AffixSetting<TimeValue> CACHE_TTL_SETTING = Setting.affixKeySetting( RealmSettings.realmSettingPrefix(TYPE), @@ -75,6 +106,8 @@ private PkiRealmSettings() {} public static Set<Setting.AffixSetting<?>> getSettings() { Set<Setting.AffixSetting<?>> settings = new HashSet<>(); settings.add(USERNAME_PATTERN_SETTING); + settings.add(USERNAME_RDN_OID_SETTING); + settings.add(USERNAME_RDN_NAME_SETTING); settings.add(CACHE_TTL_SETTING); settings.add(CACHE_MAX_USERS_SETTING); settings.add(DELEGATION_ENABLED_SETTING);
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java+35 −2 modified@@ -12,6 +12,7 @@ import org.elasticsearch.common.cache.Cache; import org.elasticsearch.common.cache.CacheBuilder; import org.elasticsearch.common.hash.MessageDigests; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslConfiguration; import org.elasticsearch.common.ssl.SslTrustConfig; import org.elasticsearch.common.util.concurrent.ReleasableLock; @@ -51,6 +52,7 @@ import javax.net.ssl.X509ExtendedTrustManager; import javax.net.ssl.X509TrustManager; +import javax.security.auth.x500.X500Principal; import static org.elasticsearch.core.Strings.format; @@ -76,6 +78,7 @@ public class PkiRealm extends Realm implements CachingRealm { private final X509TrustManager trustManager; private final Pattern principalPattern; + private final String principalRdnOid; private final UserRoleMapper roleMapper; private final Cache<BytesKey, User> cache; private DelegatedAuthorizationSupport delegatedRealms; @@ -91,6 +94,18 @@ public PkiRealm(RealmConfig config, ResourceWatcherService watcherService, UserR this.delegationEnabled = config.getSetting(PkiRealmSettings.DELEGATION_ENABLED_SETTING); this.trustManager = trustManagers(config); this.principalPattern = config.getSetting(PkiRealmSettings.USERNAME_PATTERN_SETTING); + String rdnOid = config.getSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING); + String rdnOidFromName = config.getSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING); + if (false == rdnOid.isEmpty() && false == rdnOidFromName.isEmpty()) { + throw new SettingsException( + "Both [" + + config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING).getKey() + + "] and [" + + config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING).getKey() + + "] are set. Only one of these settings can be configured." + ); + } + this.principalRdnOid = false == rdnOid.isEmpty() ? rdnOid : (false == rdnOidFromName.isEmpty() ? rdnOidFromName : null); this.roleMapper = roleMapper; this.roleMapper.clearRealmCacheOnChange(this); this.cache = CacheBuilder.<BytesKey, User>builder() @@ -133,7 +148,7 @@ public X509AuthenticationToken token(ThreadContext context) { // validation). In this case the principal should be set by the realm that completes the authentication. But in the common case, // where a single PKI realm is configured, there is no risk of eagerly parsing the principal before authentication and it also // maintains BWC. - String parsedPrincipal = getPrincipalFromSubjectDN(principalPattern, token, logger); + String parsedPrincipal = getPrincipalFromToken(token); if (parsedPrincipal == null) { return null; } @@ -164,7 +179,7 @@ public void authenticate(AuthenticationToken authToken, ActionListener<Authentic // parse the principal again after validating the cert chain, and do not rely on the token.principal one, because that could // be set by a different realm that failed trusted chain validation. We SHOULD NOT parse the principal BEFORE this step, but // we do it for BWC purposes. Changing this is a breaking change. - final String principal = getPrincipalFromSubjectDN(principalPattern, token, logger); + final String principal = getPrincipalFromToken(token); if (principal == null) { logger.debug( () -> format( @@ -231,6 +246,24 @@ public void lookupUser(String username, ActionListener<User> listener) { listener.onResponse(null); } + String getPrincipalFromToken(X509AuthenticationToken token) { + return principalRdnOid != null + ? getPrincipalFromRdnAttribute(principalRdnOid, token, logger) + : getPrincipalFromSubjectDN(principalPattern, token, logger); + } + + static String getPrincipalFromRdnAttribute(String principalRdnOid, X509AuthenticationToken token, Logger logger) { + X500Principal certPrincipal = token.credentials()[0].getSubjectX500Principal(); + String principal = RdnFieldExtractor.extract(certPrincipal.getEncoded(), principalRdnOid); + if (principal == null) { + logger.debug( + () -> format("the extracted principal from DN [%s] using RDN OID [%s] is empty", certPrincipal.toString(), principalRdnOid) + ); + return null; + } + return principal; + } + static String getPrincipalFromSubjectDN(Pattern principalPattern, X509AuthenticationToken token, Logger logger) { String dn = token.credentials()[0].getSubjectX500Principal().toString(); Matcher matcher = principalPattern.matcher(dn);
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractor.java+62 −0 added@@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.pki; + +import org.elasticsearch.common.ssl.DerParser; + +import java.io.IOException; + +/** + * Utility class to extract RDN field values from X500 principal DER encoding. + */ +public class RdnFieldExtractor { + + public static String extract(byte[] encoded, String oid) { + try { + return doExtract(encoded, oid); + } catch (IOException | IllegalStateException e) { + return null; // invalid encoding + } + } + + private static String doExtract(byte[] encoded, String oid) throws IOException { + DerParser parser = new DerParser(encoded); + + DerParser.Asn1Object dnSequence = parser.readAsn1Object(DerParser.Type.SEQUENCE); + DerParser sequenceParser = dnSequence.getParser(); + + String value = null; + + while (true) { + try { + DerParser.Asn1Object rdnSet = sequenceParser.readAsn1Object(DerParser.Type.SET); // throws IOException on EOF + DerParser setParser = rdnSet.getParser(); + + while (true) { + try { + DerParser.Asn1Object attrSeq = setParser.readAsn1Object(DerParser.Type.SEQUENCE); // throws IOException on EOF + DerParser attrParser = attrSeq.getParser(); + + String attrOid = attrParser.readAsn1Object().getOid(); + DerParser.Asn1Object attrValue = attrParser.readAsn1Object(); + if (oid.equals(attrOid)) { + value = attrValue.getString(); // retain last (most-significant) occurrence + } + } catch (IOException e) { + break; // RDN SET EOF + } + } + } catch (IOException e) { + break; // DN SEQUENCE EOF + } + } + + return value; + } + +}
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/PkiRealmTests.java+70 −1 modified@@ -13,6 +13,7 @@ import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslConfigException; import org.elasticsearch.common.util.CollectionUtils; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -230,7 +231,7 @@ private AuthenticationResult<User> authenticate(X509AuthenticationToken token, P } public void testCustomUsernamePatternMatches() throws Exception { - final Settings settings = Settings.builder() + Settings settings = Settings.builder() .put(globalSettings) .put("xpack.security.authc.realms.pki.my_pki.username_pattern", "OU=(.*?),") .build(); @@ -249,6 +250,74 @@ public void testCustomUsernamePatternMatches() throws Exception { assertThat(user.roles().length, is(0)); } + public void testRdnOidMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.11") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + User user = authenticate(token, realm).getValue(); + assertThat(user, is(notNullValue())); + assertThat(user.principal(), is("elasticsearch")); + } + + public void testRdnOidNameMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "OU") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + User user = authenticate(token, realm).getValue(); + assertThat(user, is(notNullValue())); + assertThat(user.principal(), is("elasticsearch")); + } + + public void testRdnOidNameNotMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + assertThat(token, is(nullValue())); + } + + public void testRdnOidNameUnknown() { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UNKNOWN_OID_NAME") + .build(); + UserRoleMapper roleMapper = buildRoleMapper(); + assertThrows(IllegalArgumentException.class, () -> buildRealm(roleMapper, settings)); + } + + public void testRedundantRdnOidSettings() { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.3") + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID") + .build(); + UserRoleMapper roleMapper = buildRoleMapper(); + assertThrows(SettingsException.class, () -> buildRealm(roleMapper, settings)); + } + public void testCustomUsernamePatternMismatchesAndNullToken() throws Exception { final Settings settings = Settings.builder() .put(globalSettings)
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractorTests.java+119 −0 added@@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.pki; + +import org.elasticsearch.test.ESTestCase; + +import java.util.List; +import java.util.Map; + +import javax.security.auth.x500.X500Principal; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +public class RdnFieldExtractorTests extends ESTestCase { + + private static final String OID_CN = "2.5.4.3"; // Common Name + private static final String OID_OU = "2.5.4.11"; // Organizational Unit + private static final String OID_O = "2.5.4.10"; // Organization + private static final String OID_C = "2.5.4.6"; // Country + private static final String OID_ST = "2.5.4.8"; // State or Province + private static final String OID_L = "2.5.4.7"; // Locality + private static final String OID_EMAIL = "1.2.840.113549.1.9.1"; // Email Address + + // Custom/domain-specific OID (fictional private enterprise OID) + // Format: 1.3.6.1.4.1.<enterprise-number>.<custom-attributes> + private static final String OID_EMPLOYEE_ID = "1.3.6.1.4.1.50000.1.1"; // Fictional: Employee ID + + private record ExtractionTestCase(String dn, String oid, String expectedValue) {} + + private static String extractFromDN(String dn, String oid) { + X500Principal principal = new X500Principal(dn); + return RdnFieldExtractor.extract(principal.getEncoded(), oid); + } + + private void assertExtractions(String dn, Map<String, String> expectedExtractions) { + byte[] encoded = new X500Principal(dn).getEncoded(); + for (Map.Entry<String, String> entry : expectedExtractions.entrySet()) { + String oid = entry.getKey(); + String expected = entry.getValue(); + String actual = RdnFieldExtractor.extract(encoded, oid); + assertThat("OID " + oid + " extraction failed for DN: " + dn, actual, is(equalTo(expected))); + } + } + + public void testExtractBasicAttributes() { + List<ExtractionTestCase> testCases = List.of( + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_CN, "John Doe"), + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_OU, "Engineering"), + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_O, "Elastic"), + new ExtractionTestCase("CN=John Doe, C=US", OID_C, "US"), + new ExtractionTestCase("CN=John Doe, ST=California, C=US", OID_ST, "California"), + new ExtractionTestCase("CN=John Doe, L=Mountain View, ST=California, C=US", OID_L, "Mountain View"), + new ExtractionTestCase("EMAILADDRESS=john@elastic.co, CN=John Doe", OID_EMAIL, "john@elastic.co") + ); + + for (ExtractionTestCase testCase : testCases) { + String result = extractFromDN(testCase.dn, testCase.oid); + assertThat("Failed to extract from DN: " + testCase.dn, result, is(equalTo(testCase.expectedValue))); + } + } + + public void testExtractFirstOUWhenMultipleExist() { + // When multiple RDNs with the same OID exist, should return the last one encountered in DER encoding + // Note: X.500 encoding reverses the order - the last attribute in the DN string is first in DER encoding + String ou = extractFromDN("CN=John Doe, OU=Security Team, OU=Engineering, O=Elastic", OID_OU); + assertThat(ou, is(equalTo("Security Team"))); + } + + public void testExtractOidNotFound() { + assertThat(extractFromDN("CN=John Doe, OU=Engineering", OID_C), is(nullValue())); + } + + public void testExtractWithEmptyEncoding() { + assertThat(RdnFieldExtractor.extract(new byte[0], OID_CN), is(nullValue())); + } + + public void testExtractWithMalformedDerData() { + byte[] malformedBytes = randomByteArrayOfLength(50); + + String result = RdnFieldExtractor.extract(malformedBytes, OID_CN); + assertThat(result, is(nullValue())); + } + + public void testExtractWithSpecialCharacters() { + assertExtractions("CN=Test\\, User, OU=R\\+D, O=Elastic\\\\Co", Map.of(OID_CN, "Test, User", OID_OU, "R+D", OID_O, "Elastic\\Co")); + } + + public void testExtractWithUtf8Characters() { + assertExtractions( + "CN=José García, OU=Ingeniería, O=Elástico", + Map.of(OID_CN, "José García", OID_OU, "Ingeniería", OID_O, "Elástico") + ); + } + + public void testExtractCustomDomainSpecificOid() { + // Test with a custom OID that might be used in a private PKI infrastructure + // This demonstrates the extractor works with any valid OID, not just RFC-standardized ones + // Using OID format: 1.3.6.1.4.1.<enterprise-number>.<custom-attributes> + String dnWithCustomOid = OID_EMPLOYEE_ID + "=EMP-2024-42, CN=Jane Developer, OU=Engineering, O=Acme Corp"; + String employeeId = extractFromDN(dnWithCustomOid, OID_EMPLOYEE_ID); + assertThat("Custom domain-specific OID extraction failed", employeeId, is(equalTo("EMP-2024-42"))); + } + + public void testExtractFromMultiValuedRdn() { + // Multi-valued RDNs use "+" to combine multiple attributes in a single RDN component (SET) + // Example: "CN=John Doe+OU=Engineering" - both CN and OU are in the same RDN SET + String multiValuedRdn = "CN=John Smith+OU=Development, O=Acme Corp"; + assertThat(extractFromDN(multiValuedRdn, OID_CN), is("John Smith")); + assertThat(extractFromDN(multiValuedRdn, OID_OU), is("Development")); + assertThat(extractFromDN(multiValuedRdn, OID_O), is("Acme Corp")); + } +}
cd97b8566bf5Extract principal from certificate RDN (#137230) (#138386)
8 files changed · +352 −18
docs/changelog/137230.yaml+5 −0 added@@ -0,0 +1,5 @@ +pr: 137230 +summary: Principal Extraction from Certificate RDN Attribute Value in PKI Realm +area: Security +type: bug +issues: []
docs/reference/elasticsearch/configuration-reference/security-settings.md+12 −0 modified@@ -769,6 +769,18 @@ In addition to the [settings that are valid for all realms](#ref-realm-settings) `username_pattern` : ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The regular expression pattern used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The first match group is the used as the username. Defaults to `CN=(.*?)(?:,|$)`. + This setting is ignored if either `username_rdn_oid` or `username_rdn_name` is set. + +`username_rdn_oid` +: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The relative distinguished name (RDN) attribute OID used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The value of the most specific RDN matching this attribute OID is used as the username. + + This setting takes precedent over `username_pattern`. You cannot use this setting and `username_rdn_name` at the same time. + +`username_rdn_name` +: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The relative distinguished name (RDN) attribute name used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The value of the most specific RDN matching this attribute name is used as the username. + + This setting takes precedent over `username_pattern`. You cannot use this setting and `username_rdn_oid` at the same time. + `certificate_authorities` : ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) List of paths to the PEM certificate files that should be used to authenticate a user’s certificate as trusted. Defaults to the trusted certificates configured for SSL. This setting cannot be used with `truststore.path`.
libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/DerParser.java+16 −15 modified@@ -36,21 +36,22 @@ public final class DerParser { private static final int CONSTRUCTED = 0x20; // Tag and data types - static final class Type { - static final int INTEGER = 0x02; - static final int OCTET_STRING = 0x04; - static final int OBJECT_OID = 0x06; - static final int SEQUENCE = 0x10; - static final int NUMERIC_STRING = 0x12; - static final int PRINTABLE_STRING = 0x13; - static final int VIDEOTEX_STRING = 0x15; - static final int IA5_STRING = 0x16; - static final int GRAPHIC_STRING = 0x19; - static final int ISO646_STRING = 0x1A; - static final int GENERAL_STRING = 0x1B; - static final int UTF8_STRING = 0x0C; - static final int UNIVERSAL_STRING = 0x1C; - static final int BMP_STRING = 0x1E; + public static final class Type { + public static final int INTEGER = 0x02; + public static final int OCTET_STRING = 0x04; + public static final int OBJECT_OID = 0x06; + public static final int SEQUENCE = 0x10; + public static final int SET = 0x11; + public static final int NUMERIC_STRING = 0x12; + public static final int PRINTABLE_STRING = 0x13; + public static final int VIDEOTEX_STRING = 0x15; + public static final int IA5_STRING = 0x16; + public static final int GRAPHIC_STRING = 0x19; + public static final int ISO646_STRING = 0x1A; + public static final int GENERAL_STRING = 0x1B; + public static final int UTF8_STRING = 0x0C; + public static final int UNIVERSAL_STRING = 0x1C; + public static final int BMP_STRING = 0x1E; } private InputStream derInputStream;
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java+33 −0 modified@@ -6,6 +6,10 @@ */ package org.elasticsearch.xpack.core.security.authc.pki; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.schema.AttributeTypeDefinition; +import com.unboundid.ldap.sdk.schema.Schema; + import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.core.TimeValue; @@ -29,6 +33,33 @@ public final class PkiRealmSettings { key -> new Setting<>(key, DEFAULT_USERNAME_PATTERN, s -> Pattern.compile(s, Pattern.CASE_INSENSITIVE), Setting.Property.NodeScope) ); + public static final Setting.AffixSetting<String> USERNAME_RDN_OID_SETTING = Setting.affixKeySetting( + RealmSettings.realmSettingPrefix(TYPE), + "username_rdn_oid", + key -> Setting.simpleString(key, Setting.Property.NodeScope) + ); + + public static final Setting.AffixSetting<String> USERNAME_RDN_NAME_SETTING = Setting.affixKeySetting( + RealmSettings.realmSettingPrefix(TYPE), + "username_rdn_name", + key -> new Setting<>(key, (String) null, s -> { + if (s == null) { + return ""; + } + Schema schema; + try { + schema = Schema.getDefaultStandardSchema(); + } catch (LDAPException e) { + throw new IllegalStateException("Unexpected error occurred obtaining default LDAP schema", e); + } + AttributeTypeDefinition atd = schema.getAttributeType(s); + if (atd == null) { + throw new IllegalArgumentException("Unknown RDN name [" + s + "] for setting [" + key + "]"); + } + return atd.getOID(); + }, Setting.Property.NodeScope) + ); + private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20); public static final Setting.AffixSetting<TimeValue> CACHE_TTL_SETTING = Setting.affixKeySetting( RealmSettings.realmSettingPrefix(TYPE), @@ -75,6 +106,8 @@ private PkiRealmSettings() {} public static Set<Setting.AffixSetting<?>> getSettings() { Set<Setting.AffixSetting<?>> settings = new HashSet<>(); settings.add(USERNAME_PATTERN_SETTING); + settings.add(USERNAME_RDN_OID_SETTING); + settings.add(USERNAME_RDN_NAME_SETTING); settings.add(CACHE_TTL_SETTING); settings.add(CACHE_MAX_USERS_SETTING); settings.add(DELEGATION_ENABLED_SETTING);
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java+35 −2 modified@@ -12,6 +12,7 @@ import org.elasticsearch.common.cache.Cache; import org.elasticsearch.common.cache.CacheBuilder; import org.elasticsearch.common.hash.MessageDigests; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslConfiguration; import org.elasticsearch.common.ssl.SslTrustConfig; import org.elasticsearch.common.util.concurrent.ReleasableLock; @@ -51,6 +52,7 @@ import javax.net.ssl.X509ExtendedTrustManager; import javax.net.ssl.X509TrustManager; +import javax.security.auth.x500.X500Principal; import static org.elasticsearch.core.Strings.format; @@ -76,6 +78,7 @@ public class PkiRealm extends Realm implements CachingRealm { private final X509TrustManager trustManager; private final Pattern principalPattern; + private final String principalRdnOid; private final UserRoleMapper roleMapper; private final Cache<BytesKey, User> cache; private DelegatedAuthorizationSupport delegatedRealms; @@ -91,6 +94,18 @@ public PkiRealm(RealmConfig config, ResourceWatcherService watcherService, UserR this.delegationEnabled = config.getSetting(PkiRealmSettings.DELEGATION_ENABLED_SETTING); this.trustManager = trustManagers(config); this.principalPattern = config.getSetting(PkiRealmSettings.USERNAME_PATTERN_SETTING); + String rdnOid = config.getSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING); + String rdnOidFromName = config.getSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING); + if (false == rdnOid.isEmpty() && false == rdnOidFromName.isEmpty()) { + throw new SettingsException( + "Both [" + + config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING).getKey() + + "] and [" + + config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING).getKey() + + "] are set. Only one of these settings can be configured." + ); + } + this.principalRdnOid = false == rdnOid.isEmpty() ? rdnOid : (false == rdnOidFromName.isEmpty() ? rdnOidFromName : null); this.roleMapper = roleMapper; this.roleMapper.clearRealmCacheOnChange(this); this.cache = CacheBuilder.<BytesKey, User>builder() @@ -133,7 +148,7 @@ public X509AuthenticationToken token(ThreadContext context) { // validation). In this case the principal should be set by the realm that completes the authentication. But in the common case, // where a single PKI realm is configured, there is no risk of eagerly parsing the principal before authentication and it also // maintains BWC. - String parsedPrincipal = getPrincipalFromSubjectDN(principalPattern, token, logger); + String parsedPrincipal = getPrincipalFromToken(token); if (parsedPrincipal == null) { return null; } @@ -164,7 +179,7 @@ public void authenticate(AuthenticationToken authToken, ActionListener<Authentic // parse the principal again after validating the cert chain, and do not rely on the token.principal one, because that could // be set by a different realm that failed trusted chain validation. We SHOULD NOT parse the principal BEFORE this step, but // we do it for BWC purposes. Changing this is a breaking change. - final String principal = getPrincipalFromSubjectDN(principalPattern, token, logger); + final String principal = getPrincipalFromToken(token); if (principal == null) { logger.debug( () -> format( @@ -231,6 +246,24 @@ public void lookupUser(String username, ActionListener<User> listener) { listener.onResponse(null); } + String getPrincipalFromToken(X509AuthenticationToken token) { + return principalRdnOid != null + ? getPrincipalFromRdnAttribute(principalRdnOid, token, logger) + : getPrincipalFromSubjectDN(principalPattern, token, logger); + } + + static String getPrincipalFromRdnAttribute(String principalRdnOid, X509AuthenticationToken token, Logger logger) { + X500Principal certPrincipal = token.credentials()[0].getSubjectX500Principal(); + String principal = RdnFieldExtractor.extract(certPrincipal.getEncoded(), principalRdnOid); + if (principal == null) { + logger.debug( + () -> format("the extracted principal from DN [%s] using RDN OID [%s] is empty", certPrincipal.toString(), principalRdnOid) + ); + return null; + } + return principal; + } + static String getPrincipalFromSubjectDN(Pattern principalPattern, X509AuthenticationToken token, Logger logger) { String dn = token.credentials()[0].getSubjectX500Principal().toString(); Matcher matcher = principalPattern.matcher(dn);
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractor.java+62 −0 added@@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.pki; + +import org.elasticsearch.common.ssl.DerParser; + +import java.io.IOException; + +/** + * Utility class to extract RDN field values from X500 principal DER encoding. + */ +public class RdnFieldExtractor { + + public static String extract(byte[] encoded, String oid) { + try { + return doExtract(encoded, oid); + } catch (IOException | IllegalStateException e) { + return null; // invalid encoding + } + } + + private static String doExtract(byte[] encoded, String oid) throws IOException { + DerParser parser = new DerParser(encoded); + + DerParser.Asn1Object dnSequence = parser.readAsn1Object(DerParser.Type.SEQUENCE); + DerParser sequenceParser = dnSequence.getParser(); + + String value = null; + + while (true) { + try { + DerParser.Asn1Object rdnSet = sequenceParser.readAsn1Object(DerParser.Type.SET); // throws IOException on EOF + DerParser setParser = rdnSet.getParser(); + + while (true) { + try { + DerParser.Asn1Object attrSeq = setParser.readAsn1Object(DerParser.Type.SEQUENCE); // throws IOException on EOF + DerParser attrParser = attrSeq.getParser(); + + String attrOid = attrParser.readAsn1Object().getOid(); + DerParser.Asn1Object attrValue = attrParser.readAsn1Object(); + if (oid.equals(attrOid)) { + value = attrValue.getString(); // retain last (most-significant) occurrence + } + } catch (IOException e) { + break; // RDN SET EOF + } + } + } catch (IOException e) { + break; // DN SEQUENCE EOF + } + } + + return value; + } + +}
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/PkiRealmTests.java+70 −1 modified@@ -13,6 +13,7 @@ import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslConfigException; import org.elasticsearch.common.util.CollectionUtils; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -230,7 +231,7 @@ private AuthenticationResult<User> authenticate(X509AuthenticationToken token, P } public void testCustomUsernamePatternMatches() throws Exception { - final Settings settings = Settings.builder() + Settings settings = Settings.builder() .put(globalSettings) .put("xpack.security.authc.realms.pki.my_pki.username_pattern", "OU=(.*?),") .build(); @@ -249,6 +250,74 @@ public void testCustomUsernamePatternMatches() throws Exception { assertThat(user.roles().length, is(0)); } + public void testRdnOidMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.11") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + User user = authenticate(token, realm).getValue(); + assertThat(user, is(notNullValue())); + assertThat(user.principal(), is("elasticsearch")); + } + + public void testRdnOidNameMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "OU") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + User user = authenticate(token, realm).getValue(); + assertThat(user, is(notNullValue())); + assertThat(user.principal(), is("elasticsearch")); + } + + public void testRdnOidNameNotMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + assertThat(token, is(nullValue())); + } + + public void testRdnOidNameUnknown() { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UNKNOWN_OID_NAME") + .build(); + UserRoleMapper roleMapper = buildRoleMapper(); + assertThrows(IllegalArgumentException.class, () -> buildRealm(roleMapper, settings)); + } + + public void testRedundantRdnOidSettings() { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.3") + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID") + .build(); + UserRoleMapper roleMapper = buildRoleMapper(); + assertThrows(SettingsException.class, () -> buildRealm(roleMapper, settings)); + } + public void testCustomUsernamePatternMismatchesAndNullToken() throws Exception { final Settings settings = Settings.builder() .put(globalSettings)
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractorTests.java+119 −0 added@@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.pki; + +import org.elasticsearch.test.ESTestCase; + +import java.util.List; +import java.util.Map; + +import javax.security.auth.x500.X500Principal; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +public class RdnFieldExtractorTests extends ESTestCase { + + private static final String OID_CN = "2.5.4.3"; // Common Name + private static final String OID_OU = "2.5.4.11"; // Organizational Unit + private static final String OID_O = "2.5.4.10"; // Organization + private static final String OID_C = "2.5.4.6"; // Country + private static final String OID_ST = "2.5.4.8"; // State or Province + private static final String OID_L = "2.5.4.7"; // Locality + private static final String OID_EMAIL = "1.2.840.113549.1.9.1"; // Email Address + + // Custom/domain-specific OID (fictional private enterprise OID) + // Format: 1.3.6.1.4.1.<enterprise-number>.<custom-attributes> + private static final String OID_EMPLOYEE_ID = "1.3.6.1.4.1.50000.1.1"; // Fictional: Employee ID + + private record ExtractionTestCase(String dn, String oid, String expectedValue) {} + + private static String extractFromDN(String dn, String oid) { + X500Principal principal = new X500Principal(dn); + return RdnFieldExtractor.extract(principal.getEncoded(), oid); + } + + private void assertExtractions(String dn, Map<String, String> expectedExtractions) { + byte[] encoded = new X500Principal(dn).getEncoded(); + for (Map.Entry<String, String> entry : expectedExtractions.entrySet()) { + String oid = entry.getKey(); + String expected = entry.getValue(); + String actual = RdnFieldExtractor.extract(encoded, oid); + assertThat("OID " + oid + " extraction failed for DN: " + dn, actual, is(equalTo(expected))); + } + } + + public void testExtractBasicAttributes() { + List<ExtractionTestCase> testCases = List.of( + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_CN, "John Doe"), + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_OU, "Engineering"), + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_O, "Elastic"), + new ExtractionTestCase("CN=John Doe, C=US", OID_C, "US"), + new ExtractionTestCase("CN=John Doe, ST=California, C=US", OID_ST, "California"), + new ExtractionTestCase("CN=John Doe, L=Mountain View, ST=California, C=US", OID_L, "Mountain View"), + new ExtractionTestCase("EMAILADDRESS=john@elastic.co, CN=John Doe", OID_EMAIL, "john@elastic.co") + ); + + for (ExtractionTestCase testCase : testCases) { + String result = extractFromDN(testCase.dn, testCase.oid); + assertThat("Failed to extract from DN: " + testCase.dn, result, is(equalTo(testCase.expectedValue))); + } + } + + public void testExtractFirstOUWhenMultipleExist() { + // When multiple RDNs with the same OID exist, should return the last one encountered in DER encoding + // Note: X.500 encoding reverses the order - the last attribute in the DN string is first in DER encoding + String ou = extractFromDN("CN=John Doe, OU=Security Team, OU=Engineering, O=Elastic", OID_OU); + assertThat(ou, is(equalTo("Security Team"))); + } + + public void testExtractOidNotFound() { + assertThat(extractFromDN("CN=John Doe, OU=Engineering", OID_C), is(nullValue())); + } + + public void testExtractWithEmptyEncoding() { + assertThat(RdnFieldExtractor.extract(new byte[0], OID_CN), is(nullValue())); + } + + public void testExtractWithMalformedDerData() { + byte[] malformedBytes = randomByteArrayOfLength(50); + + String result = RdnFieldExtractor.extract(malformedBytes, OID_CN); + assertThat(result, is(nullValue())); + } + + public void testExtractWithSpecialCharacters() { + assertExtractions("CN=Test\\, User, OU=R\\+D, O=Elastic\\\\Co", Map.of(OID_CN, "Test, User", OID_OU, "R+D", OID_O, "Elastic\\Co")); + } + + public void testExtractWithUtf8Characters() { + assertExtractions( + "CN=José García, OU=Ingeniería, O=Elástico", + Map.of(OID_CN, "José García", OID_OU, "Ingeniería", OID_O, "Elástico") + ); + } + + public void testExtractCustomDomainSpecificOid() { + // Test with a custom OID that might be used in a private PKI infrastructure + // This demonstrates the extractor works with any valid OID, not just RFC-standardized ones + // Using OID format: 1.3.6.1.4.1.<enterprise-number>.<custom-attributes> + String dnWithCustomOid = OID_EMPLOYEE_ID + "=EMP-2024-42, CN=Jane Developer, OU=Engineering, O=Acme Corp"; + String employeeId = extractFromDN(dnWithCustomOid, OID_EMPLOYEE_ID); + assertThat("Custom domain-specific OID extraction failed", employeeId, is(equalTo("EMP-2024-42"))); + } + + public void testExtractFromMultiValuedRdn() { + // Multi-valued RDNs use "+" to combine multiple attributes in a single RDN component (SET) + // Example: "CN=John Doe+OU=Engineering" - both CN and OU are in the same RDN SET + String multiValuedRdn = "CN=John Smith+OU=Development, O=Acme Corp"; + assertThat(extractFromDN(multiValuedRdn, OID_CN), is("John Smith")); + assertThat(extractFromDN(multiValuedRdn, OID_OU), is("Development")); + assertThat(extractFromDN(multiValuedRdn, OID_O), is("Acme Corp")); + } +}
d8a408da79f2Extract principal from certificate RDN (#137230) (#138388)
7 files changed · +340 −18
docs/changelog/137230.yaml+5 −0 added@@ -0,0 +1,5 @@ +pr: 137230 +summary: Principal Extraction from Certificate RDN Attribute Value in PKI Realm +area: Security +type: bug +issues: []
libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/DerParser.java+16 −15 modified@@ -36,21 +36,22 @@ public final class DerParser { private static final int CONSTRUCTED = 0x20; // Tag and data types - static final class Type { - static final int INTEGER = 0x02; - static final int OCTET_STRING = 0x04; - static final int OBJECT_OID = 0x06; - static final int SEQUENCE = 0x10; - static final int NUMERIC_STRING = 0x12; - static final int PRINTABLE_STRING = 0x13; - static final int VIDEOTEX_STRING = 0x15; - static final int IA5_STRING = 0x16; - static final int GRAPHIC_STRING = 0x19; - static final int ISO646_STRING = 0x1A; - static final int GENERAL_STRING = 0x1B; - static final int UTF8_STRING = 0x0C; - static final int UNIVERSAL_STRING = 0x1C; - static final int BMP_STRING = 0x1E; + public static final class Type { + public static final int INTEGER = 0x02; + public static final int OCTET_STRING = 0x04; + public static final int OBJECT_OID = 0x06; + public static final int SEQUENCE = 0x10; + public static final int SET = 0x11; + public static final int NUMERIC_STRING = 0x12; + public static final int PRINTABLE_STRING = 0x13; + public static final int VIDEOTEX_STRING = 0x15; + public static final int IA5_STRING = 0x16; + public static final int GRAPHIC_STRING = 0x19; + public static final int ISO646_STRING = 0x1A; + public static final int GENERAL_STRING = 0x1B; + public static final int UTF8_STRING = 0x0C; + public static final int UNIVERSAL_STRING = 0x1C; + public static final int BMP_STRING = 0x1E; } private InputStream derInputStream;
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java+33 −0 modified@@ -6,6 +6,10 @@ */ package org.elasticsearch.xpack.core.security.authc.pki; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.schema.AttributeTypeDefinition; +import com.unboundid.ldap.sdk.schema.Schema; + import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.core.TimeValue; @@ -29,6 +33,33 @@ public final class PkiRealmSettings { key -> new Setting<>(key, DEFAULT_USERNAME_PATTERN, s -> Pattern.compile(s, Pattern.CASE_INSENSITIVE), Setting.Property.NodeScope) ); + public static final Setting.AffixSetting<String> USERNAME_RDN_OID_SETTING = Setting.affixKeySetting( + RealmSettings.realmSettingPrefix(TYPE), + "username_rdn_oid", + key -> Setting.simpleString(key, Setting.Property.NodeScope) + ); + + public static final Setting.AffixSetting<String> USERNAME_RDN_NAME_SETTING = Setting.affixKeySetting( + RealmSettings.realmSettingPrefix(TYPE), + "username_rdn_name", + key -> new Setting<>(key, (String) null, s -> { + if (s == null) { + return ""; + } + Schema schema; + try { + schema = Schema.getDefaultStandardSchema(); + } catch (LDAPException e) { + throw new IllegalStateException("Unexpected error occurred obtaining default LDAP schema", e); + } + AttributeTypeDefinition atd = schema.getAttributeType(s); + if (atd == null) { + throw new IllegalArgumentException("Unknown RDN name [" + s + "] for setting [" + key + "]"); + } + return atd.getOID(); + }, Setting.Property.NodeScope) + ); + private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20); public static final Setting.AffixSetting<TimeValue> CACHE_TTL_SETTING = Setting.affixKeySetting( RealmSettings.realmSettingPrefix(TYPE), @@ -75,6 +106,8 @@ private PkiRealmSettings() {} public static Set<Setting.AffixSetting<?>> getSettings() { Set<Setting.AffixSetting<?>> settings = new HashSet<>(); settings.add(USERNAME_PATTERN_SETTING); + settings.add(USERNAME_RDN_OID_SETTING); + settings.add(USERNAME_RDN_NAME_SETTING); settings.add(CACHE_TTL_SETTING); settings.add(CACHE_MAX_USERS_SETTING); settings.add(DELEGATION_ENABLED_SETTING);
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java+35 −2 modified@@ -12,6 +12,7 @@ import org.elasticsearch.common.cache.Cache; import org.elasticsearch.common.cache.CacheBuilder; import org.elasticsearch.common.hash.MessageDigests; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslConfiguration; import org.elasticsearch.common.ssl.SslTrustConfig; import org.elasticsearch.common.util.concurrent.ReleasableLock; @@ -51,6 +52,7 @@ import javax.net.ssl.X509ExtendedTrustManager; import javax.net.ssl.X509TrustManager; +import javax.security.auth.x500.X500Principal; import static org.elasticsearch.core.Strings.format; @@ -76,6 +78,7 @@ public class PkiRealm extends Realm implements CachingRealm { private final X509TrustManager trustManager; private final Pattern principalPattern; + private final String principalRdnOid; private final UserRoleMapper roleMapper; private final Cache<BytesKey, User> cache; private DelegatedAuthorizationSupport delegatedRealms; @@ -91,6 +94,18 @@ public PkiRealm(RealmConfig config, ResourceWatcherService watcherService, UserR this.delegationEnabled = config.getSetting(PkiRealmSettings.DELEGATION_ENABLED_SETTING); this.trustManager = trustManagers(config); this.principalPattern = config.getSetting(PkiRealmSettings.USERNAME_PATTERN_SETTING); + String rdnOid = config.getSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING); + String rdnOidFromName = config.getSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING); + if (false == rdnOid.isEmpty() && false == rdnOidFromName.isEmpty()) { + throw new SettingsException( + "Both [" + + config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING).getKey() + + "] and [" + + config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING).getKey() + + "] are set. Only one of these settings can be configured." + ); + } + this.principalRdnOid = false == rdnOid.isEmpty() ? rdnOid : (false == rdnOidFromName.isEmpty() ? rdnOidFromName : null); this.roleMapper = roleMapper; this.roleMapper.clearRealmCacheOnChange(this); this.cache = CacheBuilder.<BytesKey, User>builder() @@ -133,7 +148,7 @@ public X509AuthenticationToken token(ThreadContext context) { // validation). In this case the principal should be set by the realm that completes the authentication. But in the common case, // where a single PKI realm is configured, there is no risk of eagerly parsing the principal before authentication and it also // maintains BWC. - String parsedPrincipal = getPrincipalFromSubjectDN(principalPattern, token, logger); + String parsedPrincipal = getPrincipalFromToken(token); if (parsedPrincipal == null) { return null; } @@ -164,7 +179,7 @@ public void authenticate(AuthenticationToken authToken, ActionListener<Authentic // parse the principal again after validating the cert chain, and do not rely on the token.principal one, because that could // be set by a different realm that failed trusted chain validation. We SHOULD NOT parse the principal BEFORE this step, but // we do it for BWC purposes. Changing this is a breaking change. - final String principal = getPrincipalFromSubjectDN(principalPattern, token, logger); + final String principal = getPrincipalFromToken(token); if (principal == null) { logger.debug( () -> format( @@ -231,6 +246,24 @@ public void lookupUser(String username, ActionListener<User> listener) { listener.onResponse(null); } + String getPrincipalFromToken(X509AuthenticationToken token) { + return principalRdnOid != null + ? getPrincipalFromRdnAttribute(principalRdnOid, token, logger) + : getPrincipalFromSubjectDN(principalPattern, token, logger); + } + + static String getPrincipalFromRdnAttribute(String principalRdnOid, X509AuthenticationToken token, Logger logger) { + X500Principal certPrincipal = token.credentials()[0].getSubjectX500Principal(); + String principal = RdnFieldExtractor.extract(certPrincipal.getEncoded(), principalRdnOid); + if (principal == null) { + logger.debug( + () -> format("the extracted principal from DN [%s] using RDN OID [%s] is empty", certPrincipal.toString(), principalRdnOid) + ); + return null; + } + return principal; + } + static String getPrincipalFromSubjectDN(Pattern principalPattern, X509AuthenticationToken token, Logger logger) { String dn = token.credentials()[0].getSubjectX500Principal().toString(); Matcher matcher = principalPattern.matcher(dn);
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractor.java+62 −0 added@@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.pki; + +import org.elasticsearch.common.ssl.DerParser; + +import java.io.IOException; + +/** + * Utility class to extract RDN field values from X500 principal DER encoding. + */ +public class RdnFieldExtractor { + + public static String extract(byte[] encoded, String oid) { + try { + return doExtract(encoded, oid); + } catch (IOException | IllegalStateException e) { + return null; // invalid encoding + } + } + + private static String doExtract(byte[] encoded, String oid) throws IOException { + DerParser parser = new DerParser(encoded); + + DerParser.Asn1Object dnSequence = parser.readAsn1Object(DerParser.Type.SEQUENCE); + DerParser sequenceParser = dnSequence.getParser(); + + String value = null; + + while (true) { + try { + DerParser.Asn1Object rdnSet = sequenceParser.readAsn1Object(DerParser.Type.SET); // throws IOException on EOF + DerParser setParser = rdnSet.getParser(); + + while (true) { + try { + DerParser.Asn1Object attrSeq = setParser.readAsn1Object(DerParser.Type.SEQUENCE); // throws IOException on EOF + DerParser attrParser = attrSeq.getParser(); + + String attrOid = attrParser.readAsn1Object().getOid(); + DerParser.Asn1Object attrValue = attrParser.readAsn1Object(); + if (oid.equals(attrOid)) { + value = attrValue.getString(); // retain last (most-significant) occurrence + } + } catch (IOException e) { + break; // RDN SET EOF + } + } + } catch (IOException e) { + break; // DN SEQUENCE EOF + } + } + + return value; + } + +}
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/PkiRealmTests.java+70 −1 modified@@ -13,6 +13,7 @@ import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslConfigException; import org.elasticsearch.common.util.CollectionUtils; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -230,7 +231,7 @@ private AuthenticationResult<User> authenticate(X509AuthenticationToken token, P } public void testCustomUsernamePatternMatches() throws Exception { - final Settings settings = Settings.builder() + Settings settings = Settings.builder() .put(globalSettings) .put("xpack.security.authc.realms.pki.my_pki.username_pattern", "OU=(.*?),") .build(); @@ -249,6 +250,74 @@ public void testCustomUsernamePatternMatches() throws Exception { assertThat(user.roles().length, is(0)); } + public void testRdnOidMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.11") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + User user = authenticate(token, realm).getValue(); + assertThat(user, is(notNullValue())); + assertThat(user.principal(), is("elasticsearch")); + } + + public void testRdnOidNameMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "OU") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + User user = authenticate(token, realm).getValue(); + assertThat(user, is(notNullValue())); + assertThat(user.principal(), is("elasticsearch")); + } + + public void testRdnOidNameNotMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + assertThat(token, is(nullValue())); + } + + public void testRdnOidNameUnknown() { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UNKNOWN_OID_NAME") + .build(); + UserRoleMapper roleMapper = buildRoleMapper(); + assertThrows(IllegalArgumentException.class, () -> buildRealm(roleMapper, settings)); + } + + public void testRedundantRdnOidSettings() { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.3") + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID") + .build(); + UserRoleMapper roleMapper = buildRoleMapper(); + assertThrows(SettingsException.class, () -> buildRealm(roleMapper, settings)); + } + public void testCustomUsernamePatternMismatchesAndNullToken() throws Exception { final Settings settings = Settings.builder() .put(globalSettings)
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractorTests.java+119 −0 added@@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.pki; + +import org.elasticsearch.test.ESTestCase; + +import java.util.List; +import java.util.Map; + +import javax.security.auth.x500.X500Principal; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +public class RdnFieldExtractorTests extends ESTestCase { + + private static final String OID_CN = "2.5.4.3"; // Common Name + private static final String OID_OU = "2.5.4.11"; // Organizational Unit + private static final String OID_O = "2.5.4.10"; // Organization + private static final String OID_C = "2.5.4.6"; // Country + private static final String OID_ST = "2.5.4.8"; // State or Province + private static final String OID_L = "2.5.4.7"; // Locality + private static final String OID_EMAIL = "1.2.840.113549.1.9.1"; // Email Address + + // Custom/domain-specific OID (fictional private enterprise OID) + // Format: 1.3.6.1.4.1.<enterprise-number>.<custom-attributes> + private static final String OID_EMPLOYEE_ID = "1.3.6.1.4.1.50000.1.1"; // Fictional: Employee ID + + private record ExtractionTestCase(String dn, String oid, String expectedValue) {} + + private static String extractFromDN(String dn, String oid) { + X500Principal principal = new X500Principal(dn); + return RdnFieldExtractor.extract(principal.getEncoded(), oid); + } + + private void assertExtractions(String dn, Map<String, String> expectedExtractions) { + byte[] encoded = new X500Principal(dn).getEncoded(); + for (Map.Entry<String, String> entry : expectedExtractions.entrySet()) { + String oid = entry.getKey(); + String expected = entry.getValue(); + String actual = RdnFieldExtractor.extract(encoded, oid); + assertThat("OID " + oid + " extraction failed for DN: " + dn, actual, is(equalTo(expected))); + } + } + + public void testExtractBasicAttributes() { + List<ExtractionTestCase> testCases = List.of( + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_CN, "John Doe"), + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_OU, "Engineering"), + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_O, "Elastic"), + new ExtractionTestCase("CN=John Doe, C=US", OID_C, "US"), + new ExtractionTestCase("CN=John Doe, ST=California, C=US", OID_ST, "California"), + new ExtractionTestCase("CN=John Doe, L=Mountain View, ST=California, C=US", OID_L, "Mountain View"), + new ExtractionTestCase("EMAILADDRESS=john@elastic.co, CN=John Doe", OID_EMAIL, "john@elastic.co") + ); + + for (ExtractionTestCase testCase : testCases) { + String result = extractFromDN(testCase.dn, testCase.oid); + assertThat("Failed to extract from DN: " + testCase.dn, result, is(equalTo(testCase.expectedValue))); + } + } + + public void testExtractFirstOUWhenMultipleExist() { + // When multiple RDNs with the same OID exist, should return the last one encountered in DER encoding + // Note: X.500 encoding reverses the order - the last attribute in the DN string is first in DER encoding + String ou = extractFromDN("CN=John Doe, OU=Security Team, OU=Engineering, O=Elastic", OID_OU); + assertThat(ou, is(equalTo("Security Team"))); + } + + public void testExtractOidNotFound() { + assertThat(extractFromDN("CN=John Doe, OU=Engineering", OID_C), is(nullValue())); + } + + public void testExtractWithEmptyEncoding() { + assertThat(RdnFieldExtractor.extract(new byte[0], OID_CN), is(nullValue())); + } + + public void testExtractWithMalformedDerData() { + byte[] malformedBytes = randomByteArrayOfLength(50); + + String result = RdnFieldExtractor.extract(malformedBytes, OID_CN); + assertThat(result, is(nullValue())); + } + + public void testExtractWithSpecialCharacters() { + assertExtractions("CN=Test\\, User, OU=R\\+D, O=Elastic\\\\Co", Map.of(OID_CN, "Test, User", OID_OU, "R+D", OID_O, "Elastic\\Co")); + } + + public void testExtractWithUtf8Characters() { + assertExtractions( + "CN=José García, OU=Ingeniería, O=Elástico", + Map.of(OID_CN, "José García", OID_OU, "Ingeniería", OID_O, "Elástico") + ); + } + + public void testExtractCustomDomainSpecificOid() { + // Test with a custom OID that might be used in a private PKI infrastructure + // This demonstrates the extractor works with any valid OID, not just RFC-standardized ones + // Using OID format: 1.3.6.1.4.1.<enterprise-number>.<custom-attributes> + String dnWithCustomOid = OID_EMPLOYEE_ID + "=EMP-2024-42, CN=Jane Developer, OU=Engineering, O=Acme Corp"; + String employeeId = extractFromDN(dnWithCustomOid, OID_EMPLOYEE_ID); + assertThat("Custom domain-specific OID extraction failed", employeeId, is(equalTo("EMP-2024-42"))); + } + + public void testExtractFromMultiValuedRdn() { + // Multi-valued RDNs use "+" to combine multiple attributes in a single RDN component (SET) + // Example: "CN=John Doe+OU=Engineering" - both CN and OU are in the same RDN SET + String multiValuedRdn = "CN=John Smith+OU=Development, O=Acme Corp"; + assertThat(extractFromDN(multiValuedRdn, OID_CN), is("John Smith")); + assertThat(extractFromDN(multiValuedRdn, OID_OU), is("Development")); + assertThat(extractFromDN(multiValuedRdn, OID_O), is("Acme Corp")); + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-m9gh-789g-q5pvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-37731ghsaADVISORY
- discuss.elastic.co/t/elasticsearch-8-19-8-9-1-8-and-9-2-2-security-update-esa-2025-27/384063ghsaWEB
- github.com/elastic/elasticsearch/commit/cd97b8566bf56e628070021300784cb9cee0286fghsaWEB
- github.com/elastic/elasticsearch/commit/d8a408da79f214395845d99d241e832077045983ghsaWEB
- github.com/elastic/elasticsearch/commit/e519fe4c51a3c887675eb7daea2f914738847f23ghsaWEB
News mentions
0No linked articles in our index yet.