001/* 002 * Copyright 2014-2020 Ping Identity Corporation 003 * All Rights Reserved. 004 */ 005/* 006 * Copyright 2014-2020 Ping Identity Corporation 007 * 008 * Licensed under the Apache License, Version 2.0 (the "License"); 009 * you may not use this file except in compliance with the License. 010 * You may obtain a copy of the License at 011 * 012 * http://www.apache.org/licenses/LICENSE-2.0 013 * 014 * Unless required by applicable law or agreed to in writing, software 015 * distributed under the License is distributed on an "AS IS" BASIS, 016 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 017 * See the License for the specific language governing permissions and 018 * limitations under the License. 019 */ 020/* 021 * Copyright (C) 2014-2020 Ping Identity Corporation 022 * 023 * This program is free software; you can redistribute it and/or modify 024 * it under the terms of the GNU General Public License (GPLv2 only) 025 * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) 026 * as published by the Free Software Foundation. 027 * 028 * This program is distributed in the hope that it will be useful, 029 * but WITHOUT ANY WARRANTY; without even the implied warranty of 030 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 031 * GNU General Public License for more details. 032 * 033 * You should have received a copy of the GNU General Public License 034 * along with this program; if not, see <http://www.gnu.org/licenses>. 035 */ 036package com.unboundid.util.ssl; 037 038 039 040import java.net.InetAddress; 041import java.net.URI; 042import java.util.Collection; 043import java.util.List; 044import java.security.cert.Certificate; 045import java.security.cert.X509Certificate; 046import javax.net.ssl.SSLSession; 047import javax.net.ssl.SSLSocket; 048import javax.security.auth.x500.X500Principal; 049 050import com.unboundid.asn1.ASN1OctetString; 051import com.unboundid.ldap.matchingrules.CaseIgnoreStringMatchingRule; 052import com.unboundid.ldap.sdk.DN; 053import com.unboundid.ldap.sdk.Filter; 054import com.unboundid.ldap.sdk.LDAPConnectionOptions; 055import com.unboundid.ldap.sdk.LDAPException; 056import com.unboundid.ldap.sdk.RDN; 057import com.unboundid.ldap.sdk.ResultCode; 058import com.unboundid.util.Debug; 059import com.unboundid.util.NotMutable; 060import com.unboundid.util.NotNull; 061import com.unboundid.util.Nullable; 062import com.unboundid.util.ObjectPair; 063import com.unboundid.util.StaticUtils; 064import com.unboundid.util.ThreadSafety; 065import com.unboundid.util.ThreadSafetyLevel; 066import com.unboundid.util.args.IPAddressArgumentValueValidator; 067 068import static com.unboundid.util.ssl.SSLMessages.*; 069 070 071 072/** 073 * This class provides an implementation of an {@code SSLSocket} verifier that 074 * will verify that the presented server certificate includes the address to 075 * which the client intended to establish a connection. It will check the CN 076 * attribute of the certificate subject, as well as certain subjectAltName 077 * extensions, including dNSName, uniformResourceIdentifier, and iPAddress. 078 */ 079@NotMutable() 080@ThreadSafety(level=ThreadSafetyLevel.COMPLETELY_THREADSAFE) 081public final class HostNameSSLSocketVerifier 082 extends SSLSocketVerifier 083{ 084 /** 085 * The name of a system property that can be used to specify the default 086 * behavior that the verifier should exhibit when checking certificates that 087 * contain both a CN attribute in the subject DN and a subject alternative 088 * name extension that contains one or more dNSName, 089 * uniformResourceIdentifier, or iPAddress values. Although RFC 6125 section 090 * 6.4.4 indicates that the CN attribute should not be checked in certificates 091 * that have an appropriate subject alternative name extension, LDAP clients 092 * historically treat both sources as equally valid. 093 */ 094 @NotNull public static final String 095 PROPERTY_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT = 096 HostNameSSLSocketVerifier.class.getName() + 097 ".checkCNWhenSubjectAltNameIsPresent"; 098 099 100 101 /** 102 * Indicates whether to check the CN attribute in the peer certificate's 103 * subject DN when that certificate also contains a subject subject 104 * alternative name extension. 105 */ 106 static final boolean DEFAULT_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT; 107 static 108 { 109 boolean checkCN = true; 110 final String propValue = StaticUtils.getSystemProperty( 111 PROPERTY_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT); 112 if ((propValue != null) && propValue.equalsIgnoreCase("false")) 113 { 114 checkCN = false; 115 } 116 117 DEFAULT_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT = checkCN; 118 } 119 120 121 122 // Indicates whether to allow wildcard certificates which contain an asterisk 123 // as the first component of a CN subject attribute or dNSName subjectAltName 124 // extension. 125 private final boolean allowWildcards; 126 127 // Indicates whether to check the CN attribute in the peer certificate's 128 // subject DN if the certificate also contains a subject alternative name 129 // extension that contains at least dNSName, uniformResourceIdentifier, or 130 // iPAddress value. 131 private final boolean checkCNWhenSubjectAltNameIsPresent; 132 133 134 135 /** 136 * Creates a new instance of this {@code SSLSocket} verifier. 137 * 138 * @param allowWildcards Indicates whether to allow wildcard certificates 139 * that contain an asterisk in the leftmost component 140 * of a hostname in the dNSName or 141 * uniformResourceIdentifier of the subject 142 * alternative name extension, or in the CN attribute 143 * of the subject DN. 144 */ 145 public HostNameSSLSocketVerifier(final boolean allowWildcards) 146 { 147 this(allowWildcards, DEFAULT_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT); 148 } 149 150 151 152 /** 153 * Creates a new instance of this {@code SSLSocket} verifier. 154 * 155 * @param allowWildcards 156 * Indicates whether to allow wildcard certificates that contain 157 * an asterisk in the leftmost component of a hostname in the 158 * dNSName or uniformResourceIdentifier of the subject 159 * alternative name extension, or in the CN attribute of the 160 * subject DN. 161 * @param checkCNWhenSubjectAltNameIsPresent 162 * Indicates whether to check the CN attribute in the peer 163 * certificate's subject DN if the certificate also contains a 164 * subject alternative name extension that contains at least one 165 * dNSName, uniformResourceIdentifier, or iPAddress value. 166 * Although RFC 6125 section 6.4.4 indicates that the CN 167 * attribute should not be checked in certificates that have an 168 * appropriate subject alternative name extension, LDAP clients 169 * historically treat both sources as equally valid. 170 */ 171 public HostNameSSLSocketVerifier(final boolean allowWildcards, 172 final boolean checkCNWhenSubjectAltNameIsPresent) 173 { 174 this.allowWildcards = allowWildcards; 175 this.checkCNWhenSubjectAltNameIsPresent = 176 checkCNWhenSubjectAltNameIsPresent; 177 } 178 179 180 181 /** 182 * Verifies that the provided {@code SSLSocket} is acceptable and the 183 * connection should be allowed to remain established. 184 * 185 * @param host The address to which the client intended the connection 186 * to be established. 187 * @param port The port to which the client intended the connection to 188 * be established. 189 * @param sslSocket The {@code SSLSocket} that should be verified. 190 * 191 * @throws LDAPException If a problem is identified that should prevent the 192 * provided {@code SSLSocket} from remaining 193 * established. 194 */ 195 @Override() 196 public void verifySSLSocket(@NotNull final String host, final int port, 197 @NotNull final SSLSocket sslSocket) 198 throws LDAPException 199 { 200 try 201 { 202 // Get the certificates presented during negotiation. The certificates 203 // will be ordered so that the server certificate comes first. 204 final SSLSession sslSession = sslSocket.getSession(); 205 if (sslSession == null) 206 { 207 throw new LDAPException(ResultCode.CONNECT_ERROR, 208 ERR_HOST_NAME_SSL_SOCKET_VERIFIER_NO_SESSION.get(host, port)); 209 } 210 211 final Certificate[] peerCertificateChain = 212 sslSession.getPeerCertificates(); 213 if ((peerCertificateChain == null) || (peerCertificateChain.length == 0)) 214 { 215 throw new LDAPException(ResultCode.CONNECT_ERROR, 216 ERR_HOST_NAME_SSL_SOCKET_VERIFIER_NO_PEER_CERTS.get(host, port)); 217 } 218 219 if (peerCertificateChain[0] instanceof X509Certificate) 220 { 221 final StringBuilder certInfo = new StringBuilder(); 222 if (! certificateIncludesHostname(host, 223 (X509Certificate) peerCertificateChain[0], allowWildcards, 224 checkCNWhenSubjectAltNameIsPresent, certInfo)) 225 { 226 throw new LDAPException(ResultCode.CONNECT_ERROR, 227 ERR_HOST_NAME_SSL_SOCKET_VERIFIER_HOSTNAME_NOT_FOUND.get(host, 228 certInfo.toString())); 229 } 230 } 231 else 232 { 233 throw new LDAPException(ResultCode.CONNECT_ERROR, 234 ERR_HOST_NAME_SSL_SOCKET_VERIFIER_PEER_NOT_X509.get(host, port, 235 peerCertificateChain[0].getType())); 236 } 237 } 238 catch (final LDAPException le) 239 { 240 Debug.debugException(le); 241 throw le; 242 } 243 catch (final Exception e) 244 { 245 Debug.debugException(e); 246 throw new LDAPException(ResultCode.CONNECT_ERROR, 247 ERR_HOST_NAME_SSL_SOCKET_VERIFIER_EXCEPTION.get(host, port, 248 StaticUtils.getExceptionMessage(e)), 249 e); 250 } 251 } 252 253 254 255 /** 256 * Determines whether the provided certificate contains the specified 257 * hostname. 258 * 259 * @param host 260 * The address expected to be found in the provided certificate. 261 * @param certificate 262 * The peer certificate to be validated. 263 * @param allowWildcards 264 * Indicates whether to allow wildcard certificates that contain 265 * an asterisk in the leftmost component of a hostname in the 266 * dNSName or uniformResourceIdentifier of the subject 267 * alternative name extension, or in the CN attribute of the 268 * subject DN. 269 * @param checkCNWhenSubjectAltNameIsPresent 270 * Indicates whether to check the CN attribute in the peer 271 * certificate's subject DN if the certificate also contains a 272 * subject alternative name extension that contains at least one 273 * dNSName, uniformResourceIdentifier, or iPAddress value. RFC 274 * 6125 section 6.4.4 indicates that the CN attribute should not 275 * be checked in certificates that have an appropriate subject 276 * alternative name extension, although some clients may expect 277 * CN matching anyway. 278 * @param certInfo 279 * A buffer into which information will be provided about the 280 * provided certificate. 281 * 282 * @return {@code true} if the expected hostname was found in the 283 * certificate, or {@code false} if not. 284 */ 285 static boolean certificateIncludesHostname(@NotNull final String host, 286 @NotNull final X509Certificate certificate, 287 final boolean allowWildcards, 288 final boolean checkCNWhenSubjectAltNameIsPresent, 289 @NotNull final StringBuilder certInfo) 290 { 291 // Check to see if the provided hostname is an IP address. 292 InetAddress hostInetAddress = null; 293 if (IPAddressArgumentValueValidator.isValidNumericIPAddress(host)) 294 { 295 try 296 { 297 hostInetAddress = 298 LDAPConnectionOptions.DEFAULT_NAME_RESOLVER.getByName(host); 299 } 300 catch (final Exception e) 301 { 302 Debug.debugException(e); 303 } 304 } 305 306 307 // Check to see if the certificate has a subject alternative name extension. 308 // If so, then check its dNSName, uniformResourceLocator, and iPAddress 309 // elements. 310 boolean hasAuthoritativeSubjectAlternativeName = false; 311 try 312 { 313 final Collection<List<?>> subjectAltNames; 314 subjectAltNames = certificate.getSubjectAlternativeNames(); 315 if (subjectAltNames != null) 316 { 317 for (final List<?> l : subjectAltNames) 318 { 319 final Integer type = (Integer) l.get(0); 320 switch (type) 321 { 322 case 2: // dNSName 323 final String dnsName = (String) l.get(1); 324 certInfo.append(" dNSName='"); 325 certInfo.append(dnsName); 326 certInfo.append('\''); 327 328 if (hostnameMatches(host, dnsName, allowWildcards)) 329 { 330 return true; 331 } 332 333 hasAuthoritativeSubjectAlternativeName = true; 334 break; 335 336 case 6: // uniformResourceIdentifier 337 final String uriString = (String) l.get(1); 338 certInfo.append(" uniformResourceIdentifier='"); 339 certInfo.append(uriString); 340 certInfo.append('\''); 341 342 final String uriHost = getHostFromURI(uriString); 343 if (uriHost != null) 344 { 345 if (IPAddressArgumentValueValidator.isValidNumericIPAddress( 346 uriHost)) 347 { 348 if ((hostInetAddress != null) && 349 ipAddressMatches(hostInetAddress, uriHost)) 350 { 351 return true; 352 } 353 } 354 else if (hostnameMatches(host, uriHost, allowWildcards)) 355 { 356 return true; 357 } 358 } 359 360 hasAuthoritativeSubjectAlternativeName = true; 361 break; 362 363 case 7: // iPAddress 364 final String ipAddressString = (String) l.get(1); 365 certInfo.append(" iPAddress='"); 366 certInfo.append(ipAddressString); 367 certInfo.append('\''); 368 369 if ((hostInetAddress != null) && 370 ipAddressMatches(hostInetAddress, ipAddressString)) 371 { 372 return true; 373 } 374 375 hasAuthoritativeSubjectAlternativeName = true; 376 break; 377 } 378 } 379 } 380 } 381 catch (final Exception e) 382 { 383 Debug.debugException(e); 384 } 385 386 387 // If we found an authoritative subject alternative name and we should not 388 // check the subject DN to see if it contains a CN attribute, then indicate 389 // that we didn't find a match. 390 if (hasAuthoritativeSubjectAlternativeName && 391 (! checkCNWhenSubjectAltNameIsPresent)) 392 { 393 return false; 394 } 395 396 397 // Look for any CN attributes in the certificate subject. 398 final String subjectDNString = 399 certificate.getSubjectX500Principal().getName(X500Principal.RFC2253); 400 certInfo.append("subject='"); 401 certInfo.append(subjectDNString); 402 certInfo.append('\''); 403 404 try 405 { 406 final DN subjectDN = new DN(subjectDNString); 407 for (final RDN rdn : subjectDN.getRDNs()) 408 { 409 final String[] names = rdn.getAttributeNames(); 410 final String[] values = rdn.getAttributeValues(); 411 for (int i=0; i < names.length; i++) 412 { 413 final String lowerName = StaticUtils.toLowerCase(names[i]); 414 if (lowerName.equals("cn") || lowerName.equals("commonname") || 415 lowerName.equals("2.5.4.3")) 416 417 { 418 final String cnValue = values[i]; 419 if (IPAddressArgumentValueValidator. 420 isValidNumericIPAddress(cnValue)) 421 { 422 if ((hostInetAddress != null) && 423 ipAddressMatches(hostInetAddress, cnValue)) 424 { 425 return true; 426 } 427 } 428 else 429 { 430 if (hostnameMatches(host, cnValue, allowWildcards)) 431 { 432 return true; 433 } 434 } 435 } 436 } 437 } 438 } 439 catch (final Exception e) 440 { 441 // This shouldn't happen for a well-formed certificate subject, but we 442 // have to handle it anyway. 443 Debug.debugException(e); 444 } 445 446 447 // If we've gotten here, then we can't consider the hostname a match. 448 return false; 449 } 450 451 452 453 /** 454 * Determines whether the provided client hostname matches the given 455 * hostname from the certificate. 456 * 457 * @param clientHostname 458 * The hostname that the client used when establishing the 459 * connection. 460 * @param certificateHostname 461 * A hostname obtained from the certificate. 462 * @param allowWildcards 463 * Indicates whether to allow wildcard certificates that contain 464 * an asterisk in the leftmost component of a hostname in the 465 * dNSName or uniformResourceIdentifier of the subject 466 * alternative name extension, or in the CN attribute of the 467 * subject DN. 468 * 469 * @return {@code true} if the client hostname is considered a match for the 470 * certificate hostname, or {@code false} if not. 471 */ 472 private static boolean hostnameMatches(@NotNull final String clientHostname, 473 @NotNull final String certificateHostname, 474 final boolean allowWildcards) 475 { 476 // If the provided certificate hostname does not contain any asterisks, 477 // then we just need to do a case-insensitive match. 478 if (! certificateHostname.contains("*")) 479 { 480 return clientHostname.equalsIgnoreCase(certificateHostname); 481 } 482 483 484 // The certificate hostname contains at least one wildcard. See if that's 485 // allowed. 486 if (! allowWildcards) 487 { 488 return false; 489 } 490 491 492 // Get the first component and the remainder for both the client and 493 // certificate hostnames. If the remainder doesn't match, then it's not a 494 // match. 495 final ObjectPair<String,String> clientFirstComponentAndRemainder = 496 getFirstComponentAndRemainder(clientHostname); 497 final ObjectPair<String,String> certificateFirstComponentAndRemainder = 498 getFirstComponentAndRemainder(certificateHostname); 499 if (! clientFirstComponentAndRemainder.getSecond().equalsIgnoreCase( 500 certificateFirstComponentAndRemainder.getSecond())) 501 { 502 return false; 503 } 504 505 506 // If the first component of the certificate hostname is just an asterisk, 507 // then we can consider it a match. 508 final String certificateFirstComponent = 509 certificateFirstComponentAndRemainder.getFirst(); 510 if (certificateFirstComponent.equals("*")) 511 { 512 return true; 513 } 514 515 516 // The filter has wildcard and non-wildcard components. At this point, the 517 // easiest thing to do is to try to create a substring filter to get the 518 // individual components of the filter. 519 final Filter filter; 520 try 521 { 522 filter = Filter.create("(hostname=" + certificateFirstComponent + ')'); 523 if (filter.getFilterType() != Filter.FILTER_TYPE_SUBSTRING) 524 { 525 return false; 526 } 527 } 528 catch (final Exception e) 529 { 530 Debug.debugException(e); 531 return false; 532 } 533 534 535 return CaseIgnoreStringMatchingRule.getInstance().matchesSubstring( 536 new ASN1OctetString(clientFirstComponentAndRemainder.getFirst()), 537 filter.getRawSubInitialValue(), 538 filter.getRawSubAnyValues(), filter.getRawSubFinalValue()); 539 } 540 541 542 543 /** 544 * Separates the provided address into the leftmost component (everything up 545 * to the first period) and the remainder (everything else, including the 546 * first period). If the provided address does not contain any periods, then 547 * the leftmost component will be the entire value and the remainder will be 548 * an empty string. 549 * 550 * @param address The address to be separated into the leftmost component 551 * and the remainder. It must not be {@code null}. 552 * 553 * @return An object pair in which the first element is the leftmost 554 * component of the provided address and the second element is the 555 * remainder of the address. 556 */ 557 @NotNull() 558 private static ObjectPair<String,String> getFirstComponentAndRemainder( 559 @NotNull final String address) 560 { 561 final int periodPos = address.indexOf('.'); 562 if (periodPos < 0) 563 { 564 return new ObjectPair<>(address, ""); 565 } 566 else 567 { 568 return new ObjectPair<>(address.substring(0, periodPos), 569 address.substring(periodPos)); 570 } 571 } 572 573 574 575 /** 576 * Determines whether the provided client IP address matches the IP address 577 * represented by the provided string. 578 * 579 * @param clientIPAddress 580 * The IP address that the client used when establishing the 581 * connection. 582 * @param certificateIPAddressString 583 * The string representation of an IP address obtained from the 584 * certificate. 585 * 586 * @return {@code true} if the client hostname is considered a match for the 587 * certificate hostname, or {@code false} if not. 588 */ 589 private static boolean ipAddressMatches( 590 @NotNull final InetAddress clientIPAddress, 591 @NotNull final String certificateIPAddressString) 592 { 593 final InetAddress certificateIPAddress; 594 try 595 { 596 certificateIPAddress = LDAPConnectionOptions.DEFAULT_NAME_RESOLVER. 597 getByName(certificateIPAddressString); 598 } 599 catch (final Exception e) 600 { 601 Debug.debugException(e); 602 return false; 603 } 604 605 return clientIPAddress.equals(certificateIPAddress); 606 } 607 608 609 610 /** 611 * Extracts the host from the URI with the given string representation. Note 612 * that the Java URI parser doesn't like hostnames that have wildcards, so we 613 * have to handle them specially. 614 * 615 * @param uriString The string representation of the URI to parse. It must 616 * not be {@code null}. 617 * 618 * @return The host extracted from the provided URI, or {@code null} if none 619 * is available (e.g., because the URI is malformed). 620 */ 621 @Nullable() 622 private static String getHostFromURI(@NotNull final String uriString) 623 { 624 final URI uri; 625 try 626 { 627 uri = new URI(uriString); 628 } 629 catch (final Exception e) 630 { 631 Debug.debugException(e); 632 return null; 633 } 634 635 final String uriHost = uri.getHost(); 636 if (uriHost != null) 637 { 638 return uriHost; 639 } 640 641 642 // Java's URI code can't handle hosts with wildcards. See if the provided 643 // URI string looks like it might contain a wildcard. If not, then just 644 // return null. 645 if (! uriString.contains("*")) 646 { 647 return null; 648 } 649 650 651 // If Java was at least able to parse the scheme, and if the URI starts with 652 // that scheme, then we can go ahead with our own parsing attempt. 653 final String scheme = uri.getScheme(); 654 if ((scheme == null) || scheme.isEmpty() || 655 (! uriString.toLowerCase().startsWith(scheme))) 656 { 657 return null; 658 } 659 660 661 // Strip the scheme from the beginning of the URI. Note that the scheme 662 // probably won't contain the "://", so strip that separately. 663 String paredDownURI = uriString.substring(scheme.length()); 664 if (paredDownURI.startsWith("://")) 665 { 666 paredDownURI = paredDownURI.substring(3); 667 } 668 669 670 // If the pared down URI contains a slash (which would separate the hostport 671 // section from the path), then strip that off and everything after it. 672 final int slashPos = paredDownURI.indexOf('/'); 673 if (slashPos >= 0) 674 { 675 paredDownURI = paredDownURI.substring(0, slashPos); 676 } 677 678 679 // If the pared down URI contains a colon (which would separate the host 680 // from the port), then strip that off and everything after it. 681 final int colonPos = paredDownURI.indexOf(':'); 682 if (colonPos >= 0) 683 { 684 paredDownURI = paredDownURI.substring(0, colonPos); 685 } 686 687 688 // If there's anything left, then it should be the host. 689 if (! paredDownURI.isEmpty()) 690 { 691 return paredDownURI; 692 } 693 694 return null; 695 } 696}