SSLHostnameVerificationTest.java
/*
* 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.
*/
package org.apache.zookeeper.server;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Security;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.client.ZKClientConfig;
import org.apache.zookeeper.common.ssl.Ca;
import org.apache.zookeeper.common.ssl.Cert;
import org.apache.zookeeper.server.embedded.ExitHandler;
import org.apache.zookeeper.server.embedded.ZooKeeperServerEmbedded;
import org.apache.zookeeper.test.ClientBase;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.burningwave.tools.net.HostResolutionRequestInterceptor;
import org.burningwave.tools.net.MappedHostResolver;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
public class SSLHostnameVerificationTest {
@BeforeAll
public static void setupDNSMocks() {
Map<String, String> hostAliases = new LinkedHashMap<>();
// avoid resolving "localhost" to ipv6 address "::1"
hostAliases.put("localhost", "127.0.0.1");
HostResolutionRequestInterceptor.INSTANCE.install(new MappedHostResolver(hostAliases));
HostResolutionRequestInterceptor.INSTANCE.clearCache();
}
@AfterAll
public static void clearDNSMocks() {
HostResolutionRequestInterceptor.INSTANCE.uninstall();
}
@BeforeAll
public static void setup() {
Security.addProvider(new BouncyCastleProvider());
}
@AfterAll
public static void cleanup() {
Security.removeProvider("BC");
}
Watcher.Event.KeeperState checkConnectState(String connectString, ZKClientConfig clientConfig) throws Exception {
Duration timeout = Duration.ofSeconds(1);
Watcher.Event.KeeperState state;
CompletableFuture<WatchedEvent> future = new CompletableFuture<>();
try (ZooKeeper zk = new ZooKeeper(connectString, (int) timeout.toMillis(), future::complete, clientConfig)) {
try {
WatchedEvent event = future.get(timeout.toMillis() * 2, TimeUnit.MILLISECONDS);
state = event.getState();
} catch (TimeoutException ignored) {
// See: ZOOKEEPER-4508, ZOOKEEPER-4921, ZOOKEEPER-4923
state = Watcher.Event.KeeperState.Expired;
}
}
return state;
}
@ParameterizedTest(name = "{0}, fips-mode: {1}")
@CsvSource({
"localhost, true",
"localhost, false",
"127.0.0.1, true",
"127.0.0.1, false",
})
public void testClientHostnameVerificationWithMismatchNames(String serverHost, boolean fipsEnabled, @TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server with cert mismatching cn/dns/ip
Cert server1Cert = ca.signer("abc0").withDnsName("abc1").withIpAddress("192.168.0.10").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
config.put("secureClientPortAddress", serverHost);
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.fips-mode", Boolean.toString(fipsEnabled));
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
// when: connect using mismatched dns/ip
String connectionString = server.getSecureConnectionString();
// then: connection rejected by us as no matching name
assertEquals(Watcher.Event.KeeperState.Expired, checkConnectState(server.getSecureConnectionString(), clientConfig));
}
}
}
@Test
public void testClientHostnameVerificationWithMatchingCnName(@TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server cert with cn name "localhost"
Cert server1Cert = ca.signer("localhost").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
clientConfig.setProperty("zookeeper.fips-mode", "false");
// when: connect using matching dns
String connectionString = "localhost:" + server.getSecureClientPort();
// then: connected as there is no other sans
assertEquals(Watcher.Event.KeeperState.SyncConnected, checkConnectState(connectionString, clientConfig));
}
}
}
@Test
public void testClientHostnameVerificationWithMatchingReversedDnsName(@TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server cert with cn name "localhost"
Cert server1Cert = ca.signer("localhost").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
clientConfig.setProperty("zookeeper.fips-mode", "false");
// when: connect using matching reversed dns
String connectionString = "127.0.0.1:" + server.getSecureClientPort();
clientConfig.setProperty("zookeeper.ssl.allowReverseDnsLookup", "true");
// then: connected as there is no other sans
assertEquals(Watcher.Event.KeeperState.SyncConnected, checkConnectState(connectionString, clientConfig));
}
}
}
@Test
public void testClientHostnameVerificationWithMatchingDisabledReversedDnsName(@TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server cert with cn name "localhost"
Cert server1Cert = ca.signer("localhost").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
clientConfig.setProperty("zookeeper.fips-mode", "false");
// when: connect using matching reversed dns
String connectionString = "127.0.0.1:" + server.getSecureClientPort();
clientConfig.setProperty("zookeeper.ssl.allowReverseDnsLookup", "false");
// then: connected as there is no other sans
assertEquals(Watcher.Event.KeeperState.Expired, checkConnectState(connectionString, clientConfig));
}
}
}
@ParameterizedTest
@ValueSource(strings = {"localhost", "127.0.0.1"})
public void testClientHostnameVerificationWithMatchingCnNameButMismatchingSan(String serverHost, @TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server with cert matching cn
Cert server1Cert = ca.signer("localhost").withDnsName("abc1").withIpAddress("192.168.0.10").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.ssl.allowReverseDnsLookup", "true");
clientConfig.setProperty("zookeeper.fips-mode", "false");
// when: connect with dns or ip resolved to cn name
String connectionString = String.format("%s:%d", serverHost, server.getSecureClientPort());
// then: fail to connect
//
// CN matching has been deprecated by rfc2818 and can be used
// as fallback only when no subjectAlts are available
assertEquals(Watcher.Event.KeeperState.Expired, checkConnectState(connectionString, clientConfig));
}
}
}
@ParameterizedTest
@ValueSource(strings = {"localhost", "127.0.0.1"})
public void testClientHostnameVerificationWithMatchingIpAddress(String serverHost, @TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server with cert mismatching ip
Cert server1Cert = ca.signer("abc0").withDnsName("abc1").withIpAddress("127.0.0.1").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.fips-mode", "false");
// when: connect with matching ip or its dns
String connectionString = String.format("%s:%d", serverHost, server.getSecureClientPort());
// then: connected
assertEquals(Watcher.Event.KeeperState.SyncConnected, checkConnectState(connectionString, clientConfig));
}
}
}
@Test
public void testClientHostnameVerificationFipsModeWithIpAddress(@TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server with cert mismatching ip
Cert server1Cert = ca.signer("abc0").withDnsName("abc1").withIpAddress("127.0.0.1").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.fips-mode", "true");
// when: connect with ip's dns
String connectionString = String.format("localhost:%d", server.getSecureClientPort());
// then: rejected as fips-mode don't do dns lookup
assertEquals(Watcher.Event.KeeperState.Expired, checkConnectState(connectionString, clientConfig));
// when: connect with ip address
connectionString = String.format("127.0.0.1:%d", server.getSecureClientPort());
// then: connected
assertEquals(Watcher.Event.KeeperState.SyncConnected, checkConnectState(connectionString, clientConfig));
}
}
}
@Test
public void testClientHostnameVerificationFipsModeWithDns(@TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server cert with dns "localhost"
Cert server1Cert = ca.signer("abc0").withDnsName("localhost").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.fips-mode", "true");
// when: connect with ip address
String connectionString = String.format("127.0.0.1:%d", server.getSecureClientPort());
// then: fail as fips-mode won't do reverse dns lookup
assertEquals(Watcher.Event.KeeperState.Expired, checkConnectState(connectionString, clientConfig));
// when: connect with "localhost"
connectionString = String.format("localhost:%d", server.getSecureClientPort());
// then: succeed
assertEquals(Watcher.Event.KeeperState.SyncConnected, checkConnectState(connectionString, clientConfig));
}
}
}
@Test
public void testClientHostnameVerificationWithMatchingDnsName(@TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
// given: server cert with dns name "localhost"
Cert server1Cert = ca.signer("abc0").withDnsName("localhost").withIpAddress("192.168.0.10").sign();
Properties config = server1Cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "false");
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
Cert clientCert = ca.sign("client");
ZKClientConfig clientConfig = clientCert.buildClientConfig(ca);
clientConfig.setProperty("zookeeper.sasl.client", "false");
clientConfig.setProperty("zookeeper.fips-mode", "false");
clientConfig.setProperty("zookeeper.ssl.hostnameVerification", "true");
clientConfig.setProperty("zookeeper.ssl.allowReverseDnsLookup", "true");
// when: connect to "127.0.0.1"
String connectionString = String.format("127.0.0.1:%d", server.getSecureClientPort());
// then: connected as ZKHostnameVerifier will do reverse dns lookup
assertEquals(Watcher.Event.KeeperState.SyncConnected, checkConnectState(connectionString, clientConfig));
// when: connect to "localhost"
connectionString = String.format("localhost:%d", server.getSecureClientPort());
// then: connected as dns match
assertEquals(Watcher.Event.KeeperState.SyncConnected, checkConnectState(connectionString, clientConfig));
}
}
}
@ParameterizedTest
@ValueSource(strings = {"x509", ""})
public void testServerHostnameVerification(String authProvider, @TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
Cert cert = ca.sign("server");
// given: server with client hostname verification enabled
Properties config = cert.buildServerProperties(ca);
config.put("ssl.hostnameVerification", "true");
config.put("ssl.clientHostnameVerification", "true");
config.put("ssl.allowReverseDnsLookup", "false");
config.put("fips-mode", "false");
if (!authProvider.isEmpty()) {
config.put("ssl.authProvider", "x509");
}
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
// // when: connect with matching dns name
Cert client1Cert = ca.signer("client1").withDnsName("localhost").sign();
ZKClientConfig client1Config = client1Cert.buildClientConfig(ca);
client1Config.setProperty("zookeeper.ssl.hostnameVerification", "false");
// then: connected
assertTrue(ClientBase.waitForServerUp(server.getSecureConnectionString(), 6000, true, client1Config));
// when: connect with matching ip address
Cert client2Cert = ca.signer("client2").withIpAddress("127.0.0.1").sign();
ZKClientConfig client2Config = client2Cert.buildClientConfig(ca);
client2Config.setProperty("zookeeper.ssl.hostnameVerification", "false");
// then: connected
assertTrue(ClientBase.waitForServerUp(server.getSecureConnectionString(), 6000, true, client2Config));
// when: connect with matching cn name
Cert client3Cert = ca.signer("localhost").sign();
ZKClientConfig client3Config = client3Cert.buildClientConfig(ca);
client3Config.setProperty("zookeeper.ssl.hostnameVerification", "false");
// then: connected
assertTrue(ClientBase.waitForServerUp(server.getSecureConnectionString(), 6000, true, client3Config));
// when: connect with mismatching cert name
Cert client4Cert = ca.signer("client4").withDnsName("abc").sign();
ZKClientConfig client4Config = client4Cert.buildClientConfig(ca);
client4Config.setProperty("zookeeper.ssl.hostnameVerification", "false");
// then: fail to connect
assertFalse(ClientBase.waitForServerUp(server.getSecureConnectionString(), 6000, true, client4Config));
}
}
}
/**
* FIPS mode disallow custom trust manager so server has no way to validate against client's endpoint.
*/
@ParameterizedTest
@ValueSource(strings = {"x509", ""})
public void testServerHostnameVerificationFipsMode(String authProvider, @TempDir Path tmpDir) throws Exception {
try (Ca ca = Ca.create(tmpDir)) {
Cert cert = ca.sign("server");
Properties config = cert.buildServerProperties(ca);
// given: server in fips mode with client hostname verification enabled
config.put("ssl.hostnameVerification", "true");
config.put("ssl.clientHostnameVerification", "true");
config.put("fips-mode", "true");
if (!authProvider.isEmpty()) {
config.put("ssl.authProvider", "x509");
}
try (ZooKeeperServerEmbedded server = ZooKeeperServerEmbedded
.builder()
.baseDir(Files.createTempDirectory(tmpDir, "server.data"))
.configuration(config)
.exitHandler(ExitHandler.LOG_ONLY)
.build()) {
server.start();
// server ready
assertTrue(ClientBase.waitForServerUp(server.getConnectionString(), 60000));
// // when: connect with matching dns name
Cert client1Cert = ca.signer("localhost").withResolvedDns("localhost").sign();
ZKClientConfig client1Config = client1Cert.buildClientConfig(ca);
client1Config.setProperty("zookeeper.ssl.hostnameVerification", "false");
// then: fail to connect
assertFalse(ClientBase.waitForServerUp(server.getSecureConnectionString(), 6000, true, client1Config));
}
}
}
}