-
Notifications
You must be signed in to change notification settings - Fork 8.8k
Refacoring test case for MisdirectedFilter #51443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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}. | ||||||
| * | ||||||
| * <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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Each realm can be configured to have its own frontend url - keycloak/docs/documentation/server_admin/topics/realms/proc-using-admin-console.adoc Line 38 in 29556d3
The hostname provider looks for it here
|
||||||
| * </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; | ||||||
|
|
||||||
| 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); | ||
|
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); | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.