From eb1d74d0af955b511d96d69d5e31a2e5567efbe0 Mon Sep 17 00:00:00 2001 From: Mark Terry Date: Tue, 19 Feb 2019 15:38:47 +1000 Subject: [PATCH] [PAN-2287] Added rebind mitigation for websockets. --- .../PantheonFactoryConfigurationBuilder.java | 2 + .../websocket/WebSocketConfiguration.java | 10 + .../jsonrpc/websocket/WebSocketService.java | 56 ++++++ .../websocket/WebSocketHostWhitelistTest.java | 187 ++++++++++++++++++ .../websocket/WebSocketServiceLoginTest.java | 2 + .../websocket/WebSocketServiceTest.java | 2 + .../pegasys/pantheon/cli/PantheonCommand.java | 1 + .../tech/pegasys/pantheon/RunnerTest.java | 1 + 8 files changed, 261 insertions(+) create mode 100644 ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketHostWhitelistTest.java diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/factory/PantheonFactoryConfigurationBuilder.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/factory/PantheonFactoryConfigurationBuilder.java index 0cc2d55854..071a6e2d5b 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/factory/PantheonFactoryConfigurationBuilder.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/factory/PantheonFactoryConfigurationBuilder.java @@ -24,6 +24,7 @@ import java.net.URISyntaxException; import java.nio.file.Paths; +import java.util.Collections; import java.util.Optional; public class PantheonFactoryConfigurationBuilder { @@ -99,6 +100,7 @@ public PantheonFactoryConfigurationBuilder webSocketEnabled() { final WebSocketConfiguration config = WebSocketConfiguration.createDefault(); config.setEnabled(true); config.setPort(0); + config.setHostsWhitelist(Collections.singleton("*")); this.webSocketConfiguration = config; return this; diff --git a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketConfiguration.java b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketConfiguration.java index 7c2795ca1b..a8afb9b572 100644 --- a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketConfiguration.java +++ b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketConfiguration.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; @@ -37,6 +38,7 @@ public class WebSocketConfiguration { private long refreshDelay; private boolean authenticationEnabled = false; private String authenticationCredentialsFile; + private Collection hostsWhitelist = Collections.singletonList("localhost"); public static WebSocketConfiguration createDefault() { final WebSocketConfiguration config = new WebSocketConfiguration(); @@ -142,4 +144,12 @@ public void setAuthenticationCredentialsFile(final String authenticationCredenti public String getAuthenticationCredentialsFile() { return authenticationCredentialsFile; } + + public void setHostsWhitelist(final Collection hostsWhitelist) { + this.hostsWhitelist = hostsWhitelist; + } + + public Collection getHostsWhitelist() { + return Collections.unmodifiableCollection(this.hostsWhitelist); + } } diff --git a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketService.java b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketService.java index 62ad3e17af..8ebf1cb66a 100644 --- a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketService.java +++ b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketService.java @@ -12,6 +12,8 @@ */ package tech.pegasys.pantheon.ethereum.jsonrpc.websocket; +import static com.google.common.collect.Streams.stream; + import tech.pegasys.pantheon.ethereum.jsonrpc.authentication.AuthenticationService; import tech.pegasys.pantheon.ethereum.jsonrpc.authentication.AuthenticationUtils; import tech.pegasys.pantheon.ethereum.jsonrpc.websocket.subscription.SubscriptionManager; @@ -21,6 +23,8 @@ import java.util.concurrent.CompletableFuture; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; import io.vertx.core.Vertx; @@ -30,6 +34,7 @@ import io.vertx.core.http.ServerWebSocket; import io.vertx.core.net.SocketAddress; import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -100,6 +105,10 @@ private Handler websocketHandler() { LOG.trace("Websocket authentication token {}", token); } + if (!hasWhitelistedHostnameHeader(Optional.ofNullable(websocket.headers().get("Host")))) { + websocket.reject(403); + } + LOG.debug("Websocket Connected ({})", socketAddressAsString(socketAddress)); websocket.handler( @@ -138,6 +147,10 @@ private Handler websocketHandler() { private Handler httpHandler() { final Router router = Router.router(vertx); + + // Verify Host header to avoid rebind attack. + router.route().handler(checkWhitelistHostHeader()); + if (authenticationService.isPresent()) { router.route("/login").handler(BodyHandler.create()); router @@ -212,4 +225,47 @@ private String getAuthToken(final ServerWebSocket websocket) { return AuthenticationUtils.getJwtTokenFromAuthorizationHeaderValue( websocket.headers().get("Authorization")); } + + private Handler checkWhitelistHostHeader() { + return event -> { + if (hasWhitelistedHostnameHeader(Optional.ofNullable(event.request().host()))) { + event.next(); + } else { + event + .response() + .setStatusCode(403) + .putHeader("Content-Type", "application/json; charset=utf-8") + .end("{\"message\":\"Host not authorized.\"}"); + } + }; + } + + @VisibleForTesting + public boolean hasWhitelistedHostnameHeader(final Optional header) { + return configuration.getHostsWhitelist().contains("*") + || header.map(value -> checkHostInWhitelist(validateHostHeader(value))).orElse(false); + } + + private Optional validateHostHeader(final String header) { + final Iterable splitHostHeader = Splitter.on(':').split(header); + final long hostPieces = stream(splitHostHeader).count(); + if (hostPieces > 1) { + // If the host contains a colon, verify the host is correctly formed - host [ ":" port ] + if (hostPieces > 2 || !Iterables.get(splitHostHeader, 1).matches("\\d{1,5}+")) { + return Optional.empty(); + } + } + return Optional.ofNullable(Iterables.get(splitHostHeader, 0)); + } + + private boolean checkHostInWhitelist(final Optional hostHeader) { + return hostHeader + .map( + header -> + configuration.getHostsWhitelist().stream() + .anyMatch( + whitelistEntry -> + whitelistEntry.toLowerCase().equals(header.toLowerCase()))) + .orElse(false); + } } diff --git a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketHostWhitelistTest.java b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketHostWhitelistTest.java new file mode 100644 index 0000000000..8070e9f117 --- /dev/null +++ b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketHostWhitelistTest.java @@ -0,0 +1,187 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed 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 tech.pegasys.pantheon.ethereum.jsonrpc.websocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; + +import tech.pegasys.pantheon.ethereum.jsonrpc.internal.methods.JsonRpcMethod; +import tech.pegasys.pantheon.ethereum.jsonrpc.websocket.methods.WebSocketMethodsFactory; +import tech.pegasys.pantheon.ethereum.jsonrpc.websocket.subscription.SubscriptionManager; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; + +@RunWith(VertxUnitRunner.class) +public class WebSocketHostWhitelistTest { + + @ClassRule public static final TemporaryFolder folder = new TemporaryFolder(); + + protected static Vertx vertx; + + private final List hostsWhitelist = Arrays.asList("ally", "friend"); + + private final WebSocketConfiguration webSocketConfiguration = + WebSocketConfiguration.createDefault(); + private static WebSocketRequestHandler webSocketRequestHandlerSpy; + private WebSocketService websocketService; + private HttpClient httpClient; + private static final int VERTX_AWAIT_TIMEOUT_MILLIS = 10000; + + @Before + public void initServerAndClient() { + vertx = Vertx.vertx(); + + final Map websocketMethods = + new WebSocketMethodsFactory(new SubscriptionManager(), new HashMap<>()).methods(); + webSocketRequestHandlerSpy = spy(new WebSocketRequestHandler(vertx, websocketMethods)); + + websocketService = + new WebSocketService(vertx, webSocketConfiguration, webSocketRequestHandlerSpy); + websocketService.start().join(); + + final HttpClientOptions httpClientOptions = + new HttpClientOptions() + .setDefaultHost(webSocketConfiguration.getHost()) + .setDefaultPort(webSocketConfiguration.getPort()); + + httpClient = vertx.createHttpClient(httpClientOptions); + } + + @After + public void after() { + reset(webSocketRequestHandlerSpy); + websocketService.stop(); + } + + @Test + public void websocketRequestWithDefaultHeaderAndDefaultConfigIsAccepted() { + boolean result = websocketService.hasWhitelistedHostnameHeader(Optional.of("localhost:50012")); + assertThat(result).isTrue(); + } + + @Test + public void httpRequestWithDefaultHeaderAndDefaultConfigIsAccepted(final TestContext context) { + doHttpRequestAndVerify(context, "localhost:50012", 400); + } + + @Test + public void websocketRequestWithEmptyHeaderAndDefaultConfigIsRejected() { + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of(""))).isFalse(); + } + + @Test + public void httpRequestWithEmptyHeaderAndDefaultConfigIsRejected(final TestContext context) { + doHttpRequestAndVerify(context, "", 403); + } + + @Test + public void websocketRequestWithAnyHostnameAndWildcardConfigIsAccepted() { + webSocketConfiguration.setHostsWhitelist(Collections.singletonList("*")); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("ally"))).isTrue(); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("foe"))).isTrue(); + } + + @Test + public void httpRequestWithAnyHostnameAndWildcardConfigIsAccepted(final TestContext context) { + webSocketConfiguration.setHostsWhitelist(Collections.singletonList("*")); + doHttpRequestAndVerify(context, "ally", 400); + doHttpRequestAndVerify(context, "foe", 400); + } + + @Test + public void websocketRequestWithWhitelistedHostIsAccepted() { + webSocketConfiguration.setHostsWhitelist(hostsWhitelist); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("ally"))).isTrue(); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("ally:12345"))).isTrue(); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("friend"))).isTrue(); + } + + @Test + public void httpRequestWithWhitelistedHostIsAccepted(final TestContext context) { + webSocketConfiguration.setHostsWhitelist(hostsWhitelist); + doHttpRequestAndVerify(context, "ally", 400); + doHttpRequestAndVerify(context, "ally:12345", 400); + doHttpRequestAndVerify(context, "friend", 400); + } + + @Test + public void websocketRequestWithUnknownHostIsRejected() { + webSocketConfiguration.setHostsWhitelist(hostsWhitelist); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("foe"))).isFalse(); + } + + @Test + public void httpRequestWithUnknownHostIsRejected(final TestContext context) { + webSocketConfiguration.setHostsWhitelist(hostsWhitelist); + doHttpRequestAndVerify(context, "foe", 403); + } + + @Test + public void websocketRequestWithMalformedHostIsRejected() { + webSocketConfiguration.setAuthenticationEnabled(false); + webSocketConfiguration.setHostsWhitelist(hostsWhitelist); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("ally:friend"))).isFalse(); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("ally:123456"))).isFalse(); + assertThat(websocketService.hasWhitelistedHostnameHeader(Optional.of("ally:friend:1234"))) + .isFalse(); + } + + @Test + public void httpRequestWithMalformedHostIsRejected(final TestContext context) { + webSocketConfiguration.setAuthenticationEnabled(false); + webSocketConfiguration.setHostsWhitelist(hostsWhitelist); + doHttpRequestAndVerify(context, "ally:friend", 403); + doHttpRequestAndVerify(context, "ally:123456", 403); + doHttpRequestAndVerify(context, "ally:friend:1234", 403); + } + + private void doHttpRequestAndVerify( + final TestContext context, final String hostname, final int expectedResponse) { + final Async async = context.async(); + + final HttpClientRequest request = + httpClient.post( + webSocketConfiguration.getPort(), + webSocketConfiguration.getHost(), + "/", + response -> { + assertThat(response.statusCode()).isEqualTo(expectedResponse); + async.complete(); + }); + + request.putHeader("Host", hostname); + request.end(); + + async.awaitSuccess(VERTX_AWAIT_TIMEOUT_MILLIS); + } +} diff --git a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketServiceLoginTest.java b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketServiceLoginTest.java index f1d7ca31bc..2641132fbd 100644 --- a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketServiceLoginTest.java +++ b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketServiceLoginTest.java @@ -22,6 +22,7 @@ import java.net.URISyntaxException; import java.nio.file.Paths; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -70,6 +71,7 @@ public void before() throws URISyntaxException { websocketConfiguration.setPort(0); websocketConfiguration.setAuthenticationEnabled(true); websocketConfiguration.setAuthenticationCredentialsFile(authTomlPath); + websocketConfiguration.setHostsWhitelist(Collections.singleton("*")); final Map websocketMethods = new WebSocketMethodsFactory(new SubscriptionManager(), new HashMap<>()).methods(); diff --git a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketServiceTest.java b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketServiceTest.java index b316e89f14..8438630e11 100644 --- a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketServiceTest.java +++ b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/websocket/WebSocketServiceTest.java @@ -21,6 +21,7 @@ import tech.pegasys.pantheon.ethereum.jsonrpc.websocket.subscription.SubscriptionManager; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -56,6 +57,7 @@ public void before() { websocketConfiguration = WebSocketConfiguration.createDefault(); websocketConfiguration.setPort(0); + websocketConfiguration.setHostsWhitelist(Collections.singleton("*")); final Map websocketMethods = new WebSocketMethodsFactory(new SubscriptionManager(), new HashMap<>()).methods(); diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java index 760d55473d..4aff0dfad7 100644 --- a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java +++ b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java @@ -688,6 +688,7 @@ private WebSocketConfiguration webSocketConfiguration() { webSocketConfiguration.setRefreshDelay(rpcWsRefreshDelay); webSocketConfiguration.setAuthenticationEnabled(isRpcWsAuthenticationEnabled); webSocketConfiguration.setAuthenticationCredentialsFile(rpcWsAuthenticationCredentialsFile()); + webSocketConfiguration.setHostsWhitelist(hostsWhitelist); return webSocketConfiguration; } diff --git a/pantheon/src/test/java/tech/pegasys/pantheon/RunnerTest.java b/pantheon/src/test/java/tech/pegasys/pantheon/RunnerTest.java index 7f03c4926c..01c21bb65c 100644 --- a/pantheon/src/test/java/tech/pegasys/pantheon/RunnerTest.java +++ b/pantheon/src/test/java/tech/pegasys/pantheon/RunnerTest.java @@ -276,6 +276,7 @@ private WebSocketConfiguration wsRpcConfiguration() { final WebSocketConfiguration configuration = WebSocketConfiguration.createDefault(); configuration.setPort(0); configuration.setEnabled(true); + configuration.setHostsWhitelist(Collections.singletonList("*")); return configuration; }