Skip to content
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
3 changes: 3 additions & 0 deletions docs/guides/server/reverseproxy.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ Mitigate this risk, for example, by restricting network access.

* Allow a longer `--shutdown-delay` (for example, 10–30 seconds) to give keepalive connections time to drain during shutdown, since the proxy cannot signal a connection close at the HTTP connection level.

* Due to HTTP/2 connection coalescing, do not configure {project_name} to be the proxy's default backend when {project_name} is using a wildcard certificate and that certificate is shared among several services behind the proxy.
In this configuration {project_name} will not be able to distinguish between misdirected requests and those sent by default - when no SNI information is sent or SNI does not match any backend.

=== Backchannel access considerations

If private backchannel access to {project_name} is requested, using a proxy might be challenging:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.io.File;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import org.keycloak.common.crypto.FipsMode;

Expand Down Expand Up @@ -53,6 +54,12 @@ public enum ClientAuth {
.buildTime(true)
.build();

public static final Option<Boolean> HTTPS_SNI_ENABLED = new OptionBuilder<>("https-sni-enabled", Boolean.class)
Comment thread
shawkins marked this conversation as resolved.
.category(OptionCategory.HTTP)
.synthetic()
.defaultValue(Optional.empty())
.build();

public static final Option<String> HTTPS_CIPHER_SUITES = new OptionBuilder<>("https-cipher-suites", String.class)
.category(OptionCategory.HTTP)
.description("The cipher suites to use. If none is given, a reasonable default is selected.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
import io.quarkus.vertx.http.deployment.ManagementInterfaceFilterBuildItem;
import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem;
import io.quarkus.vertx.http.deployment.RouteBuildItem;
import io.quarkus.vertx.http.deployment.VertxWebRouterBuildItem;
import io.quarkus.vertx.http.runtime.security.SecurityHandlerPriorities;
import org.eclipse.microprofile.config.spi.ConfigSource;
import org.eclipse.microprofile.health.Readiness;
Expand Down Expand Up @@ -291,24 +292,20 @@ void configureRedirectForRootPath(BuildProducer<RouteBuildItem> routes,
);
}

@Record(ExecutionTime.STATIC_INIT)
@Record(ExecutionTime.RUNTIME_INIT)
@BuildStep
@Consume(ConfigBuildItem.class)
void filterAllRequests(BuildProducer<FilterBuildItem> filters, KeycloakRecorder recorder) {
var filter = recorder.getRejectNonNormalizedPathFilter();
if (filter != null) {
filters.produce(new FilterBuildItem(filter, SecurityHandlerPriorities.CORS + 1));
}
void filterAllRequests(VertxWebRouterBuildItem vertxWebRouterBuildItem, KeycloakRecorder recorder) {
recorder.rejectNonNormalizedPathFilter(vertxWebRouterBuildItem.getHttpRouter());

recorder.misdirectedRequestFilter(vertxWebRouterBuildItem.getHttpRouter());
}

@Record(ExecutionTime.STATIC_INIT)
@Record(ExecutionTime.RUNTIME_INIT)
@BuildStep(onlyIf = IsManagementEnabled.class)
@Consume(ConfigBuildItem.class)
void filterAllManagementRequests(BuildProducer<ManagementInterfaceFilterBuildItem> filters, KeycloakRecorder recorder) {
var filter = recorder.getRejectNonNormalizedPathFilter();
if (filter != null) {
filters.produce(new ManagementInterfaceFilterBuildItem(filter, SecurityHandlerPriorities.CORS + 1));
}
void filterAllManagementRequests(VertxWebRouterBuildItem vertxWebRouterBuildItem, BuildProducer<ManagementInterfaceFilterBuildItem> filters, KeycloakRecorder recorder) {
recorder.rejectNonNormalizedPathFilter(vertxWebRouterBuildItem.getManagementRouter());
}

@BuildStep(onlyIfNot = IsKeycloakDevMode.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@

import java.io.File;
import java.lang.annotation.Annotation;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand All @@ -38,18 +41,23 @@
import org.keycloak.common.crypto.FipsMode;
import org.keycloak.config.DatabaseOptions;
import org.keycloak.config.HealthOptions;
import org.keycloak.config.HostnameV2Options;
import org.keycloak.config.HttpAccessLogOptions;
import org.keycloak.config.HttpOptions;
import org.keycloak.config.MetricsOptions;
import org.keycloak.config.OpenApiOptions;
import org.keycloak.config.Option;
import org.keycloak.config.ProxyOptions;
import org.keycloak.config.TruststoreOptions;
import org.keycloak.marshalling.Marshalling;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
import org.keycloak.quarkus.runtime.configuration.Configuration;
import org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider;
import org.keycloak.quarkus.runtime.configuration.mappers.HttpPropertyMappers;
import org.keycloak.quarkus.runtime.integration.QuarkusKeycloakSessionFactory;
import org.keycloak.quarkus.runtime.services.MisdirectedFilter;
import org.keycloak.quarkus.runtime.services.RejectNonNormalizedPathFilter;
import org.keycloak.quarkus.runtime.storage.database.liquibase.FastServiceLocator;
import org.keycloak.representations.userprofile.config.UPConfig;
Expand All @@ -64,7 +72,9 @@
import io.quarkus.hibernate.orm.runtime.integration.HibernateOrmIntegrationRuntimeInitListener;
import io.quarkus.runtime.RuntimeValue;
import io.quarkus.runtime.annotations.Recorder;
import io.quarkus.vertx.http.runtime.security.SecurityHandlerPriorities;
import io.vertx.core.Handler;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import liquibase.Scope;
import liquibase.servicelocator.ServiceLocator;
Expand Down Expand Up @@ -126,8 +136,32 @@ String getListItem() {
}
}

public Handler<RoutingContext> getRejectNonNormalizedPathFilter() {
return !Configuration.isTrue(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS) ? new RejectNonNormalizedPathFilter() : null;
public void rejectNonNormalizedPathFilter(RuntimeValue<Router> runtimeValue) {
if (Configuration.isTrue(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS)) {
return;
}
runtimeValue.getValue().route().order(-1 * (SecurityHandlerPriorities.CORS + 1)).handler(new RejectNonNormalizedPathFilter());
}

public void misdirectedRequestFilter(RuntimeValue<Router> runtimeValue) {
// not checking for http/2 enablement - it is enabled by default and not exposed as a supported configuration option
if (!Configuration.isTrue(HttpPropertyMappers.QUARKUS_HTTPS_SNI) || !HttpPropertyMappers.isHttpsEnabled() || Configuration.getConfigValue(ProxyOptions.PROXY_HEADERS).getValue() != null) {
return;
}

Set<String> allowedHosts = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
extractHost(HostnameV2Options.HOSTNAME, allowedHosts);
extractHost(HostnameV2Options.HOSTNAME_ADMIN, allowedHosts);
runtimeValue.getValue().route().order(-1 * (SecurityHandlerPriorities.CORS + 2)).handler(new MisdirectedFilter(allowedHosts));
}

private static void extractHost(Option<String> option, Set<String> allowedHosts) {
String hostnameOrUrl = Configuration.getConfigValue(option).getValue();
if (hostnameOrUrl != null) {
allowedHosts.add((hostnameOrUrl.startsWith("http://") || hostnameOrUrl.startsWith("https://"))
? URI.create(hostnameOrUrl).getHost()
Comment thread
shawkins marked this conversation as resolved.
: hostnameOrUrl);
}
}

public void configureTruststore() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
import org.keycloak.config.ManagementOptions;
import org.keycloak.config.Option;
import org.keycloak.config.OptionsUtil;
import org.keycloak.config.ProxyOptions;
import org.keycloak.config.SecurityOptions;
import org.keycloak.quarkus.runtime.Environment;
import org.keycloak.quarkus.runtime.Messages;
import org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler;
import org.keycloak.quarkus.runtime.cli.Picocli;
import org.keycloak.quarkus.runtime.cli.PropertyException;
import org.keycloak.quarkus.runtime.cli.command.AbstractCommand;
import org.keycloak.quarkus.runtime.configuration.Configuration;

import io.quarkus.runtime.LaunchMode;
import io.quarkus.runtime.configuration.DurationConverter;
Expand All @@ -42,6 +44,7 @@ public final class HttpPropertyMappers implements PropertyMapperGrouping {
private static final String QUARKUS_HTTPS_TRUST_STORE_FILE = "quarkus.http.ssl.certificate.trust-store-file";
public static final String QUARKUS_HTTPS_TRUST_STORE_FILE_TYPE = "quarkus.http.ssl.certificate.trust-store-file-type";
private static final String QUARKUS_HTTPS_KEY_STORE_FILE_TYPE = "quarkus.http.ssl.certificate.key-store-file-type";
public static final String QUARKUS_HTTPS_SNI = "quarkus.http.ssl.sni";

// Transform runtime exceptions obtained from Quarkus to ours with a relevant message
private static void setCustomExceptionTransformer() {
Expand Down Expand Up @@ -264,6 +267,15 @@ public List<PropertyMapper<?>> getPropertyMappers() {
.to("kc.spi-connections-infinispan--default--shutdown-timeout")
.paramLabel("timeout")
.validator(HttpPropertyMappers::validateShutdownDuration)
.build(),
fromOption(HttpOptions.HTTPS_SNI_ENABLED)
.to(QUARKUS_HTTPS_SNI)
.transformer((value, context) -> {
if (value == null && isHttpsEnabled() && Configuration.getConfigValue(ProxyOptions.PROXY_HEADERS).getValue() == null) {
return "true";
}
return value;
})
.build()
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.keycloak.quarkus.runtime.services;

import java.util.Set;

import io.netty.handler.codec.http.HttpResponseStatus;
import io.vertx.core.Handler;
import io.vertx.ext.web.RoutingContext;

public class MisdirectedFilter implements Handler<RoutingContext> {

private final Set<String> allowedHosts;

public MisdirectedFilter(Set<String> allowedHosts) {
this.allowedHosts = allowedHosts;
}

@Override
public void handle(RoutingContext routingContext) {
if ("http".equals(routingContext.request().scheme())) {
routingContext.next();
return;
}
var request = routingContext.request();
var authority = request.authority();
Comment thread
vmuzikar marked this conversation as resolved.
String indicatedName = request.connection().indicatedServerName();
if (authority == null || indicatedName == null || indicatedName.equalsIgnoreCase(authority.host())
|| allowedHosts.contains(authority.host())) {
routingContext.next();
} else {
routingContext.response()
.setStatusCode(HttpResponseStatus.MISDIRECTED_REQUEST.code())
.setStatusMessage(HttpResponseStatus.MISDIRECTED_REQUEST.reasonPhrase())
.putHeader("Content-Type", "text/plain; charset=UTF-8")
.end(HttpResponseStatus.MISDIRECTED_REQUEST.reasonPhrase());
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -1117,4 +1117,14 @@ public void testMtlsEnabledMapsToRuntimeProperty() {
assertEquals("false", config.getConfigValue("kc.spi-jgroups-mtls--default--activated").getValue());
assertNull(config.getConfigValue("kc.spi-jgroups-mtls--default--enabled").getValue());
}

@Test
public void testSniEnabled() {
var config = createConfigFromCliArguments("--https-certificate-file=\\some\\file");
assertEquals("true", config.getConfigValue(HttpPropertyMappers.QUARKUS_HTTPS_SNI).getValue());

// not expected to be enabled in reencrypt
config = createConfigFromCliArguments("--https-certificate-file=\\some\\file", "--proxy-headers=forwarded");
assertNull(config.getConfigValue(HttpPropertyMappers.QUARKUS_HTTPS_SNI).getValue());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import org.keycloak.it.junit5.extension.CLIResult;
import org.keycloak.it.junit5.extension.DistributionTest;
Expand All @@ -37,6 +38,15 @@
import io.quarkus.test.junit.main.Launch;
import io.restassured.RestAssured;
import io.restassured.config.RedirectConfig;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpClientResponse;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.HttpVersion;
import io.vertx.core.http.RequestOptions;
import io.vertx.core.net.HostAndPort;
import io.vertx.core.net.SocketAddress;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -90,6 +100,63 @@ public void preventNonNormalizedURLs() {
given().urlEncodingEnabled(false)
.when().get("/realms/master;xxx").then().statusCode(400);
}

@Test
@Launch({"start-dev", "--hostname=https://example.com"})
public void misdirectedRequestDetection() throws Exception {
Vertx vertx = Vertx.vertx();
try {
HttpClient client = vertx.createHttpClient(new HttpClientOptions()
.setSsl(true)
.setTrustAll(true)
.setVerifyHost(false)
.setProtocolVersion(HttpVersion.HTTP_2)
.setUseAlpn(true));
try {
assertThat("Matching indicated to authority is allowed",
misdirectedRequest(client, "servicehost.com", "servicehost.com", 8443), Matchers.is(200));

// null sniHostname → defaults to "localhost" (non-FQDN → Java skips SNI → indicatedServerName is null)
assertThat("No indicated name is allowed",
misdirectedRequest(client, null, "example.com", 443), Matchers.is(200));

// connection originated from another backend, but we're reusing it for a request to the keycloak server
assertThat("Matching a known host is allowed",
misdirectedRequest(client, "other-example.com", "example.com", 443), Matchers.is(200));

// connection originated from keycloak, but the browser is mistakenly reusing for another service
assertThat("Expected HTTP 421 Misdirected Request for SNI/authority mismatch",
misdirectedRequest(client, "example.com", "misdirected.com", 443), Matchers.is(421));
} finally {
client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
} finally {
vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
}

private static int misdirectedRequest(HttpClient client, String sniHostname, String authorityHost, int authorityPort) throws Exception {
RequestOptions options = new RequestOptions()
.setServer(SocketAddress.inetSocketAddress(8443, "localhost"))
.setPort(8443)
.setSsl(true)
.setURI("/realms/master")
.setMethod(HttpMethod.GET);

if (sniHostname != null) {
options.setHost(sniHostname);
}

return client.request(options)
.compose(req -> {
req.authority(HostAndPort.create(authorityHost, authorityPort));
return req.send();
})
.map(HttpClientResponse::statusCode)
.toCompletionStage()
.toCompletableFuture()
.get(10, TimeUnit.SECONDS);
}

@Test
@Launch({"start-dev", "--http-access-log-enabled=true", "--http-accept-non-normalized-paths=true"})
Expand Down
Loading