Skip to content
Closed
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 @@ -6,6 +6,37 @@
import io.vertx.core.Handler;
import io.vertx.ext.web.RoutingContext;

/**
* Defends against HTTP/2 connection coalescing delivering requests to the wrong backend.
*
* <p>When multiple hostnames share a TLS certificate (e.g. a wildcard cert behind a reverse proxy),
* browsers may reuse a single HTTP/2 connection for requests to different hostnames (RFC 9113 §9.1.1).
* In TLS passthrough mode the proxy routes by SNI, so all requests on a coalesced connection land on
* the backend selected during the original TLS handshake — even if the {@code :authority} names a
* different service. This filter returns HTTP 421 Misdirected Request for such requests, prompting
* the client to open a new connection with the correct SNI.
*
* <p>Only active in TLS passthrough mode (no proxy headers configured). Registered by
* {@link org.keycloak.quarkus.runtime.KeycloakRecorder#misdirectedRequestFilter}.
Comment thread
ahus1 marked this conversation as resolved.
*
* <p>Cases that pass through:
* <ul>
* <li>Plain HTTP requests — no TLS, no SNI to check.</li>
* <li>No {@code :authority} header — nothing to compare against.</li>
* <li>No SNI sent ({@code indicatedServerName()} is null) — happens with health checks
* or non-FQDN connections; rejecting these would break monitoring.</li>
* <li>SNI matches authority — the connection was established for this hostname.
* This also allows health checks that connect with an arbitrary hostname
* not configured in Keycloak.</li>
Comment on lines +26 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if you want to mention it, but these account for all "backend" requests made directly to the Keycloak instance, not just monitoring.

* <li>Authority matches a configured Keycloak hostname ({@code hostname} or
* {@code hostname-admin}) — the request is legitimately for this server, even if
* the connection was originally established for a different hostname.</li>
Comment on lines +31 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth mentioning here that we aren't yet trying to account for the realm front-end urls - and that 421s may erroneously occur then.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are "realm front-end urls"? It seems that I missed that discussion :D

If I know what they are, I'm happy to add them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each realm can be configured to have its own frontend url -

Click a question mark *?* icon to show the definition of a field such as *Frontend URL*.

The hostname provider looks for it here

.map(r -> r.getAttribute("frontendUrl"))

* </ul>
*
* <p>Note: {@code indicatedServerName()} returns the SNI from the initial TLS handshake and is
* a per-connection value, not per-request. All HTTP/2 streams multiplexed on the same connection
* share it.
*/
public class MisdirectedFilter implements Handler<RoutingContext> {

private final Set<String> allowedHosts;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
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;
Expand Down Expand Up @@ -102,63 +101,6 @@ public void preventNonNormalizedURLs() {
.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"})
public void largeHeadersTest() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package org.keycloak.tests.ssl;

import java.util.concurrent.TimeUnit;

import org.keycloak.testframework.annotations.InjectKeycloakUrls;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.https.CertificatesConfig;
import org.keycloak.testframework.https.CertificatesConfigBuilder;
import org.keycloak.testframework.https.InjectCertificates;
import org.keycloak.testframework.https.ManagedCertificates;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.testframework.server.KeycloakUrls;

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.junit.jupiter.api.Test;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;

/**
* Tests that the {@link org.keycloak.quarkus.runtime.services.MisdirectedFilter} returns HTTP 421 for misdirected HTTP/2 requests.
*/
@KeycloakIntegrationTest(config = MisdirectedRequestTest.ServerConfig.class)
class MisdirectedRequestTest {

@InjectCertificates(config = TlsEnabledConfig.class)
ManagedCertificates managedCertificates;

@InjectKeycloakUrls
KeycloakUrls keycloakUrls;

@Test
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 {
int port = keycloakUrls.getBaseUrl().getPort();

assertThat("Matching indicated to authority is allowed",
sendRequest(client, port, "servicehost.com", "servicehost.com", 8443), is(200));

// null sniHostname → defaults to "localhost" (non-FQDN → Java skips SNI → indicatedServerName is null)
assertThat("No indicated name is allowed",
sendRequest(client, port, null, "example.com", 443), 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",
sendRequest(client, port, "other-example.com", "example.com", 443), 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",
sendRequest(client, port, "example.com", "misdirected.com", 443), is(421));
} finally {
// Timeout set to minutes to allow manual debugging
client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.MINUTES);
Comment thread
ahus1 marked this conversation as resolved.
}
} finally {
// Timeout set to minutes to allow manual debugging
vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.MINUTES);
}
}

private int sendRequest(HttpClient client, int serverPort, String sniHostname, String authorityHost, int authorityPort) throws Exception {
RequestOptions options = new RequestOptions()
.setServer(SocketAddress.inetSocketAddress(serverPort, "localhost"))
.setPort(serverPort)
.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);
}

static class ServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.option("hostname", "https://example.com");
}
}

static class TlsEnabledConfig implements CertificatesConfig {
@Override
public CertificatesConfigBuilder configure(CertificatesConfigBuilder config) {
return config.tlsEnabled(true);
}
}
}
Loading