diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerCertificateUtils.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerCertificateUtils.java index 04eb5d018..3e9d13107 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerCertificateUtils.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerCertificateUtils.java @@ -194,37 +194,67 @@ static void validateServerNameInCertificate(X509Certificate cert, String hostNam // more than one name of the same type." // So, more than one entry of dnsNameType can be present. // Java docs guarantee that the first entry in the list will be an integer. - // 2 is the sequence no of a dnsName - if ((key != null) && (key instanceof Integer) && ((Integer) key == 2)) { - // As per RFC2459, the DNSName will be in the - // "preferred name syntax" as specified by RFC - // 1034 and the name can be in upper or lower case. - // And no significance is attached to case. - // Java docs guarantee that the second entry in the list - // will be a string for dnsName - if (value != null && value instanceof String) { - dnsNameInSANCert = (String) value; - - // Use English locale to avoid Turkish i issues. - // Note that, this conversion was not necessary for - // cert.getSubjectX500Principal().getName("canonical"); - // as the above API already does this by default as per documentation. - dnsNameInSANCert = dnsNameInSANCert.toLowerCase(Locale.ENGLISH); - - isServerNameValidated = validateServerName(dnsNameInSANCert, hostName); - if (isServerNameValidated) { + // Per RFC 5280, GeneralName types: 2 = dNSName, 7 = iPAddress + if ((key != null) && (key instanceof Integer)) { + int sanType = (Integer) key; + + if (sanType == 2) { + // As per RFC2459, the DNSName will be in the + // "preferred name syntax" as specified by RFC + // 1034 and the name can be in upper or lower case. + // And no significance is attached to case. + // Java docs guarantee that the second entry in the list + // will be a string for dnsName + if (value != null && value instanceof String) { + dnsNameInSANCert = (String) value; + + // Use English locale to avoid Turkish i issues. + // Note that, this conversion was not necessary for + // cert.getSubjectX500Principal().getName("canonical"); + // as the above API already does this by default as per documentation. + dnsNameInSANCert = dnsNameInSANCert.toLowerCase(Locale.ENGLISH); + + isServerNameValidated = validateServerName(dnsNameInSANCert, hostName); + if (isServerNameValidated) { + if (logger.isLoggable(Level.FINER)) { + logger.finer( + logContext + " found a valid name in certificate: " + dnsNameInSANCert); + } + break; + } + } + + if (logger.isLoggable(Level.FINER)) { + logger.finer(logContext + + " the following name in certificate does not match the serverName: " + value); + logger.finer(logContext + " certificate:\n" + cert.toString()); + } + } else if (sanType == 7) { + // iPAddress SAN entry - per RFC 5280, IP addresses in SAN are stored + // as octet strings but Java's getSubjectAlternativeNames() returns + // them as String representation of the IP address + if (value != null && value instanceof String) { + String ipInCert = (String) value; + if (logger.isLoggable(Level.FINER)) { - logger.finer( - logContext + " found a valid name in certificate: " + dnsNameInSANCert); + logger.finer(logContext + " found IP address in SAN: " + ipInCert); + } + + // IP addresses must match exactly (no wildcard support) + if (ipInCert.equalsIgnoreCase(hostName)) { + isServerNameValidated = true; + if (logger.isLoggable(Level.FINER)) { + logger.finer( + logContext + " IP address in certificate matches: " + ipInCert); + } + break; } - break; } - } - if (logger.isLoggable(Level.FINER)) { - logger.finer(logContext - + " the following name in certificate does not match the serverName: " + value); - logger.finer(logContext + " certificate:\n" + cert.toString()); + if (logger.isLoggable(Level.FINER)) { + logger.finer(logContext + + " the following IP in certificate does not match the serverName: " + value); + } } } } else { diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/SSLCertificateValidationTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/SSLCertificateValidationTest.java index 3963a7e02..5599561ab 100644 --- a/src/test/java/com/microsoft/sqlserver/jdbc/SSLCertificateValidationTest.java +++ b/src/test/java/com/microsoft/sqlserver/jdbc/SSLCertificateValidationTest.java @@ -12,6 +12,8 @@ import java.lang.reflect.Method; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.List; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -271,4 +273,116 @@ public void testSecureCNParsing_preventsHostnameSpoofing() throws Exception { assertDoesNotThrow( () -> SQLServerCertificateUtils.validateServerNameInCertificate(spoofedCert, "attacker.com")); } + + /** + * Helper to create a SAN entry list. + * Per X509Certificate.getSubjectAlternativeNames() spec: + * - First element is an Integer representing the type (2 = dNSName, 7 = iPAddress) + * - Second element is a String for dNSName and iPAddress types + */ + private static List sanEntry(int type, String value) { + return Arrays.asList(Integer.valueOf(type), value); + } + + @Test + public void testIPAddressInSAN_IPv4Match() throws Exception { + // Certificate with IPv4 address in SAN (type 7) + X500Principal subject = new X500Principal("CN=sqlserver.example.com"); + Collection> sans = new ArrayList<>(); + sans.add(sanEntry(7, "192.168.1.100")); + + X509Certificate cert = mockCert(subject, sans); + + // Should succeed when connecting via the IP address in SAN + assertDoesNotThrow( + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "192.168.1.100")); + } + + @Test + public void testIPAddressInSAN_IPv4Mismatch() throws Exception { + // Certificate with IPv4 address in SAN (type 7) + X500Principal subject = new X500Principal("CN=sqlserver.example.com"); + Collection> sans = new ArrayList<>(); + sans.add(sanEntry(7, "192.168.1.100")); + + X509Certificate cert = mockCert(subject, sans); + + // Should fail when connecting via a different IP address + assertThrows(CertificateException.class, + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "192.168.1.101")); + } + + @Test + public void testIPAddressInSAN_IPv6Match() throws Exception { + // Certificate with IPv6 address in SAN (type 7) + X500Principal subject = new X500Principal("CN=sqlserver.example.com"); + Collection> sans = new ArrayList<>(); + sans.add(sanEntry(7, "2001:db8::1")); + + X509Certificate cert = mockCert(subject, sans); + + // Should succeed when connecting via the IPv6 address in SAN + assertDoesNotThrow( + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "2001:db8::1")); + } + + @Test + public void testIPAddressInSAN_MultipleEntries() throws Exception { + // Certificate with both DNS name and IP address in SAN + X500Principal subject = new X500Principal("CN=sqlserver.example.com"); + Collection> sans = new ArrayList<>(); + sans.add(sanEntry(2, "sqlserver.example.com")); + sans.add(sanEntry(7, "10.0.0.50")); + sans.add(sanEntry(7, "192.168.1.100")); + + X509Certificate cert = mockCert(subject, sans); + + // Should succeed for DNS name + assertDoesNotThrow( + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "sqlserver.example.com")); + + // Should succeed for first IP + assertDoesNotThrow( + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "10.0.0.50")); + + // Should succeed for second IP + assertDoesNotThrow( + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "192.168.1.100")); + + // Should fail for unlisted IP + assertThrows(CertificateException.class, + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "172.16.0.1")); + } + + @Test + public void testIPAddressInSAN_CaseInsensitive() throws Exception { + // Certificate with IPv6 address in SAN - test case insensitivity + X500Principal subject = new X500Principal("CN=sqlserver.example.com"); + Collection> sans = new ArrayList<>(); + sans.add(sanEntry(7, "2001:DB8::1")); + + X509Certificate cert = mockCert(subject, sans); + + // Should succeed with different case (IPv6 addresses are case-insensitive) + assertDoesNotThrow( + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "2001:db8::1")); + } + + @Test + public void testIPAddressInSAN_FallbackToCN() throws Exception { + // Certificate with only DNS SAN, no IP - should still work via CN fallback + X500Principal subject = new X500Principal("CN=sqlserver.example.com"); + Collection> sans = new ArrayList<>(); + sans.add(sanEntry(2, "sqlserver.example.com")); + + X509Certificate cert = mockCert(subject, sans); + + // Should succeed for hostname matching CN/SAN + assertDoesNotThrow( + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "sqlserver.example.com")); + + // Should fail for IP when no IP SAN is present + assertThrows(CertificateException.class, + () -> SQLServerCertificateUtils.validateServerNameInCertificate(cert, "192.168.1.100")); + } }