Skip to content
This repository has been archived by the owner on Sep 26, 2019. It is now read-only.

[PAN-2287] Added rebind mitigation for websockets. #905

Merged
merged 3 commits into from
Feb 19, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import java.net.URISyntaxException;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.Optional;

public class PantheonFactoryConfigurationBuilder {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,6 +38,7 @@ public class WebSocketConfiguration {
private long refreshDelay;
private boolean authenticationEnabled = false;
private String authenticationCredentialsFile;
private Collection<String> hostsWhitelist = Collections.singletonList("localhost");

public static WebSocketConfiguration createDefault() {
final WebSocketConfiguration config = new WebSocketConfiguration();
Expand Down Expand Up @@ -142,4 +144,12 @@ public void setAuthenticationCredentialsFile(final String authenticationCredenti
public String getAuthenticationCredentialsFile() {
return authenticationCredentialsFile;
}

public void setHostsWhitelist(final Collection<String> hostsWhitelist) {
this.hostsWhitelist = hostsWhitelist;
}

public Collection<String> getHostsWhitelist() {
return Collections.unmodifiableCollection(this.hostsWhitelist);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -100,6 +105,10 @@ private Handler<ServerWebSocket> 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(
Expand Down Expand Up @@ -138,6 +147,10 @@ private Handler<ServerWebSocket> websocketHandler() {

private Handler<HttpServerRequest> 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
Expand Down Expand Up @@ -212,4 +225,47 @@ private String getAuthToken(final ServerWebSocket websocket) {
return AuthenticationUtils.getJwtTokenFromAuthorizationHeaderValue(
websocket.headers().get("Authorization"));
}

private Handler<RoutingContext> 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<String> header) {
return configuration.getHostsWhitelist().contains("*")
|| header.map(value -> checkHostInWhitelist(validateHostHeader(value))).orElse(false);
}

private Optional<String> validateHostHeader(final String header) {
final Iterable<String> 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<String> hostHeader) {
return hostHeader
.map(
header ->
configuration.getHostsWhitelist().stream()
.anyMatch(
whitelistEntry ->
whitelistEntry.toLowerCase().equals(header.toLowerCase())))
.orElse(false);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<String, JsonRpcMethod> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -70,6 +71,7 @@ public void before() throws URISyntaxException {
websocketConfiguration.setPort(0);
websocketConfiguration.setAuthenticationEnabled(true);
websocketConfiguration.setAuthenticationCredentialsFile(authTomlPath);
websocketConfiguration.setHostsWhitelist(Collections.singleton("*"));

final Map<String, JsonRpcMethod> websocketMethods =
new WebSocketMethodsFactory(new SubscriptionManager(), new HashMap<>()).methods();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -56,6 +57,7 @@ public void before() {

websocketConfiguration = WebSocketConfiguration.createDefault();
websocketConfiguration.setPort(0);
websocketConfiguration.setHostsWhitelist(Collections.singleton("*"));

final Map<String, JsonRpcMethod> websocketMethods =
new WebSocketMethodsFactory(new SubscriptionManager(), new HashMap<>()).methods();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@ private WebSocketConfiguration webSocketConfiguration() {
webSocketConfiguration.setRefreshDelay(rpcWsRefreshDelay);
webSocketConfiguration.setAuthenticationEnabled(isRpcWsAuthenticationEnabled);
webSocketConfiguration.setAuthenticationCredentialsFile(rpcWsAuthenticationCredentialsFile());
webSocketConfiguration.setHostsWhitelist(hostsWhitelist);
return webSocketConfiguration;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ private WebSocketConfiguration wsRpcConfiguration() {
final WebSocketConfiguration configuration = WebSocketConfiguration.createDefault();
configuration.setPort(0);
configuration.setEnabled(true);
configuration.setHostsWhitelist(Collections.singletonList("*"));
return configuration;
}

Expand Down