From 26b3585886ddf6d2cad620378f10d13ba843ff0a Mon Sep 17 00:00:00 2001 From: Steve Hawkins Date: Thu, 2 Jul 2026 14:47:57 -0400 Subject: [PATCH 1/4] fix: enables sni for tls passthrough to determine misdirected requests closes: #50602 Signed-off-by: Steve Hawkins --- .../java/org/keycloak/config/HttpOptions.java | 7 ++++ .../keycloak/config/ManagementOptions.java | 7 ++++ .../quarkus/deployment/KeycloakProcessor.java | 5 +++ .../quarkus/runtime/KeycloakRecorder.java | 29 +++++++++++++- .../mappers/HttpPropertyMappers.java | 12 ++++++ .../mappers/ManagementPropertyMappers.java | 5 +++ .../runtime/services/MisdirectedFilter.java | 39 +++++++++++++++++++ 7 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java index 7585b695f785..8397501ea812 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java @@ -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; @@ -53,6 +54,12 @@ public enum ClientAuth { .buildTime(true) .build(); + public static final Option HTTPS_SNI_ENABLED = new OptionBuilder<>("https-sni-enabled", Boolean.class) + .category(OptionCategory.HTTP) + .description("Enables SNI for HTTPS") + .defaultValue(Optional.empty()) + .build(); + public static final Option 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.") diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/ManagementOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/ManagementOptions.java index 8099208afe49..b9f14fd063b1 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/ManagementOptions.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/ManagementOptions.java @@ -18,6 +18,7 @@ import java.io.File; import java.util.List; +import java.util.Optional; import static org.keycloak.config.OptionsUtil.DURATION_DESCRIPTION; @@ -80,6 +81,12 @@ public enum Scheme { .defaultValue(Scheme.inherited) .build(); + public static final Option HTTPS_MANAGEMENT_SNI_ENABLED = new OptionBuilder<>("https-sni-enabled", Boolean.class) + .category(OptionCategory.HTTP) + .description("Enables SNI for HTTPS for the management interface. If not given, the value is inherited from HTTP options. " + RELEVANT_MSG) + .defaultValue(Optional.empty()) + .build(); + public static final Option HTTPS_MANAGEMENT_CLIENT_AUTH = new OptionBuilder<>("https-management-client-auth", HttpOptions.ClientAuth.class) .category(OptionCategory.MANAGEMENT) .description("Configures the management interface to require/request client authentication. If not given, the value is inherited from HTTP options. " + RELEVANT_MSG) diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java index d96034d5d57f..0689ab2b33b4 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java @@ -299,6 +299,11 @@ void filterAllRequests(BuildProducer filters, KeycloakRecorder if (filter != null) { filters.produce(new FilterBuildItem(filter, SecurityHandlerPriorities.CORS + 1)); } + + var misdirectedRequestFilter = recorder.getMisdirectedRequestFilter(); + if (misdirectedRequestFilter != null) { + filters.produce(new FilterBuildItem(misdirectedRequestFilter, SecurityHandlerPriorities.CORS + 2)); + } } @Record(ExecutionTime.STATIC_INIT) diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java index 700347d6abb6..1c084f204ad0 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java @@ -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; @@ -38,10 +41,13 @@ 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; @@ -49,7 +55,9 @@ 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; @@ -129,7 +137,26 @@ String getListItem() { public Handler getRejectNonNormalizedPathFilter() { return !Configuration.isTrue(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS) ? new RejectNonNormalizedPathFilter() : null; } - + + public Handler getMisdirectedRequestFilter() { + // 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 null; + } + + String adminHostnameOrUrl = Configuration.getConfigValue(HostnameV2Options.HOSTNAME_ADMIN).getValue(); + String adminHostname = null; + if (adminHostnameOrUrl != null) { + if (adminHostnameOrUrl.startsWith("http://") || adminHostnameOrUrl.startsWith("https://")) { + adminHostname = URI.create(adminHostnameOrUrl).getHost(); + } else { + adminHostname = adminHostnameOrUrl; + } + } + boolean isStrict = Configuration.getConfigValue(HostnameV2Options.HOSTNAME).getValue() != null; + return new MisdirectedFilter(isStrict, adminHostname); + } + public void configureTruststore() { List truststores = new ArrayList<>(); Configuration.getOptionalKcValue(TruststoreOptions.TRUSTSTORE_PATHS.getKey()) diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/HttpPropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/HttpPropertyMappers.java index 2ec61fe39fcd..bb48be7380a0 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/HttpPropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/HttpPropertyMappers.java @@ -14,6 +14,7 @@ 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; @@ -21,6 +22,7 @@ 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; @@ -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() { @@ -264,6 +267,15 @@ public List> 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() ); diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java index 14ecafafbc80..d93b275022c9 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java @@ -141,6 +141,11 @@ public List> getPropertyMappers() { .mapFrom(HttpOptions.HTTPS_TRUST_STORE_TYPE) .to(QUARKUS_MANAGEMENT_HTTPS_TRUST_STORE_FILE_TYPE) .paramLabel("type") + .build(), + fromOption(ManagementOptions.HTTPS_MANAGEMENT_SNI_ENABLED) + .isEnabled(ManagementPropertyMappers::isInheritedScheme, HTTP_MANAGEMENT_SCHEME_IS_INHERITED) + .mapFrom(HttpOptions.HTTPS_SNI_ENABLED) + .to("quarkus.management.ssl.sni") .build() ); } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java new file mode 100644 index 000000000000..cb4f9e8d79df --- /dev/null +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java @@ -0,0 +1,39 @@ +package org.keycloak.quarkus.runtime.services; + +import org.keycloak.models.KeycloakSession; + +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; +import jakarta.inject.Inject; +import jakarta.ws.rs.WebApplicationException; + +public class MisdirectedFilter implements Handler { + + @Inject + KeycloakSession session; + private final boolean hostnameStrict; + private final String adminHost; + + public MisdirectedFilter(boolean hostnameStrict, String adminHost) { + this.hostnameStrict = hostnameStrict; + this.adminHost = adminHost; + } + + @Override + public void handle(RoutingContext routingContext) { + if ("http".equals(routingContext.request().scheme())) { + return; + } + var request = routingContext.request(); + var authority = request.authority(); + String indicatedName = request.connection().indicatedServerName(); + if (authority == null || indicatedName == null || indicatedName.equalsIgnoreCase(authority.host())) { + return; + } + if (!hostnameStrict || !(authority.host().equalsIgnoreCase(adminHost) + || authority.host().equalsIgnoreCase(session.getContext().getUri().getBaseUri().getHost()))) { + throw new WebApplicationException(421); + } + } + +} From fa7602155f2f9749ad110446521e978e3ac0bc76 Mon Sep 17 00:00:00 2001 From: Steve Hawkins Date: Sun, 5 Jul 2026 09:35:58 -0400 Subject: [PATCH 2/4] correcting to not use the session Signed-off-by: Steve Hawkins --- .../quarkus/runtime/KeycloakRecorder.java | 24 +++++++++-------- .../runtime/services/MisdirectedFilter.java | 26 +++++++------------ 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java index 1c084f204ad0..281dd05134f0 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java @@ -144,19 +144,21 @@ public Handler getMisdirectedRequestFilter() { return null; } - String adminHostnameOrUrl = Configuration.getConfigValue(HostnameV2Options.HOSTNAME_ADMIN).getValue(); - String adminHostname = null; - if (adminHostnameOrUrl != null) { - if (adminHostnameOrUrl.startsWith("http://") || adminHostnameOrUrl.startsWith("https://")) { - adminHostname = URI.create(adminHostnameOrUrl).getHost(); - } else { - adminHostname = adminHostnameOrUrl; - } - } - boolean isStrict = Configuration.getConfigValue(HostnameV2Options.HOSTNAME).getValue() != null; - return new MisdirectedFilter(isStrict, adminHostname); + Set allowedHosts = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + extractHost(HostnameV2Options.HOSTNAME, allowedHosts); + extractHost(HostnameV2Options.HOSTNAME_ADMIN, allowedHosts); + return new MisdirectedFilter(allowedHosts); } + private static void extractHost(Option option, Set allowedHosts) { + String hostnameOrUrl = Configuration.getConfigValue(option).getValue(); + if (hostnameOrUrl != null) { + allowedHosts.add((hostnameOrUrl.startsWith("http://") || hostnameOrUrl.startsWith("https://")) + ? URI.create(hostnameOrUrl).getHost() + : hostnameOrUrl); + } + } + public void configureTruststore() { List truststores = new ArrayList<>(); Configuration.getOptionalKcValue(TruststoreOptions.TRUSTSTORE_PATHS.getKey()) diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java index cb4f9e8d79df..46235ecc44ee 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java @@ -1,38 +1,32 @@ package org.keycloak.quarkus.runtime.services; -import org.keycloak.models.KeycloakSession; +import java.util.Set; import io.vertx.core.Handler; import io.vertx.ext.web.RoutingContext; -import jakarta.inject.Inject; -import jakarta.ws.rs.WebApplicationException; public class MisdirectedFilter implements Handler { - @Inject - KeycloakSession session; - private final boolean hostnameStrict; - private final String adminHost; + private final Set allowedHosts; - public MisdirectedFilter(boolean hostnameStrict, String adminHost) { - this.hostnameStrict = hostnameStrict; - this.adminHost = adminHost; + public MisdirectedFilter(Set 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(); String indicatedName = request.connection().indicatedServerName(); - if (authority == null || indicatedName == null || indicatedName.equalsIgnoreCase(authority.host())) { - return; - } - if (!hostnameStrict || !(authority.host().equalsIgnoreCase(adminHost) - || authority.host().equalsIgnoreCase(session.getContext().getUri().getBaseUri().getHost()))) { - throw new WebApplicationException(421); + if (authority == null || indicatedName == null || indicatedName.equalsIgnoreCase(authority.host()) + || allowedHosts.contains(authority.host())) { + routingContext.next(); + } else { + routingContext.response().setStatusCode(421).end(); } } From 1d7d678cde1db03e8778701277eb8ed5cd44d720 Mon Sep 17 00:00:00 2001 From: Steve Hawkins Date: Wed, 15 Jul 2026 13:18:19 -0400 Subject: [PATCH 3/4] addressing review comments and adding tests Signed-off-by: Steve Hawkins --- .../java/org/keycloak/config/HttpOptions.java | 2 +- .../keycloak/config/ManagementOptions.java | 7 - .../mappers/ManagementPropertyMappers.java | 5 - .../configuration/ConfigurationTest.java | 10 + .../keycloak/it/cli/dist/HttpDistTest.java | 243 ++++++++++++++++++ 5 files changed, 254 insertions(+), 13 deletions(-) diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java index 8397501ea812..6a94f41a6cd1 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java @@ -56,7 +56,7 @@ public enum ClientAuth { public static final Option HTTPS_SNI_ENABLED = new OptionBuilder<>("https-sni-enabled", Boolean.class) .category(OptionCategory.HTTP) - .description("Enables SNI for HTTPS") + .synthetic() .defaultValue(Optional.empty()) .build(); diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/ManagementOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/ManagementOptions.java index b9f14fd063b1..8099208afe49 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/ManagementOptions.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/ManagementOptions.java @@ -18,7 +18,6 @@ import java.io.File; import java.util.List; -import java.util.Optional; import static org.keycloak.config.OptionsUtil.DURATION_DESCRIPTION; @@ -81,12 +80,6 @@ public enum Scheme { .defaultValue(Scheme.inherited) .build(); - public static final Option HTTPS_MANAGEMENT_SNI_ENABLED = new OptionBuilder<>("https-sni-enabled", Boolean.class) - .category(OptionCategory.HTTP) - .description("Enables SNI for HTTPS for the management interface. If not given, the value is inherited from HTTP options. " + RELEVANT_MSG) - .defaultValue(Optional.empty()) - .build(); - public static final Option HTTPS_MANAGEMENT_CLIENT_AUTH = new OptionBuilder<>("https-management-client-auth", HttpOptions.ClientAuth.class) .category(OptionCategory.MANAGEMENT) .description("Configures the management interface to require/request client authentication. If not given, the value is inherited from HTTP options. " + RELEVANT_MSG) diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java index d93b275022c9..14ecafafbc80 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java @@ -141,11 +141,6 @@ public List> getPropertyMappers() { .mapFrom(HttpOptions.HTTPS_TRUST_STORE_TYPE) .to(QUARKUS_MANAGEMENT_HTTPS_TRUST_STORE_FILE_TYPE) .paramLabel("type") - .build(), - fromOption(ManagementOptions.HTTPS_MANAGEMENT_SNI_ENABLED) - .isEnabled(ManagementPropertyMappers::isInheritedScheme, HTTP_MANAGEMENT_SCHEME_IS_INHERITED) - .mapFrom(HttpOptions.HTTPS_SNI_ENABLED) - .to("quarkus.management.ssl.sni") .build() ); } diff --git a/quarkus/runtime/src/test/java/org/keycloak/quarkus/runtime/configuration/ConfigurationTest.java b/quarkus/runtime/src/test/java/org/keycloak/quarkus/runtime/configuration/ConfigurationTest.java index 3c10bb9e88d9..b6eda56ac070 100644 --- a/quarkus/runtime/src/test/java/org/keycloak/quarkus/runtime/configuration/ConfigurationTest.java +++ b/quarkus/runtime/src/test/java/org/keycloak/quarkus/runtime/configuration/ConfigurationTest.java @@ -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()); + } } diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HttpDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HttpDistTest.java index 5bd4f5808a5c..583940c62038 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HttpDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HttpDistTest.java @@ -17,12 +17,15 @@ package org.keycloak.it.cli.dist; +import java.io.IOException; +import java.net.SocketException; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import javax.net.ssl.TrustManager; import org.keycloak.it.junit5.extension.CLIResult; import org.keycloak.it.junit5.extension.DistributionTest; @@ -47,6 +50,7 @@ import static org.hamcrest.CoreMatchers.hasItem; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; /** * @author Vaclav Muzikar @@ -90,6 +94,245 @@ 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 { + // Build a trust-everything SSLContext so the self-signed dev cert is accepted + TrustManager[] trustAll = new TrustManager[]{ + new javax.net.ssl.X509TrustManager() { + public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {} + public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {} + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + } + }; + javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS"); + sslContext.init(null, trustAll, null); + + javax.net.ssl.SSLSocketFactory factory = sslContext.getSocketFactory(); + + String status = http2Request(factory, "servicehost.com", "servicehost.com:8443"); + assertThat("Matching indicated to authority is allowed", status, Matchers.is("200")); + + status = http2Request(factory, null, "example.com:433"); + assertThat("No indicated name is allowed", status, Matchers.is("200")); + + // connection originated from another backend, but we're reusing it for a request to the keycloak server + status = http2Request(factory, "other-example.com", "example.com:433"); + assertThat("Matching a known host is allowed", status, Matchers.is("200")); + + // connection originated from keycloak, but the browser is mistakenly reusing for another service + status = http2Request(factory, "example.com", "misdirected.com:433"); + assertThat("Expected HTTP 421 Misdirected Request for SNI/authority mismatch", + status, Matchers.is("421")); + } + + private static String http2Request(javax.net.ssl.SSLSocketFactory factory, String indicatedHostname, String authority) throws IOException, SocketException { + try (javax.net.ssl.SSLSocket socket = + (javax.net.ssl.SSLSocket) factory.createSocket()) { + + javax.net.ssl.SSLParameters params = socket.getSSLParameters(); + if (indicatedHostname != null) { + params.setServerNames(List.of(new javax.net.ssl.SNIHostName(indicatedHostname))); + } + params.setApplicationProtocols(new String[]{"h2"}); + socket.setSSLParameters(params); + + socket.connect(new java.net.InetSocketAddress("localhost", 8443), 5_000); + socket.startHandshake(); + + java.io.DataOutputStream out = new java.io.DataOutputStream(socket.getOutputStream()); + java.io.DataInputStream in = new java.io.DataInputStream(socket.getInputStream()); + + // ── HTTP/2 connection preface ────────────────────────────────────── + out.write("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + // SETTINGS frame: length=0, type=4, flags=0, stream=0 + out.write(new byte[]{0, 0, 0, 0x4, 0x0, 0, 0, 0, 0}); + out.flush(); + + // ── HEADERS frame: GET /realms/master, :authority = authority ────── + byte[] hpackAuthority = authority.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + byte[] hpackPath = "/realms/master".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + + java.io.ByteArrayOutputStream hpack = new java.io.ByteArrayOutputStream(); + hpack.write(0x82); // :method GET (static idx 2) + hpack.write(0x87); // :scheme https (static idx 7) + hpack.write(0x44); // literal+index, name = static idx 4 (:path) + hpack.write(hpackPath.length); + hpack.write(hpackPath); + hpack.write(0x41); // literal+index, name = static idx 1 (:authority) + hpack.write(hpackAuthority.length); + hpack.write(hpackAuthority); + + byte[] headerBlock = hpack.toByteArray(); + + // HEADERS frame header: 3-byte length, type=1, flags=END_HEADERS|END_STREAM=5, stream=1 + out.write((headerBlock.length >> 16) & 0xFF); + out.write((headerBlock.length >> 8) & 0xFF); + out.write( headerBlock.length & 0xFF); + out.write(0x1); + out.write(0x5); + out.writeInt(1); // stream id 1 + out.write(headerBlock); + out.flush(); + + // ── Read HTTP/2 frames, skip SETTINGS/WINDOW_UPDATE, find HEADERS ── + socket.setSoTimeout(5_000); + String status = null; + + frameLoop: + for (int i = 0; i < 20 && status == null; i++) { + // Each HTTP/2 frame starts with a 9-byte header + int lengthHigh = in.readUnsignedByte(); + int lengthMid = in.readUnsignedByte(); + int lengthLow = in.readUnsignedByte(); + int frameLength = (lengthHigh << 16) | (lengthMid << 8) | lengthLow; + int frameType = in.readUnsignedByte(); + int frameFlags = in.readUnsignedByte(); + @SuppressWarnings("unused") + int streamId = in.readInt() & 0x7FFFFFFF; // mask reserved bit + + byte[] payload = new byte[frameLength]; + in.readFully(payload); + + switch (frameType) { + case 0x4: // SETTINGS + if ((frameFlags & 0x1) == 0) { + // Not an ACK — send our SETTINGS ACK back + out.write(new byte[]{0, 0, 0, 0x4, 0x1, 0, 0, 0, 0}); + out.flush(); + } + break; + + case 0x8: // WINDOW_UPDATE — ignore + break; + + case 0x1: // HEADERS — parse HPACK to find :status + int hpackOffset = 0; + // If PADDED flag (0x8) is set, skip 1-byte pad length + if ((frameFlags & 0x8) != 0) hpackOffset++; + // If PRIORITY flag (0x20) is set, skip 5-byte priority block + if ((frameFlags & 0x20) != 0) hpackOffset += 5; + + status = extractStatus(payload, hpackOffset, payload.length - hpackOffset); + break frameLoop; + + case 0x7: // GOAWAY — server rejected the connection + // payload[4..7] = last stream id, payload[8..11] = error code (big-endian) + int errorCode = ((payload[4] & 0xFF) << 24) | ((payload[5] & 0xFF) << 16) + | ((payload[6] & 0xFF) << 8) | (payload[7] & 0xFF); + System.err.println("GOAWAY received, error code: " + errorCode); + break frameLoop; + + default: + // RST_STREAM (0x3), DATA (0x0), etc. — skip + break; + } + } + + return status; + } + } + + /** + * Minimal HPACK decoder that only looks for the :status header. + * Handles indexed representations (static table) and literal representations. + * Does not update the dynamic table — sufficient for reading a typical response HEADERS frame. + */ + private static String extractStatus(byte[] hpack, int offset, int length) { + // HPACK static table entries for :status (indices 8-14): + // 8=200, 9=204, 10=206, 11=304, 12=400, 13=404, 14=500 + String[] STATIC_STATUS = {"200","204","206","304","400","404","500"}; + + int end = offset + length; + while (offset < end) { + int b = hpack[offset] & 0xFF; + + if ((b & 0x80) != 0) { + // Indexed header field — RFC 7541 §6.1 + int index = b & 0x7F; + if (index == 0x7F) { // multi-byte integer (unlikely for small indices) + index = 0x7F; + int shift = 0; + offset++; + while (offset < end) { + int next = hpack[offset++] & 0xFF; + index += (next & 0x7F) << shift; + shift += 7; + if ((next & 0x80) == 0) break; + } + } else { + offset++; + } + // Static table :status entries are indices 8–14 + if (index >= 8 && index <= 14) { + return STATIC_STATUS[index - 8]; + } + // :method=GET=2, :method=POST=3, :scheme=http=6, :scheme=https=7, :path=/=4, :path=/index.html=5 + // :authority=1 — none of these are :status; keep scanning + } else if ((b & 0x40) != 0) { + // Literal with incremental indexing — RFC 7541 §6.2.1 + int nameIndex = b & 0x3F; + offset++; + boolean isStatus = (nameIndex == 8); // static index 8 = :status + if (nameIndex == 0) { + // Name is a literal string — skip it + int[] r = readString(hpack, offset, end); + isStatus = false; // we don't parse the name string here + offset = r[1]; + } + int[] r = readString(hpack, offset, end); + String value = new String(hpack, r[0], r[1] - r[0], java.nio.charset.StandardCharsets.US_ASCII); + offset = r[1] + /* length-prefix bytes accounted in readString */ 0; + offset = r[2]; // readString returns [valueStart, valueEnd, nextOffset] + if (isStatus) return value; + } else { + // Literal without indexing (0x00) or never indexed (0x10) — RFC 7541 §6.2.2/6.2.3 + int nameIndex = b & (((b & 0x10) != 0) ? 0x0F : 0x0F); + offset++; + boolean isStatus = (nameIndex == 8); + if (nameIndex == 0) { + int[] r = readString(hpack, offset, end); + offset = r[2]; + isStatus = false; + } + int[] r = readString(hpack, offset, end); + String value = new String(hpack, r[0], r[1] - r[0], java.nio.charset.StandardCharsets.US_ASCII); + offset = r[2]; + if (isStatus) return value; + } + } + return null; + } + + /** + * Reads one HPACK string (RFC 7541 §5.2). + * Returns int[3]: {valueStart, valueEnd, nextOffset}. + * Huffman-encoded strings are not decoded. + */ + private static int[] readString(byte[] buf, int offset, int end) { + int b = buf[offset] & 0xFF; + boolean huffman = (b & 0x80) != 0; + assertFalse(huffman); + int len = b & 0x7F; + offset++; + if (len == 0x7F) { // multi-byte integer + int shift = 0; + len = 0x7F; + while (offset < end) { + int next = buf[offset++] & 0xFF; + len += (next & 0x7F) << shift; + shift += 7; + if ((next & 0x80) == 0) break; + } + } + int valueStart = offset; + int valueEnd = offset + len; + return new int[]{valueStart, valueEnd, valueEnd}; + } + @Test @Launch({"start-dev", "--http-access-log-enabled=true", "--http-accept-non-normalized-paths=true"}) From 64a32385edc6d529d69f80d470023a11c93b2209 Mon Sep 17 00:00:00 2001 From: Steve Hawkins Date: Thu, 16 Jul 2026 13:58:30 -0400 Subject: [PATCH 4/4] addressing review comments incorporating the vertx httpclient Signed-off-by: Steve Hawkins --- docs/guides/server/reverseproxy.adoc | 3 + .../quarkus/deployment/KeycloakProcessor.java | 24 +- .../quarkus/runtime/KeycloakRecorder.java | 15 +- .../runtime/services/MisdirectedFilter.java | 7 +- .../keycloak/it/cli/dist/HttpDistTest.java | 286 ++++-------------- 5 files changed, 82 insertions(+), 253 deletions(-) diff --git a/docs/guides/server/reverseproxy.adoc b/docs/guides/server/reverseproxy.adoc index cbbf00225370..479ae98e1a17 100644 --- a/docs/guides/server/reverseproxy.adoc +++ b/docs/guides/server/reverseproxy.adoc @@ -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: diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java index 0689ab2b33b4..18205f8d535b 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java @@ -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; @@ -291,29 +292,20 @@ void configureRedirectForRootPath(BuildProducer routes, ); } - @Record(ExecutionTime.STATIC_INIT) + @Record(ExecutionTime.RUNTIME_INIT) @BuildStep @Consume(ConfigBuildItem.class) - void filterAllRequests(BuildProducer 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()); - var misdirectedRequestFilter = recorder.getMisdirectedRequestFilter(); - if (misdirectedRequestFilter != null) { - filters.produce(new FilterBuildItem(misdirectedRequestFilter, SecurityHandlerPriorities.CORS + 2)); - } + recorder.misdirectedRequestFilter(vertxWebRouterBuildItem.getHttpRouter()); } - @Record(ExecutionTime.STATIC_INIT) + @Record(ExecutionTime.RUNTIME_INIT) @BuildStep(onlyIf = IsManagementEnabled.class) @Consume(ConfigBuildItem.class) - void filterAllManagementRequests(BuildProducer filters, KeycloakRecorder recorder) { - var filter = recorder.getRejectNonNormalizedPathFilter(); - if (filter != null) { - filters.produce(new ManagementInterfaceFilterBuildItem(filter, SecurityHandlerPriorities.CORS + 1)); - } + void filterAllManagementRequests(VertxWebRouterBuildItem vertxWebRouterBuildItem, BuildProducer filters, KeycloakRecorder recorder) { + recorder.rejectNonNormalizedPathFilter(vertxWebRouterBuildItem.getManagementRouter()); } @BuildStep(onlyIfNot = IsKeycloakDevMode.class) diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java index 281dd05134f0..7d6b416f5068 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/KeycloakRecorder.java @@ -72,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; @@ -134,20 +136,23 @@ String getListItem() { } } - public Handler getRejectNonNormalizedPathFilter() { - return !Configuration.isTrue(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS) ? new RejectNonNormalizedPathFilter() : null; + public void rejectNonNormalizedPathFilter(RuntimeValue runtimeValue) { + if (Configuration.isTrue(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS)) { + return; + } + runtimeValue.getValue().route().order(-1 * (SecurityHandlerPriorities.CORS + 1)).handler(new RejectNonNormalizedPathFilter()); } - public Handler getMisdirectedRequestFilter() { + public void misdirectedRequestFilter(RuntimeValue 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 null; + return; } Set allowedHosts = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); extractHost(HostnameV2Options.HOSTNAME, allowedHosts); extractHost(HostnameV2Options.HOSTNAME_ADMIN, allowedHosts); - return new MisdirectedFilter(allowedHosts); + runtimeValue.getValue().route().order(-1 * (SecurityHandlerPriorities.CORS + 2)).handler(new MisdirectedFilter(allowedHosts)); } private static void extractHost(Option option, Set allowedHosts) { diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java index 46235ecc44ee..5bce5d44e400 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/services/MisdirectedFilter.java @@ -2,6 +2,7 @@ import java.util.Set; +import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.Handler; import io.vertx.ext.web.RoutingContext; @@ -26,7 +27,11 @@ public void handle(RoutingContext routingContext) { || allowedHosts.contains(authority.host())) { routingContext.next(); } else { - routingContext.response().setStatusCode(421).end(); + 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()); } } diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HttpDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HttpDistTest.java index 583940c62038..8a6c8952f11f 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HttpDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HttpDistTest.java @@ -17,15 +17,13 @@ package org.keycloak.it.cli.dist; -import java.io.IOException; -import java.net.SocketException; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import javax.net.ssl.TrustManager; +import java.util.concurrent.TimeUnit; import org.keycloak.it.junit5.extension.CLIResult; import org.keycloak.it.junit5.extension.DistributionTest; @@ -40,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; @@ -50,7 +57,6 @@ import static org.hamcrest.CoreMatchers.hasItem; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertFalse; /** * @author Vaclav Muzikar @@ -98,242 +104,60 @@ public void preventNonNormalizedURLs() { @Test @Launch({"start-dev", "--hostname=https://example.com"}) public void misdirectedRequestDetection() throws Exception { - // Build a trust-everything SSLContext so the self-signed dev cert is accepted - TrustManager[] trustAll = new TrustManager[]{ - new javax.net.ssl.X509TrustManager() { - public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {} - public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {} - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return new java.security.cert.X509Certificate[0]; - } - } - }; - javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS"); - sslContext.init(null, trustAll, null); - - javax.net.ssl.SSLSocketFactory factory = sslContext.getSocketFactory(); - - String status = http2Request(factory, "servicehost.com", "servicehost.com:8443"); - assertThat("Matching indicated to authority is allowed", status, Matchers.is("200")); - - status = http2Request(factory, null, "example.com:433"); - assertThat("No indicated name is allowed", status, Matchers.is("200")); - - // connection originated from another backend, but we're reusing it for a request to the keycloak server - status = http2Request(factory, "other-example.com", "example.com:433"); - assertThat("Matching a known host is allowed", status, Matchers.is("200")); - - // connection originated from keycloak, but the browser is mistakenly reusing for another service - status = http2Request(factory, "example.com", "misdirected.com:433"); - assertThat("Expected HTTP 421 Misdirected Request for SNI/authority mismatch", - status, Matchers.is("421")); - } - - private static String http2Request(javax.net.ssl.SSLSocketFactory factory, String indicatedHostname, String authority) throws IOException, SocketException { - try (javax.net.ssl.SSLSocket socket = - (javax.net.ssl.SSLSocket) factory.createSocket()) { - - javax.net.ssl.SSLParameters params = socket.getSSLParameters(); - if (indicatedHostname != null) { - params.setServerNames(List.of(new javax.net.ssl.SNIHostName(indicatedHostname))); - } - params.setApplicationProtocols(new String[]{"h2"}); - socket.setSSLParameters(params); - - socket.connect(new java.net.InetSocketAddress("localhost", 8443), 5_000); - socket.startHandshake(); - - java.io.DataOutputStream out = new java.io.DataOutputStream(socket.getOutputStream()); - java.io.DataInputStream in = new java.io.DataInputStream(socket.getInputStream()); - - // ── HTTP/2 connection preface ────────────────────────────────────── - out.write("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); - // SETTINGS frame: length=0, type=4, flags=0, stream=0 - out.write(new byte[]{0, 0, 0, 0x4, 0x0, 0, 0, 0, 0}); - out.flush(); - - // ── HEADERS frame: GET /realms/master, :authority = authority ────── - byte[] hpackAuthority = authority.getBytes(java.nio.charset.StandardCharsets.US_ASCII); - byte[] hpackPath = "/realms/master".getBytes(java.nio.charset.StandardCharsets.US_ASCII); - - java.io.ByteArrayOutputStream hpack = new java.io.ByteArrayOutputStream(); - hpack.write(0x82); // :method GET (static idx 2) - hpack.write(0x87); // :scheme https (static idx 7) - hpack.write(0x44); // literal+index, name = static idx 4 (:path) - hpack.write(hpackPath.length); - hpack.write(hpackPath); - hpack.write(0x41); // literal+index, name = static idx 1 (:authority) - hpack.write(hpackAuthority.length); - hpack.write(hpackAuthority); - - byte[] headerBlock = hpack.toByteArray(); - - // HEADERS frame header: 3-byte length, type=1, flags=END_HEADERS|END_STREAM=5, stream=1 - out.write((headerBlock.length >> 16) & 0xFF); - out.write((headerBlock.length >> 8) & 0xFF); - out.write( headerBlock.length & 0xFF); - out.write(0x1); - out.write(0x5); - out.writeInt(1); // stream id 1 - out.write(headerBlock); - out.flush(); - - // ── Read HTTP/2 frames, skip SETTINGS/WINDOW_UPDATE, find HEADERS ── - socket.setSoTimeout(5_000); - String status = null; - - frameLoop: - for (int i = 0; i < 20 && status == null; i++) { - // Each HTTP/2 frame starts with a 9-byte header - int lengthHigh = in.readUnsignedByte(); - int lengthMid = in.readUnsignedByte(); - int lengthLow = in.readUnsignedByte(); - int frameLength = (lengthHigh << 16) | (lengthMid << 8) | lengthLow; - int frameType = in.readUnsignedByte(); - int frameFlags = in.readUnsignedByte(); - @SuppressWarnings("unused") - int streamId = in.readInt() & 0x7FFFFFFF; // mask reserved bit - - byte[] payload = new byte[frameLength]; - in.readFully(payload); - - switch (frameType) { - case 0x4: // SETTINGS - if ((frameFlags & 0x1) == 0) { - // Not an ACK — send our SETTINGS ACK back - out.write(new byte[]{0, 0, 0, 0x4, 0x1, 0, 0, 0, 0}); - out.flush(); - } - break; - - case 0x8: // WINDOW_UPDATE — ignore - break; - - case 0x1: // HEADERS — parse HPACK to find :status - int hpackOffset = 0; - // If PADDED flag (0x8) is set, skip 1-byte pad length - if ((frameFlags & 0x8) != 0) hpackOffset++; - // If PRIORITY flag (0x20) is set, skip 5-byte priority block - if ((frameFlags & 0x20) != 0) hpackOffset += 5; - - status = extractStatus(payload, hpackOffset, payload.length - hpackOffset); - break frameLoop; - - case 0x7: // GOAWAY — server rejected the connection - // payload[4..7] = last stream id, payload[8..11] = error code (big-endian) - int errorCode = ((payload[4] & 0xFF) << 24) | ((payload[5] & 0xFF) << 16) - | ((payload[6] & 0xFF) << 8) | (payload[7] & 0xFF); - System.err.println("GOAWAY received, error code: " + errorCode); - break frameLoop; - - default: - // RST_STREAM (0x3), DATA (0x0), etc. — skip - break; - } + 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); } - - return status; + } finally { + vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); } } - /** - * Minimal HPACK decoder that only looks for the :status header. - * Handles indexed representations (static table) and literal representations. - * Does not update the dynamic table — sufficient for reading a typical response HEADERS frame. - */ - private static String extractStatus(byte[] hpack, int offset, int length) { - // HPACK static table entries for :status (indices 8-14): - // 8=200, 9=204, 10=206, 11=304, 12=400, 13=404, 14=500 - String[] STATIC_STATUS = {"200","204","206","304","400","404","500"}; + 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); - int end = offset + length; - while (offset < end) { - int b = hpack[offset] & 0xFF; - - if ((b & 0x80) != 0) { - // Indexed header field — RFC 7541 §6.1 - int index = b & 0x7F; - if (index == 0x7F) { // multi-byte integer (unlikely for small indices) - index = 0x7F; - int shift = 0; - offset++; - while (offset < end) { - int next = hpack[offset++] & 0xFF; - index += (next & 0x7F) << shift; - shift += 7; - if ((next & 0x80) == 0) break; - } - } else { - offset++; - } - // Static table :status entries are indices 8–14 - if (index >= 8 && index <= 14) { - return STATIC_STATUS[index - 8]; - } - // :method=GET=2, :method=POST=3, :scheme=http=6, :scheme=https=7, :path=/=4, :path=/index.html=5 - // :authority=1 — none of these are :status; keep scanning - } else if ((b & 0x40) != 0) { - // Literal with incremental indexing — RFC 7541 §6.2.1 - int nameIndex = b & 0x3F; - offset++; - boolean isStatus = (nameIndex == 8); // static index 8 = :status - if (nameIndex == 0) { - // Name is a literal string — skip it - int[] r = readString(hpack, offset, end); - isStatus = false; // we don't parse the name string here - offset = r[1]; - } - int[] r = readString(hpack, offset, end); - String value = new String(hpack, r[0], r[1] - r[0], java.nio.charset.StandardCharsets.US_ASCII); - offset = r[1] + /* length-prefix bytes accounted in readString */ 0; - offset = r[2]; // readString returns [valueStart, valueEnd, nextOffset] - if (isStatus) return value; - } else { - // Literal without indexing (0x00) or never indexed (0x10) — RFC 7541 §6.2.2/6.2.3 - int nameIndex = b & (((b & 0x10) != 0) ? 0x0F : 0x0F); - offset++; - boolean isStatus = (nameIndex == 8); - if (nameIndex == 0) { - int[] r = readString(hpack, offset, end); - offset = r[2]; - isStatus = false; - } - int[] r = readString(hpack, offset, end); - String value = new String(hpack, r[0], r[1] - r[0], java.nio.charset.StandardCharsets.US_ASCII); - offset = r[2]; - if (isStatus) return value; - } + if (sniHostname != null) { + options.setHost(sniHostname); } - return null; - } - /** - * Reads one HPACK string (RFC 7541 §5.2). - * Returns int[3]: {valueStart, valueEnd, nextOffset}. - * Huffman-encoded strings are not decoded. - */ - private static int[] readString(byte[] buf, int offset, int end) { - int b = buf[offset] & 0xFF; - boolean huffman = (b & 0x80) != 0; - assertFalse(huffman); - int len = b & 0x7F; - offset++; - if (len == 0x7F) { // multi-byte integer - int shift = 0; - len = 0x7F; - while (offset < end) { - int next = buf[offset++] & 0xFF; - len += (next & 0x7F) << shift; - shift += 7; - if ((next & 0x80) == 0) break; - } - } - int valueStart = offset; - int valueEnd = offset + len; - return new int[]{valueStart, valueEnd, valueEnd}; + 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"}) public void allowNonNormalizedURLs() {