Skip to content
Draft
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
21 changes: 21 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_7_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ Resources with malformed URI templates should be corrected before upgrading.
After the upgrade, existing resources with malformed templates will continue to be stored, but they may not match request URIs as intended.
Any update to such a resource will require fixing the URI template at the same time, as the stricter validation applies to all create and update operations.

=== Strict validation of HTTP Host header

When the `hostname` option is configured, {project_name} now validates the HTTP `Host` header (or HTTP/2 `:authority` pseudo-header) against the configured `hostname` and, if set, `hostname-admin` values.
Requests whose host does not match are rejected with HTTP `421 Misdirected Request`.
For the full details, see the link:https://www.keycloak.org/server/reverseproxy#_host_header_validation[Host header validation] section in the reverse proxy guide.

No action is required if:

* Your reverse proxy forwards the original `Host` header correctly and `proxy-headers` is set to `forwarded` or `xforwarded`.
* You access {project_name} directly using the hostname specified in the `hostname` option.

You may see `421 Misdirected Request` errors if:

* A re-encrypt or edge-terminating reverse proxy does not forward the original `Host` header.
Configure the proxy to forward the original hostname and set `proxy-headers` to `forwarded` or `xforwarded` for {project_name} depending on how the proxy forwards the information.
* A TLS passthrough proxy uses a wildcard or multi-SAN certificate shared with other services.
Use a dedicated certificate for {project_name} instead.

To restore the previous behavior, set `hostname-strict-host-check-enabled=false`.
This option is deprecated and may be removed in a future version.

=== Organization invitation filters use exact matching

The `email`, `firstName`, and `lastName` query parameters on the organization invitations list endpoint (`+GET /admin/realms/{realm}/orgs/{orgId}/invitations+`) now use case-insensitive exact matching instead of substring matching.
Expand Down
29 changes: 29 additions & 0 deletions docs/guides/server/reverseproxy.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ When using passthrough:

* Forwarded headers are not applicable because the proxy cannot modify the encrypted traffic.

* Do not share a TLS certificate (wildcard or multi-SAN) across different backend services when using HTTP/2.
When multiple DNS names resolve to the same IP address and are covered by the same certificate, browsers may coalesce HTTP/2 connections and send requests intended for one service to another.
This can lead to error responses or information disclosure.
Instead, use a certificate specific to each service, or switch to re-encrypt.

* Enable the PROXY protocol (`--proxy-protocol-enabled=true`) to see the real client IP address.
Note that the PROXY protocol is not compatible with the `--proxy-headers` option.
+
Expand All @@ -90,6 +95,30 @@ 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.

=== Host header validation

When the `hostname` option is configured, {project_name} validates the HTTP `Host` header (or HTTP/2 `:authority` pseudo-header) against the configured `hostname` and, if set, `hostname-admin` values.
Requests whose host does not match are rejected with HTTP `421 Misdirected Request`.

This prevents HTTP/2 connection coalescing issues that can occur when a TLS certificate -- whether a wildcard certificate (for example `*.example.com`) or a certificate with multiple Subject Alternative Names (SANs) -- covers both {project_name} and other services.
It also catches misconfigured reverse proxies that do not forward the original hostname.

The check is not active when:

* The `hostname` option is not configured.
* The `hostname-backchannel-dynamic` option is set to `true`, as backchannel requests use dynamic hostnames by design.

It is not triggered for requests sent to:

* The management port, as it is not exposed to external traffic.
* Management endpoints (`/health`, `/metrics`, `/openapi`) served on the main HTTP port.

To disable the check, set `hostname-strict-host-check-enabled=false`.
This option is deprecated and may be removed in a future version.

WARNING: Disabling this check can lead to error responses or information disclosure when HTTP/2 connection coalescing causes requests to be routed to the wrong service -- either requests intended for another service arriving at {project_name}, or requests intended for {project_name} arriving at another service.
It also hides misconfigured forwarded headers for re-encrypt proxies.

=== 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 @@ -24,6 +24,17 @@ public class HostnameV2Options {
.defaultValue(Boolean.TRUE)
.build();

/**
* This option was added for backwards compatibility in 26.7. It should not be necessary to disable it, and disabling it might lead to security problems.
*/
@Deprecated(forRemoval = true, since = "26.7")
public static final Option<Boolean> HOSTNAME_STRICT_HOST_CHECK_ENABLED = new OptionBuilder<>("hostname-strict-host-check-enabled", Boolean.class)
.category(OptionCategory.HOSTNAME_V2)
.description("Whether the server should validate that the HTTP Host header matches the configured hostname, and reject requests with HTTP 421 if not. This prevents HTTP/2 connection coalescing issues when using wildcard certificates. Requires 'hostname' to be configured.")
.defaultValue(Boolean.TRUE)
.deprecated()
.build();

public static final Option<Boolean> HOSTNAME_DEBUG = new OptionBuilder<>("hostname-debug", Boolean.class)
.category(OptionCategory.HOSTNAME_V2)
.description("Toggles the hostname debug page that is accessible at /realms/master/hostname-debug.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,23 @@ void configureRedirectForRootPath(BuildProducer<RouteBuildItem> routes,
@Record(ExecutionTime.STATIC_INIT)
@BuildStep
@Consume(ConfigBuildItem.class)
void filterAllRequests(BuildProducer<FilterBuildItem> filters, KeycloakRecorder recorder) {
void filterAllRequests(BuildProducer<FilterBuildItem> filters,
BuildProducer<MisdirectedRequestFilterBuildItem> misdirectedRequestFilterProducer,
KeycloakRecorder recorder) {
var filter = recorder.getRejectNonNormalizedPathFilter();
if (filter != null) {
filters.produce(new FilterBuildItem(filter, SecurityHandlerPriorities.CORS + 1));
}
var misdirectedFilter = recorder.createMisdirectedRequestFilter();
filters.produce(new FilterBuildItem(recorder.asMisdirectedRequestHandler(misdirectedFilter), SecurityHandlerPriorities.CORS + 1));
misdirectedRequestFilterProducer.produce(new MisdirectedRequestFilterBuildItem(misdirectedFilter));
}

@Record(ExecutionTime.RUNTIME_INIT)
@BuildStep
@Consume(ConfigBuildItem.class)
void configureMisdirectedRequestFilter(MisdirectedRequestFilterBuildItem filterBuildItem, KeycloakRecorder recorder) {
recorder.configureMisdirectedRequestFilter(filterBuildItem.getFilter());
}

@Record(ExecutionTime.STATIC_INIT)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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 org.keycloak.quarkus.deployment;

import org.keycloak.quarkus.runtime.services.RejectMisdirectedRequestFilter;

import io.quarkus.builder.item.SimpleBuildItem;
import io.quarkus.runtime.RuntimeValue;

final class MisdirectedRequestFilterBuildItem extends SimpleBuildItem {

private final RuntimeValue<RejectMisdirectedRequestFilter> filter;

MisdirectedRequestFilterBuildItem(RuntimeValue<RejectMisdirectedRequestFilter> filter) {
this.filter = filter;
}

RuntimeValue<RejectMisdirectedRequestFilter> getFilter() {
return filter;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@

import java.io.File;
import java.lang.annotation.Annotation;
import java.net.URI;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand All @@ -36,6 +40,7 @@
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;
Expand All @@ -47,7 +52,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.ManagementPropertyMappers;
import org.keycloak.quarkus.runtime.integration.QuarkusKeycloakSessionFactory;
import org.keycloak.quarkus.runtime.services.RejectMisdirectedRequestFilter;
import org.keycloak.quarkus.runtime.services.RejectNonNormalizedPathFilter;
import org.keycloak.quarkus.runtime.storage.database.liquibase.FastServiceLocator;
import org.keycloak.representations.userprofile.config.UPConfig;
Expand Down Expand Up @@ -128,6 +135,64 @@ public Handler<RoutingContext> getRejectNonNormalizedPathFilter() {
return !Configuration.isTrue(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS) ? new RejectNonNormalizedPathFilter() : null;
}

private static final List<String> MANAGEMENT_PATH_SUFFIXES = MANAGEMENT_INTERFACE_ENDPOINTS.stream()
.map(ManagementInterfaceItem::path)
.toList();

public RuntimeValue<RejectMisdirectedRequestFilter> createMisdirectedRequestFilter() {
return new RuntimeValue<>(new RejectMisdirectedRequestFilter());
}

public Handler<RoutingContext> asMisdirectedRequestHandler(RuntimeValue<RejectMisdirectedRequestFilter> filter) {
return filter.getValue();
}

public void configureMisdirectedRequestFilter(RuntimeValue<RejectMisdirectedRequestFilter> filter) {
if (!Configuration.isTrue(HostnameV2Options.HOSTNAME_STRICT_HOST_CHECK_ENABLED)
|| Configuration.isTrue(HostnameV2Options.HOSTNAME_BACKCHANNEL_DYNAMIC)) {
return;
}

String hostnameValue = Configuration.getConfigValue(HostnameV2Options.HOSTNAME).getValue();
if (hostnameValue == null) {
return;
}

Set<String> allowedHosts = new HashSet<>();
allowedHosts.add(extractHost(hostnameValue).toLowerCase(Locale.ROOT));

String adminValue = Configuration.getConfigValue(HostnameV2Options.HOSTNAME_ADMIN).getValue();
if (adminValue != null) {
allowedHosts.add(extractHost(adminValue).toLowerCase(Locale.ROOT));
}

List<String> managementPaths = null;
if (!ManagementPropertyMappers.isManagementEnabled()) {
String relativePath = Configuration.getConfigValue(HttpOptions.HTTP_RELATIVE_PATH).getValue();
String prefix = normalizePrefix(relativePath);
managementPaths = MANAGEMENT_PATH_SUFFIXES.stream()
.map(suffix -> prefix + suffix)
.toList();
}

filter.getValue().configure(allowedHosts, managementPaths);
}

private static String extractHost(String hostnameOrUrl) {
if (hostnameOrUrl.startsWith("http://") || hostnameOrUrl.startsWith("https://")) {
return URI.create(hostnameOrUrl).getHost();
}
return hostnameOrUrl;
}

private static String normalizePrefix(String relativePath) {
if (relativePath == null || relativePath.equals("/")) {
return "";
}
String prefix = relativePath.startsWith("/") ? relativePath : "/" + relativePath;
return prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix;
}

public void configureTruststore() {
List<String> truststores = new ArrayList<>();
Configuration.getOptionalKcValue(TruststoreOptions.TRUSTSTORE_PATHS.getKey())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public List<? extends PropertyMapper<?>> getPropertyMappers() {
.to("kc.spi-hostname--v2--hostname-backchannel-dynamic"),
fromOption(HostnameV2Options.HOSTNAME_STRICT)
.to("kc.spi-hostname--v2--hostname-strict"),
fromOption(HostnameV2Options.HOSTNAME_STRICT_HOST_CHECK_ENABLED),
fromOption(HostnameV2Options.HOSTNAME_DEBUG)
)
.map(b -> b.isEnabled(() -> Profile.isFeatureEnabled(Profile.Feature.HOSTNAME_V2), "hostname:v2 feature is enabled").build())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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 org.keycloak.quarkus.runtime.services;

import java.util.List;
import java.util.Locale;
import java.util.Set;

import io.netty.handler.codec.http.HttpResponseStatus;
import io.vertx.core.Handler;
import io.vertx.core.net.HostAndPort;
import io.vertx.ext.web.RoutingContext;
import org.jboss.logging.Logger;

/**
* Rejects requests whose Host header does not match the configured hostname with HTTP 421 (Misdirected Request).
* This prevents HTTP/2 connection coalescing issues when wildcard certificates are used; browsers should gracefully retry.
* It also uncovers misconfigured reverse proxies that do not forward the original HTTP host; in those cases requests will fail.
*/
public class RejectMisdirectedRequestFilter implements Handler<RoutingContext> {
private static final Logger LOGGER = Logger.getLogger(RejectMisdirectedRequestFilter.class);
private Set<String> allowedHosts;
private List<String> managementPaths;

public void configure(Set<String> allowedHosts, List<String> managementPaths) {
this.allowedHosts = allowedHosts;
this.managementPaths = managementPaths;
}

@Override
public void handle(RoutingContext routingContext) {
if (allowedHosts == null || allowedHosts.isEmpty()) {
routingContext.next();
return;
}

HostAndPort authority = routingContext.request().authority();
if (authority == null) {
routingContext.next();
return;
}

String hostOnly = authority.host().toLowerCase(Locale.ROOT);
if (allowedHosts.contains(hostOnly)) {
routingContext.next();
return;
}

if (managementPaths != null && isManagementPath(routingContext.normalizedPath())) {
routingContext.next();
return;
}

LOGGER.warnf("Misdirected request rejected: Host header '%s' does not match the configured hostname(s) %s. "
+ "This can happen due to a misconfigured reencrypt or edge terminating reverse proxy that does not forward the original hostname, "
+ "or due to HTTP/2 connection coalescing when a wildcard certificate is used. "
+ "For a reencrypt or edge terminating reverse proxy, configure it to forward the original hostname "
+ "and set 'proxy-headers' to 'forwarded' or 'xforwarded' for Keycloak depending on how the proxy forwards the information. "
+ "For a TLS passthrough proxy, use a dedicated certificate for Keycloak instead of a wildcard certificate. "
+ "To disable this check, set 'hostname-strict-host-check-enabled=false'.", authority.host(), allowedHosts);
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());
}

private boolean isManagementPath(String path) {
for (String managementPath : managementPaths) {
if (path.equals(managementPath) || path.startsWith(managementPath + "/")) {
return true;
}
}
return false;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public class ConstantsDebugHostname {
HostnameV2Options.HOSTNAME_ADMIN.getKey(),
HostnameV2Options.HOSTNAME_BACKCHANNEL_DYNAMIC.getKey(),
HostnameV2Options.HOSTNAME_STRICT.getKey(),
HostnameV2Options.HOSTNAME_STRICT_HOST_CHECK_ENABLED.getKey(),
ProxyOptions.PROXY_HEADERS.getKey(),
HttpOptions.HTTP_ENABLED.getKey(),
HttpOptions.HTTP_RELATIVE_PATH.getKey(),
Expand Down
Loading
Loading