From d948330ef28f1f5b57752d4af61abf812a00eba7 Mon Sep 17 00:00:00 2001 From: Xudong Ma Date: Fri, 18 Dec 2015 17:40:52 -0800 Subject: [PATCH] [OkHttp]Add hostname verification. --- .../testing/integration/Http2OkHttpTest.java | 33 ++ .../grpc/okhttp/OkHttpProtocolNegotiator.java | 7 +- .../io/grpc/okhttp/OkHttpTlsUpgrader.java | 14 +- .../internal/DistinguishedNameParser.java | 410 ++++++++++++++++++ .../okhttp/internal/OkHostnameVerifier.java | 255 +++++++++++ 5 files changed, 712 insertions(+), 7 deletions(-) create mode 100644 okhttp/third_party/okhttp/java/io/grpc/okhttp/internal/DistinguishedNameParser.java create mode 100644 okhttp/third_party/okhttp/java/io/grpc/okhttp/internal/OkHostnameVerifier.java diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/Http2OkHttpTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/Http2OkHttpTest.java index 652d3ca457..83a0780f25 100644 --- a/interop-testing/src/test/java/io/grpc/testing/integration/Http2OkHttpTest.java +++ b/interop-testing/src/test/java/io/grpc/testing/integration/Http2OkHttpTest.java @@ -31,6 +31,12 @@ package io.grpc.testing.integration; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.common.base.Throwables; +import com.google.protobuf.EmptyProtos.Empty; + import com.squareup.okhttp.ConnectionSpec; import com.squareup.okhttp.TlsVersion; @@ -52,6 +58,8 @@ import org.junit.runners.JUnit4; import java.io.IOException; +import javax.net.ssl.SSLPeerUnverifiedException; + /** * Integration tests for GRPC over Http2 using the OkHttp framework. */ @@ -117,4 +125,29 @@ public class Http2OkHttpTest extends AbstractTransportTest { recorder.awaitCompletion(); emptyUnary(); } + + @Test(timeout = 10000) + public void wrongHostNameFailHostnameVerification() throws Exception { + OkHttpChannelBuilder builder = OkHttpChannelBuilder.forAddress("127.0.0.1", serverPort) + .connectionSpec(new ConnectionSpec.Builder(OkHttpChannelBuilder.DEFAULT_CONNECTION_SPEC) + .cipherSuites(TestUtils.preferredTestCiphers().toArray(new String[0])) + .tlsVersions(ConnectionSpec.MODERN_TLS.tlsVersions().toArray(new TlsVersion[0])) + .build()) + .overrideAuthority(GrpcUtil.authorityFromHostAndPort( + "I.am.a.bad.hostname", serverPort)); + ManagedChannel channel = builder.sslSocketFactory( + TestUtils.newSslSocketFactoryForCa(TestUtils.loadCert("ca.pem"))).build(); + TestServiceGrpc.TestServiceBlockingStub blockingStub = + TestServiceGrpc.newBlockingStub(channel); + + try { + blockingStub.emptyCall(Empty.getDefaultInstance()); + fail("The rpc should have been failed due to hostname verification"); + } catch (Throwable t) { + Throwable cause = Throwables.getRootCause(t); + assertTrue("Failed by unexpected exception: " + cause, + cause instanceof SSLPeerUnverifiedException); + } + channel.shutdown(); + } } diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpProtocolNegotiator.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpProtocolNegotiator.java index 4abda2619f..f22ad74458 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpProtocolNegotiator.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpProtocolNegotiator.java @@ -46,6 +46,7 @@ import java.security.Provider; import java.security.Security; import java.util.List; +import javax.annotation.Nullable; import javax.net.ssl.SSLSocket; /** @@ -96,8 +97,10 @@ class OkHttpProtocolNegotiator { * @throws RuntimeException if the negotiation completed, but no protocol was selected. */ public String negotiate( - SSLSocket sslSocket, String hostname, List protocols) throws IOException { - configureTlsExtensions(sslSocket, hostname, protocols); + SSLSocket sslSocket, String hostname, @Nullable List protocols) throws IOException { + if (protocols != null) { + configureTlsExtensions(sslSocket, hostname, protocols); + } try { // Force handshake. sslSocket.startHandshake(); diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpTlsUpgrader.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpTlsUpgrader.java index 6fa1f7bd7b..4cb9c5bcf5 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpTlsUpgrader.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpTlsUpgrader.java @@ -34,6 +34,7 @@ package io.grpc.okhttp; import com.google.common.base.Preconditions; import io.grpc.okhttp.internal.ConnectionSpec; +import io.grpc.okhttp.internal.OkHostnameVerifier; import io.grpc.okhttp.internal.Protocol; import java.io.IOException; @@ -42,6 +43,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; @@ -69,11 +71,13 @@ final class OkHttpTlsUpgrader { SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket( socket, host, port, true /* auto close */); spec.apply(sslSocket, false); - if (spec.supportsTlsExtensions()) { - String negotiatedProtocol = - OkHttpProtocolNegotiator.get().negotiate(sslSocket, host, TLS_PROTOCOLS); - Preconditions.checkState(HTTP2_PROTOCOL_NAME.equals(negotiatedProtocol), - "Only \"h2\" is supported, but negotiated protocol is %s", negotiatedProtocol); + String negotiatedProtocol = OkHttpProtocolNegotiator.get().negotiate( + sslSocket, host, spec.supportsTlsExtensions() ? TLS_PROTOCOLS : null); + Preconditions.checkState(HTTP2_PROTOCOL_NAME.equals(negotiatedProtocol), + "Only \"h2\" is supported, but negotiated protocol is %s", negotiatedProtocol); + + if (!OkHostnameVerifier.INSTANCE.verify(host, sslSocket.getSession())) { + throw new SSLPeerUnverifiedException("Cannot verify hostname: " + host); } return sslSocket; } diff --git a/okhttp/third_party/okhttp/java/io/grpc/okhttp/internal/DistinguishedNameParser.java b/okhttp/third_party/okhttp/java/io/grpc/okhttp/internal/DistinguishedNameParser.java new file mode 100644 index 0000000000..dd6deb297e --- /dev/null +++ b/okhttp/third_party/okhttp/java/io/grpc/okhttp/internal/DistinguishedNameParser.java @@ -0,0 +1,410 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Forked from OkHttp 2.5.0 + */ + +package io.grpc.okhttp.internal; + +import javax.security.auth.x500.X500Principal; + +/** + * A distinguished name (DN) parser. This parser only supports extracting a + * string value from a DN. It doesn't support values in the hex-string style. + */ +final class DistinguishedNameParser { + private final String dn; + private final int length; + private int pos; + private int beg; + private int end; + + /** Temporary variable to store positions of the currently parsed item. */ + private int cur; + + /** Distinguished name characters. */ + private char[] chars; + + public DistinguishedNameParser(X500Principal principal) { + // RFC2253 is used to ensure we get attributes in the reverse + // order of the underlying ASN.1 encoding, so that the most + // significant values of repeated attributes occur first. + this.dn = principal.getName(X500Principal.RFC2253); + this.length = this.dn.length(); + } + + // gets next attribute type: (ALPHA 1*keychar) / oid + private String nextAT() { + // skip preceding space chars, they can present after + // comma or semicolon (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + if (pos == length) { + return null; // reached the end of DN + } + + // mark the beginning of attribute type + beg = pos; + + // attribute type chars + pos++; + for (; pos < length && chars[pos] != '=' && chars[pos] != ' '; pos++) { + // we don't follow exact BNF syntax here: + // accept any char except space and '=' + } + if (pos >= length) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + // mark the end of attribute type + end = pos; + + // skip trailing space chars between attribute type and '=' + // (compatibility with RFC 1779) + if (chars[pos] == ' ') { + for (; pos < length && chars[pos] != '=' && chars[pos] == ' '; pos++) { + } + + if (chars[pos] != '=' || pos == length) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + } + + pos++; //skip '=' char + + // skip space chars between '=' and attribute value + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + + // in case of oid attribute type skip its prefix: "oid." or "OID." + // (compatibility with RFC 1779) + if ((end - beg > 4) && (chars[beg + 3] == '.') + && (chars[beg] == 'O' || chars[beg] == 'o') + && (chars[beg + 1] == 'I' || chars[beg + 1] == 'i') + && (chars[beg + 2] == 'D' || chars[beg + 2] == 'd')) { + beg += 4; + } + + return new String(chars, beg, end - beg); + } + + // gets quoted attribute value: QUOTATION *( quotechar / pair ) QUOTATION + private String quotedAV() { + pos++; + beg = pos; + end = beg; + while (true) { + + if (pos == length) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + if (chars[pos] == '"') { + // enclosing quotation was found + pos++; + break; + } else if (chars[pos] == '\\') { + chars[end] = getEscaped(); + } else { + // shift char: required for string with escaped chars + chars[end] = chars[pos]; + } + pos++; + end++; + } + + // skip trailing space chars before comma or semicolon. + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + + return new String(chars, beg, end - beg); + } + + // gets hex string attribute value: "#" hexstring + private String hexAV() { + if (pos + 4 >= length) { + // encoded byte array must be not less then 4 c + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + beg = pos; // store '#' position + pos++; + while (true) { + + // check for end of attribute value + // looks for space and component separators + if (pos == length || chars[pos] == '+' || chars[pos] == ',' + || chars[pos] == ';') { + end = pos; + break; + } + + if (chars[pos] == ' ') { + end = pos; + pos++; + // skip trailing space chars before comma or semicolon. + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + break; + } else if (chars[pos] >= 'A' && chars[pos] <= 'F') { + chars[pos] += 32; //to low case + } + + pos++; + } + + // verify length of hex string + // encoded byte array must be not less then 4 and must be even number + int hexLen = end - beg; // skip first '#' char + if (hexLen < 5 || (hexLen & 1) == 0) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + // get byte encoding from string representation + byte[] encoded = new byte[hexLen / 2]; + for (int i = 0, p = beg + 1; i < encoded.length; p += 2, i++) { + encoded[i] = (byte) getByte(p); + } + + return new String(chars, beg, hexLen); + } + + // gets string attribute value: *( stringchar / pair ) + private String escapedAV() { + beg = pos; + end = pos; + while (true) { + if (pos >= length) { + // the end of DN has been found + return new String(chars, beg, end - beg); + } + + switch (chars[pos]) { + case '+': + case ',': + case ';': + // separator char has been found + return new String(chars, beg, end - beg); + case '\\': + // escaped char + chars[end++] = getEscaped(); + pos++; + break; + case ' ': + // need to figure out whether space defines + // the end of attribute value or not + cur = end; + + pos++; + chars[end++] = ' '; + + for (; pos < length && chars[pos] == ' '; pos++) { + chars[end++] = ' '; + } + if (pos == length || chars[pos] == ',' || chars[pos] == '+' + || chars[pos] == ';') { + // separator char or the end of DN has been found + return new String(chars, beg, cur - beg); + } + break; + default: + chars[end++] = chars[pos]; + pos++; + } + } + } + + // returns escaped char + private char getEscaped() { + pos++; + if (pos == length) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + switch (chars[pos]) { + case '"': + case '\\': + case ',': + case '=': + case '+': + case '<': + case '>': + case '#': + case ';': + case ' ': + case '*': + case '%': + case '_': + //FIXME: escaping is allowed only for leading or trailing space char + return chars[pos]; + default: + // RFC doesn't explicitly say that escaped hex pair is + // interpreted as UTF-8 char. It only contains an example of such DN. + return getUTF8(); + } + } + + // decodes UTF-8 char + // see http://www.unicode.org for UTF-8 bit distribution table + private char getUTF8() { + int res = getByte(pos); + pos++; //FIXME tmp + + if (res < 128) { // one byte: 0-7F + return (char) res; + } else if (res >= 192 && res <= 247) { + + int count; + if (res <= 223) { // two bytes: C0-DF + count = 1; + res = res & 0x1F; + } else if (res <= 239) { // three bytes: E0-EF + count = 2; + res = res & 0x0F; + } else { // four bytes: F0-F7 + count = 3; + res = res & 0x07; + } + + int b; + for (int i = 0; i < count; i++) { + pos++; + if (pos == length || chars[pos] != '\\') { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + pos++; + + b = getByte(pos); + pos++; //FIXME tmp + if ((b & 0xC0) != 0x80) { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + + res = (res << 6) + (b & 0x3F); + } + return (char) res; + } else { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + } + + // Returns byte representation of a char pair + // The char pair is composed of DN char in + // specified 'position' and the next char + // According to BNF syntax: + // hexchar = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" + // / "a" / "b" / "c" / "d" / "e" / "f" + private int getByte(int position) { + if (position + 1 >= length) { + throw new IllegalStateException("Malformed DN: " + dn); + } + + int b1, b2; + + b1 = chars[position]; + if (b1 >= '0' && b1 <= '9') { + b1 = b1 - '0'; + } else if (b1 >= 'a' && b1 <= 'f') { + b1 = b1 - 87; // 87 = 'a' - 10 + } else if (b1 >= 'A' && b1 <= 'F') { + b1 = b1 - 55; // 55 = 'A' - 10 + } else { + throw new IllegalStateException("Malformed DN: " + dn); + } + + b2 = chars[position + 1]; + if (b2 >= '0' && b2 <= '9') { + b2 = b2 - '0'; + } else if (b2 >= 'a' && b2 <= 'f') { + b2 = b2 - 87; // 87 = 'a' - 10 + } else if (b2 >= 'A' && b2 <= 'F') { + b2 = b2 - 55; // 55 = 'A' - 10 + } else { + throw new IllegalStateException("Malformed DN: " + dn); + } + + return (b1 << 4) + b2; + } + + /** + * Parses the DN and returns the most significant attribute value + * for an attribute type, or null if none found. + * + * @param attributeType attribute type to look for (e.g. "ca") + */ + public String findMostSpecific(String attributeType) { + // Initialize internal state. + pos = 0; + beg = 0; + end = 0; + cur = 0; + chars = dn.toCharArray(); + + String attType = nextAT(); + if (attType == null) { + return null; + } + while (true) { + String attValue = ""; + + if (pos == length) { + return null; + } + + switch (chars[pos]) { + case '"': + attValue = quotedAV(); + break; + case '#': + attValue = hexAV(); + break; + case '+': + case ',': + case ';': // compatibility with RFC 1779: semicolon can separate RDNs + //empty attribute value + break; + default: + attValue = escapedAV(); + } + + // Values are ordered from most specific to least specific + // due to the RFC2253 formatting. So take the first match + // we see. + if (attributeType.equalsIgnoreCase(attType)) { + return attValue; + } + + if (pos >= length) { + return null; + } + + if (chars[pos] == ',' || chars[pos] == ';') { + } else if (chars[pos] != '+') { + throw new IllegalStateException("Malformed DN: " + dn); + } + + pos++; + attType = nextAT(); + if (attType == null) { + throw new IllegalStateException("Malformed DN: " + dn); + } + } + } +} diff --git a/okhttp/third_party/okhttp/java/io/grpc/okhttp/internal/OkHostnameVerifier.java b/okhttp/third_party/okhttp/java/io/grpc/okhttp/internal/OkHostnameVerifier.java new file mode 100644 index 0000000000..b7b1d5367f --- /dev/null +++ b/okhttp/third_party/okhttp/java/io/grpc/okhttp/internal/OkHostnameVerifier.java @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Forked from OkHttp 2.5.0 + */ + +package io.grpc.okhttp.internal; + +import java.security.cert.Certificate; +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.List; +import java.util.Locale; +import java.util.regex.Pattern; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSession; +import javax.security.auth.x500.X500Principal; + +/** + * A HostnameVerifier consistent with RFC 2818. + */ +public final class OkHostnameVerifier implements HostnameVerifier { + public static final OkHostnameVerifier INSTANCE = new OkHostnameVerifier(); + + /** + * Quick and dirty pattern to differentiate IP addresses from hostnames. This + * is an approximation of Android's private InetAddress#isNumeric API. + * + *

This matches IPv6 addresses as a hex string containing at least one + * colon, and possibly including dots after the first colon. It matches IPv4 + * addresses as strings containing only decimal digits and dots. This pattern + * matches strings like "a:.23" and "54" that are neither IP addresses nor + * hostnames; they will be verified as IP addresses (which is a more strict + * verification). + */ + private static final Pattern VERIFY_AS_IP_ADDRESS = Pattern.compile( + "([0-9a-fA-F]*:[0-9a-fA-F:.]*)|([\\d.]+)"); + + private static final int ALT_DNS_NAME = 2; + private static final int ALT_IPA_NAME = 7; + + private OkHostnameVerifier() { + } + + @Override + public boolean verify(String host, SSLSession session) { + try { + Certificate[] certificates = session.getPeerCertificates(); + return verify(host, (X509Certificate) certificates[0]); + } catch (SSLException e) { + return false; + } + } + + public boolean verify(String host, X509Certificate certificate) { + return verifyAsIpAddress(host) + ? verifyIpAddress(host, certificate) + : verifyHostName(host, certificate); + } + + static boolean verifyAsIpAddress(String host) { + return VERIFY_AS_IP_ADDRESS.matcher(host).matches(); + } + + /** + * Returns true if {@code certificate} matches {@code ipAddress}. + */ + private boolean verifyIpAddress(String ipAddress, X509Certificate certificate) { + List altNames = getSubjectAltNames(certificate, ALT_IPA_NAME); + for (int i = 0, size = altNames.size(); i < size; i++) { + if (ipAddress.equalsIgnoreCase(altNames.get(i))) { + return true; + } + } + return false; + } + + /** + * Returns true if {@code certificate} matches {@code hostName}. + */ + private boolean verifyHostName(String hostName, X509Certificate certificate) { + hostName = hostName.toLowerCase(Locale.US); + boolean hasDns = false; + List altNames = getSubjectAltNames(certificate, ALT_DNS_NAME); + for (int i = 0, size = altNames.size(); i < size; i++) { + hasDns = true; + if (verifyHostName(hostName, altNames.get(i))) { + return true; + } + } + + if (!hasDns) { + X500Principal principal = certificate.getSubjectX500Principal(); + // RFC 2818 advises using the most specific name for matching. + String cn = new DistinguishedNameParser(principal).findMostSpecific("cn"); + if (cn != null) { + return verifyHostName(hostName, cn); + } + } + + return false; + } + + public static List allSubjectAltNames(X509Certificate certificate) { + List altIpaNames = getSubjectAltNames(certificate, ALT_IPA_NAME); + List altDnsNames = getSubjectAltNames(certificate, ALT_DNS_NAME); + List result = new ArrayList(altIpaNames.size() + altDnsNames.size()); + result.addAll(altIpaNames); + result.addAll(altDnsNames); + return result; + } + + private static List getSubjectAltNames(X509Certificate certificate, int type) { + List result = new ArrayList(); + try { + Collection subjectAltNames = certificate.getSubjectAlternativeNames(); + if (subjectAltNames == null) { + return Collections.emptyList(); + } + for (Object subjectAltName : subjectAltNames) { + List entry = (List) subjectAltName; + if (entry == null || entry.size() < 2) { + continue; + } + Integer altNameType = (Integer) entry.get(0); + if (altNameType == null) { + continue; + } + if (altNameType == type) { + String altName = (String) entry.get(1); + if (altName != null) { + result.add(altName); + } + } + } + return result; + } catch (CertificateParsingException e) { + return Collections.emptyList(); + } + } + + /** + * Returns {@code true} iff {@code hostName} matches the domain name {@code pattern}. + * + * @param hostName lower-case host name. + * @param pattern domain name pattern from certificate. May be a wildcard pattern such as + * {@code *.android.com}. + */ + private boolean verifyHostName(String hostName, String pattern) { + // Basic sanity checks + // Check length == 0 instead of .isEmpty() to support Java 5. + if ((hostName == null) || (hostName.length() == 0) || (hostName.startsWith(".")) + || (hostName.endsWith(".."))) { + // Invalid domain name + return false; + } + if ((pattern == null) || (pattern.length() == 0) || (pattern.startsWith(".")) + || (pattern.endsWith(".."))) { + // Invalid pattern/domain name + return false; + } + + // Normalize hostName and pattern by turning them into absolute domain names if they are not + // yet absolute. This is needed because server certificates do not normally contain absolute + // names or patterns, but they should be treated as absolute. At the same time, any hostName + // presented to this method should also be treated as absolute for the purposes of matching + // to the server certificate. + // www.android.com matches www.android.com + // www.android.com matches www.android.com. + // www.android.com. matches www.android.com. + // www.android.com. matches www.android.com + if (!hostName.endsWith(".")) { + hostName += '.'; + } + if (!pattern.endsWith(".")) { + pattern += '.'; + } + // hostName and pattern are now absolute domain names. + + pattern = pattern.toLowerCase(Locale.US); + // hostName and pattern are now in lower case -- domain names are case-insensitive. + + if (!pattern.contains("*")) { + // Not a wildcard pattern -- hostName and pattern must match exactly. + return hostName.equals(pattern); + } + // Wildcard pattern + + // WILDCARD PATTERN RULES: + // 1. Asterisk (*) is only permitted in the left-most domain name label and must be the + // only character in that label (i.e., must match the whole left-most label). + // For example, *.example.com is permitted, while *a.example.com, a*.example.com, + // a*b.example.com, a.*.example.com are not permitted. + // 2. Asterisk (*) cannot match across domain name labels. + // For example, *.example.com matches test.example.com but does not match + // sub.test.example.com. + // 3. Wildcard patterns for single-label domain names are not permitted. + + if ((!pattern.startsWith("*.")) || (pattern.indexOf('*', 1) != -1)) { + // Asterisk (*) is only permitted in the left-most domain name label and must be the only + // character in that label + return false; + } + + // Optimization: check whether hostName is too short to match the pattern. hostName must be at + // least as long as the pattern because asterisk must match the whole left-most label and + // hostName starts with a non-empty label. Thus, asterisk has to match one or more characters. + if (hostName.length() < pattern.length()) { + // hostName too short to match the pattern. + return false; + } + + if ("*.".equals(pattern)) { + // Wildcard pattern for single-label domain name -- not permitted. + return false; + } + + // hostName must end with the region of pattern following the asterisk. + String suffix = pattern.substring(1); + if (!hostName.endsWith(suffix)) { + // hostName does not end with the suffix + return false; + } + + // Check that asterisk did not match across domain name labels. + int suffixStartIndexInHostName = hostName.length() - suffix.length(); + if ((suffixStartIndexInHostName > 0) + && (hostName.lastIndexOf('.', suffixStartIndexInHostName - 1) != -1)) { + // Asterisk is matching across domain name labels -- not permitted. + return false; + } + + // hostName matches pattern + return true; + } +}