From b34ce456b3e634d0bfe6d10465f71621b60c0b77 Mon Sep 17 00:00:00 2001 From: Lamine Idjeraoui Date: Sun, 12 Oct 2025 17:47:14 -0500 Subject: [PATCH] Enhance X509 principal resolution Signed-off-by: Lamine Idjeraoui --- .../http/HTTPClientCertAuthenticator.java | 244 +++++++++++++++--- .../opensearch/security/IntegrationTests.java | 151 +++++++++++ 2 files changed, 356 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/opensearch/security/http/HTTPClientCertAuthenticator.java b/src/main/java/org/opensearch/security/http/HTTPClientCertAuthenticator.java index dbc3476185..7b43bfb94d 100644 --- a/src/main/java/org/opensearch/security/http/HTTPClientCertAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/HTTPClientCertAuthenticator.java @@ -27,10 +27,19 @@ package org.opensearch.security.http; import java.nio.file.Path; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.EnumSet; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import javax.naming.InvalidNameException; import javax.naming.ldap.LdapName; import javax.naming.ldap.Rdn; @@ -54,66 +63,126 @@ public class HTTPClientCertAuthenticator implements HTTPAuthenticator { public static final String OPENDISTRO_SECURITY_SSL_SKIP_USERS = "skip_users"; protected final Settings settings; private final WildcardMatcher skipUsersMatcher; + private final ParsedAttribute parsedUsernameAttr; + private final ParsedAttribute parsedRolesAttr; - public HTTPClientCertAuthenticator(final Settings settings, final Path configPath) { - this.settings = settings; - this.skipUsersMatcher = WildcardMatcher.from(settings.getAsList(OPENDISTRO_SECURITY_SSL_SKIP_USERS)); + private enum AttributeType { + DN, + SAN } - @Override - public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext threadContext) { + private record ParsedSAN(int type, Pattern pattern) { + } - final String principal = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PRINCIPAL); + private record ParsedAttribute(AttributeType type, String dnAttr, ParsedSAN san) { + + static ParsedAttribute dn(String attr) { + return new ParsedAttribute(AttributeType.DN, attr, null); + } + + static ParsedAttribute san(ParsedSAN san) { + return new ParsedAttribute(AttributeType.SAN, null, san); + } + } + + private ParsedAttribute parseAttributeSetting(String raw) { + if (Strings.isNullOrEmpty(raw)) return null; // “not configured” - if (!Strings.isNullOrEmpty(principal)) { + // Accept forms: + // "cn" -> DN:cn + // "dn:cn" -> DN:cn + // "san:EMAIL" -> SAN type EMAIL, no regex (match all of that SAN) + // "san:EMAIL:re" -> SAN type EMAIL, regex + final String s = raw.trim(); - final String usernameAttribute = settings.get("username_attribute"); - final String rolesAttribute = settings.get("roles_attribute"); + if (s.regionMatches(true, 0, "san:", 0, 4)) { + final String rest = s.substring(4); // after "san:" + final int firstColon = rest.indexOf(':'); + final String sanField = (firstColon >= 0) ? rest.substring(0, firstColon) : rest; + final String regex = (firstColon >= 0) ? rest.substring(firstColon + 1) : null; - if (skipUsersMatcher.test(principal)) { - log.debug("Skipped user client cert authentication of user {} as its in skip_users list ", principal); + Integer sanTypeInt; + try { + sanTypeInt = SANType.valueOf(sanField.toUpperCase(java.util.Locale.ROOT)).getValue(); + } catch (IllegalArgumentException e) { + log.warn("Unsupported SAN type '{}' in attribute '{}'", sanField, raw); return null; } - try { - final LdapName rfc2253dn = new LdapName(principal); - String username = principal.trim(); - String[] backendRoles = null; - - if (usernameAttribute != null && usernameAttribute.length() > 0) { - final List usernames = getDnAttribute(rfc2253dn, usernameAttribute); - if (usernames.isEmpty() == false) { - username = usernames.get(0); - } + Pattern pattern = null; + if (!Strings.isNullOrEmpty(regex)) { + try { + pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + } catch (Exception e) { + log.warn("Invalid regex in attribute '{}': {}", raw, e.toString()); + return null; } + } - if (rolesAttribute != null && rolesAttribute.length() > 0) { - final List roles = getDnAttribute(rfc2253dn, rolesAttribute); - if (roles.isEmpty() == false) { - backendRoles = roles.toArray(new String[0]); - } - } + return ParsedAttribute.san(new ParsedSAN(sanTypeInt, pattern)); + } - return new AuthCredentials(username, backendRoles).markComplete(); - } catch (InvalidNameException e) { - log.error("Client cert had no properly formed DN (was: {})", principal); - return null; - } + // DN form: either "dn:cn" or just "cn" + final String dnAttr = s.regionMatches(true, 0, "dn:", 0, 3) ? s.substring(3) : s; + return ParsedAttribute.dn(dnAttr); + } - } else { + public HTTPClientCertAuthenticator(final Settings settings, final Path configPath) { + this.settings = settings; + this.skipUsersMatcher = WildcardMatcher.from(settings.getAsList(OPENDISTRO_SECURITY_SSL_SKIP_USERS)); + this.parsedUsernameAttr = parseAttributeSetting(settings.get("username_attribute")); + this.parsedRolesAttr = parseAttributeSetting(settings.get("roles_attribute")); + } + + @Override + public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext threadContext) { + + final String principal = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PRINCIPAL); + + if (Strings.isNullOrEmpty(principal)) { log.trace("No CLIENT CERT, send 401"); return null; } + if (skipUsersMatcher.test(principal)) { + log.debug("Skipped user client cert authentication of user {} as its in skip_users list ", principal); + return null; + } + + try { + final String username = extractUsername(threadContext, principal); + final String[] roles = extractRoles(threadContext, principal); + return new AuthCredentials(username, roles).markComplete(); + } catch (InvalidNameException e) { + log.error("Client cert had no properly formed DN"); + log.debug("Client cert had no properly formed DN (was: {})", principal); + return null; + } } - @Override - public Optional reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) { - return Optional.empty(); + private String extractUsername(ThreadContext ctx, String principal) throws InvalidNameException { + if (parsedUsernameAttr == null || (parsedUsernameAttr.type == AttributeType.DN && parsedUsernameAttr.dnAttr == null)) { + return principal; + } + List usernames; + if (parsedUsernameAttr.type == AttributeType.DN) { + usernames = getDnAttribute(new LdapName(principal), parsedUsernameAttr.dnAttr); + } else { + usernames = extractFromSAN(ctx, parsedUsernameAttr.san); + } + return usernames == null || usernames.isEmpty() ? principal : usernames.get(0); } - @Override - public String getType() { - return "clientcert"; + private String[] extractRoles(ThreadContext ctx, String principal) throws InvalidNameException { + if (parsedRolesAttr == null || (parsedRolesAttr.type == AttributeType.DN && parsedRolesAttr.dnAttr == null)) { + return null; + } + List roles; + if (parsedRolesAttr.type == AttributeType.DN) { + roles = getDnAttribute(new LdapName(principal), parsedRolesAttr.dnAttr); + } else { + roles = extractFromSAN(ctx, parsedRolesAttr.san); + } + return roles == null || roles.isEmpty() ? null : roles.toArray(new String[0]); } private List getDnAttribute(LdapName rfc2253dn, String attribute) { @@ -129,4 +198,101 @@ private List getDnAttribute(LdapName rfc2253dn, String attribute) { return Collections.unmodifiableList(attrValues); } + + private static final int MAX_SAN_MATCHES = 16; + private static final int MAX_SAN_VALUE_LEN = 8192; + + private List extractFromSAN(ThreadContext ctx, ParsedSAN psan) { + if (psan == null) return Collections.emptyList(); + + final X509Certificate[] peerCertificates = ctx.getTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PEER_CERTIFICATES); + + if (peerCertificates == null || peerCertificates.length == 0) { + return Collections.emptyList(); + } + + try { + Collection> altNames = peerCertificates[0].getSubjectAlternativeNames(); + if (altNames == null) return Collections.emptyList(); + + return altNames.stream() + .filter(entry -> entry != null && entry.size() >= 2) + .filter(entry -> entry.get(0) instanceof Integer i && i.intValue() == psan.type()) + .map(entry -> sanValueToString(psan.type, entry.get(1))) + .map(v -> { + if (Strings.isNullOrEmpty(v)) return null; + if (psan.pattern() == null) return v; // no regex -> keep full + // bound input length before regex + String s = v.length() > MAX_SAN_VALUE_LEN ? v.substring(0, MAX_SAN_VALUE_LEN) : v; + Matcher m = psan.pattern().matcher(s); + if (!m.matches()) return null; + return (m.groupCount() >= 1) ? m.group(1) : s; // first capture group, else full + }) + .filter(Objects::nonNull) + .limit(MAX_SAN_MATCHES) + .collect(Collectors.toList()); + } catch (CertificateParsingException e) { + log.error("Error parsing X509 certificate", e); + return Collections.emptyList(); + } + } + + // sometimes IP address is of type of byte[] + private static String sanValueToString(int type, Object value) { + if (value == null) return null; + if (value instanceof String) return (String) value; + if (type == SANType.IP_ADDRESS.value && value instanceof byte[]) { + byte[] addr = (byte[]) value; + try { + return java.net.InetAddress.getByAddress(addr).getHostAddress(); + } catch (java.net.UnknownHostException e) { + return null; + } + } + return null; + } + + @Override + public Optional reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) { + return Optional.empty(); + } + + @Override + public String getType() { + return "clientcert"; + } + + /** + * Enumeration of supported SAN (Subject Alternative Name) types as defined in RFC 5280. + * https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6 + */ + private enum SANType { + OTHER_NAME(0), // OtherName + EMAIL(1), // rfc822Name + DNS(2), // dNSName + X400_ADDRESS(3), // x400Address + DIRECTORY_NAME(4), // directoryName + EDI_PARTY_NAME(5), // ediPartyName + URI(6), // uniformResourceIdentifier + IP_ADDRESS(7), // iPAddress + REGISTERED_ID(8); // registeredID + + private static final Map lookup = EnumSet.allOf(SANType.class) + .stream() + .collect(Collectors.toMap(SANType::getValue, sanType -> sanType)); + + private final int value; + + SANType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static SANType fromValue(int value) { + return lookup.get(value); + } + } } diff --git a/src/test/java/org/opensearch/security/IntegrationTests.java b/src/test/java/org/opensearch/security/IntegrationTests.java index 4a9b0fb907..824c877bed 100644 --- a/src/test/java/org/opensearch/security/IntegrationTests.java +++ b/src/test/java/org/opensearch/security/IntegrationTests.java @@ -26,6 +26,11 @@ package org.opensearch.security; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.TreeSet; import com.fasterxml.jackson.databind.JsonNode; @@ -53,8 +58,11 @@ import org.opensearch.security.test.helper.file.FileHelper; import org.opensearch.security.test.helper.rest.RestHelper; import org.opensearch.security.test.helper.rest.RestHelper.HttpResponse; +import org.opensearch.security.user.AuthCredentials; import org.opensearch.transport.client.Client; +import org.mockito.Mockito; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.opensearch.security.DefaultObjectMapper.readTree; @@ -141,6 +149,149 @@ private ThreadContext newThreadContext(String sslPrincipal) { return threadContext; } + @Test + public void testSanParsingCertAuth() throws Exception { + testSanUsernameParsingCertAuth(); + testSanRolesParsingCertAuth(); + } + + private void testSanUsernameParsingCertAuth() throws Exception { + for (SanUsernameCase c : USERNAME_CASES) { + X509Certificate cert = mockCertWithSan(c.type, c.value); + Settings s = Settings.builder().put("username_attribute", c.userAttr).build(); + HTTPClientCertAuthenticator auth = new HTTPClientCertAuthenticator(s, null); + + AuthCredentials creds = auth.extractCredentials(null, ctxWith(SSL_PRINCIPAL, cert)); + Assert.assertEquals("Username mismatch: " + c, c.expectedUser, creds.getUsername()); + } + } + + public void testSanRolesParsingCertAuth() throws Exception { + for (SanRolesCase c : ROLES_CASES) { + X509Certificate cert = mockCertWithSan(c.type, c.value); + Settings s = Settings.builder().put("roles_attribute", c.rolesAttr).build(); + HTTPClientCertAuthenticator auth = new HTTPClientCertAuthenticator(s, null); + + AuthCredentials creds = auth.extractCredentials(null, ctxWith(SSL_PRINCIPAL, cert)); + Set actual = creds.getBackendRoles() == null ? null : new HashSet<>(creds.getBackendRoles()); + if (c.expectedRoles == null) { + Assert.assertNull("Expected null roles: " + c, actual); + } else { + Set expected = new HashSet<>(Arrays.asList(c.expectedRoles)); + Assert.assertEquals("Roles mismatch: " + c, expected, actual); + } + } + } + + private static X509Certificate mockCertWithSan(int type, String value) throws Exception { + X509Certificate cert = Mockito.mock(X509Certificate.class); + Mockito.when(cert.getSubjectAlternativeNames()).thenReturn(Arrays.asList(Arrays.asList(type, value))); + return cert; + } + + private static ThreadContext ctxWith(String principal, X509Certificate cert) { + ThreadContext ctx = new ThreadContext(Settings.EMPTY); + ctx.putTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PRINCIPAL, principal); + ctx.putTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PEER_CERTIFICATES, cert == null ? null : new X509Certificate[] { cert }); + return ctx; + } + + private record SanUsernameCase(int type, String value, String userAttr, String expectedUser) { + + @Override + public String toString() { + return "type=" + type + ", value=" + value + ", userAttr=" + userAttr + ", expectedUser=" + expectedUser; + } + } + + private record SanRolesCase(int type, String value, String rolesAttr, String[] expectedRoles) { + + @Override + public String toString() { + return "type=" + type + ", value=" + value + ", rolesAttr=" + rolesAttr; + } + } + + private static final String SSL_PRINCIPAL = "cn=abc,cn=xxx,l=ert,st=zui,c=qwe"; + + private static final List USERNAME_CASES = Arrays.asList( + // EMAIL + new SanUsernameCase(1, "user1@example.com", "san:EMAIL:user1@example.com", "user1@example.com"), + new SanUsernameCase(1, "user2@example.com", "san:EMAIL:examples.com", SSL_PRINCIPAL), + new SanUsernameCase(1, "user+special@example.com", "san:EMAIL:user\\+special@example.com", "user+special@example.com"), + new SanUsernameCase(1, "admin@example.org", "san:EMAIL:.*@example\\.(?:com|org)", "admin@example.org"), + new SanUsernameCase(1, "admin@example.org", "san:EMAIL:(?i)^[^@]+@example\\.org$", "admin@example.org"), + new SanUsernameCase(1, "user@sub.example.net", "san:EMAIL:.*@.*example\\.net", "user@sub.example.net"), + + // DNS + new SanUsernameCase(2, "www.example.com", "san:DNS:www.example.com", "www.example.com"), + new SanUsernameCase(2, "shop.store.example.com", "san:DNS:.*.store.example.com", "shop.store.example.com"), + new SanUsernameCase(2, "subdomain.example.co.uk", "san:DNS:.*.example.co.uk", "subdomain.example.co.uk"), + new SanUsernameCase(2, "example.net", "san:DNS:.*.example.com", SSL_PRINCIPAL), + new SanUsernameCase(2, "api.services.example", "san:DNS:.*.services.example.com", SSL_PRINCIPAL), + + // IP + new SanUsernameCase(7, "10.0.0.1", "san:IP_ADDRESS:10.0.0.1", "10.0.0.1"), + new SanUsernameCase(7, "192.168.1.10", "san:IP_ADDRESS:192.168.1.*", "192.168.1.10"), + new SanUsernameCase(7, "172.16.0.1", "san:IP_ADDRESS:10.0.*", SSL_PRINCIPAL), + + // URI + new SanUsernameCase(6, "https://example.com/resource", "san:URI:https://example.com/resource", "https://example.com/resource"), + new SanUsernameCase( + 6, + "https://example.com/sub/resource", + "san:URI:https://example.com/sub/.*", + "https://example.com/sub/resource" + ), + new SanUsernameCase(6, "http://example.com", "san:URI:(?:https|http)://example.com", "http://example.com"), + new SanUsernameCase( + 6, + "https://example.com/path?param=value#fragment", + "san:URI:https://example.com/path\\?param=value.*", + "https://example.com/path?param=value#fragment" + ), + new SanUsernameCase(6, "ftp://example.com/file", "san:URI:http://.*", SSL_PRINCIPAL), + + // OTHER_NAME / DIRECTORY_NAME / REGISTERED_ID + new SanUsernameCase(0, "specificOtherNameValue", "san:OTHER_NAME:.*", "specificOtherNameValue"), + new SanUsernameCase(0, "specificOtherNameValue", "san:OTHER_NAME:specificOtherNameValue1", SSL_PRINCIPAL), + new SanUsernameCase( + 3, + "X400:C=GB;A= ;P=Some Organization", + "san:X400_ADDRESS:X400:C=GB;A=.*;P=.*", + "X400:C=GB;A= ;P=Some Organization" + ), + new SanUsernameCase( + 4, + "C=US, ST=Virginia, L=SomeCity, O=My Org, OU=My Unit, CN=www.example.org", + "san:DIRECTORY_NAME:C=US, ST=.*", + "C=US, ST=Virginia, L=SomeCity, O=My Org, OU=My Unit, CN=www.example.org" + ), + new SanUsernameCase(8, "1.2.3.4.5.6", "san:REGISTERED_ID:1.2.3.4.*", "1.2.3.4.5.6") + ); + + private static final List ROLES_CASES = Arrays.asList( + // EMAIL with capturing group → group(1) only + new SanRolesCase(1, "admin@blue.example.org", "san:EMAIL:^[^@]+@([^.]+)\\.example\\.(?:com|org)$", new String[] { "blue" }), + + // DNS no capturing group → whole string kept + new SanRolesCase(2, "svc1.api.example.com", "san:DNS:.*\\.example\\.com$", new String[] { "svc1.api.example.com" }), + + // URI with capturing group → path segment + new SanRolesCase( + 6, + "https://example.com/dep/teamA/resource", + "san:URI:^https://example\\.com/dep/([^/]+)/.*$", + new String[] { "teamA" } + ), + + // No match → empty roles + new SanRolesCase(7, "10.0.0.5", "san:EMAIL:.*@example\\.org$", new String[] {}), + + // DN roles (ignore SAN) → L=ert from DN + new SanRolesCase(1, "whatever@example.com", "dn:l", new String[] { "ert" }) + ); + @Test public void testDNSpecials() throws Exception {