Allow only normalized paths in requests (#43869)

* Allow only normalized paths in requests

Closes #43763

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Co-authored-by: Martin Bartoš <mabartos@redhat.com>

* Remove the trailing slash for base url in the account and admin tests

Closes #43863

Signed-off-by: rmartinc <rmartinc@redhat.com>
# Conflicts:
#	js/apps/account-ui/test/account-security/linked-accounts.spec.ts

---------

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Signed-off-by: rmartinc <rmartinc@redhat.com>
Co-authored-by: Martin Bartoš <mabartos@redhat.com>
Co-authored-by: Ricardo Martin <rmartinc@redhat.com>
This commit is contained in:
Alexander Schwartz 2025-10-31 15:57:40 +01:00 committed by GitHub
parent 4357fc43c7
commit 34b9ede377
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 198 additions and 15 deletions

View File

@ -4,7 +4,14 @@
Breaking changes are identified as those that might require changes for existing users to their configurations or applications.
In minor or patch releases, {project_name} will only introduce breaking changes to fix bugs.
=== <TODO>
=== Accepting only normalized paths in requests
Previously {project_name} accepted HTTP requests with paths containing double dots (`..`) or double slashes (`//`). When processing them, it normalized the path by collapsing double slashes and normalized the path according to RFC3986.
As this has led to a hard-to-configure URL filtering, for example, in reverse proxies, the normalization is now disabled, and {project_name} responds with an HTTP 400 response code.
To analyze rejected requests in the server log, enable debug logging for `org.keycloak.quarkus.runtime.services.RejectNonNormalizedPathFilter`.
To revert to the previous behavior and to accept non-normalized URLs, set the option `http-accept-non-normalized-paths` to `true`. With this configuration, enable and review the HTTP access log to identify problematic requests.
// ------------------------ Notable changes ------------------------ //
== Notable changes
@ -29,12 +36,17 @@ For more information, see link:{adminguide_link}#_fine_grained_permissions[Deleg
The following sections provide details on deprecated features.
=== <TODO>
=== Accepting HTTP requests with non-normalized paths
The option `http-accept-non-normalized-paths` was introduced to restore the previous behavior where {project_name} accepted non-normalized URLs.
As this behavior can be problematic for URL filtering, it is deprecated and will be removed in a future release.
// ------------------------ Removed features ------------------------ //
////
== Removed features
The following features have been removed from this release.
=== <TODO>
////

View File

@ -1,29 +1,36 @@
// ------------------------ Breaking changes ------------------------ //
////
== Breaking changes
Breaking changes are identified as those that might require changes for existing users to their configurations or applications.
In minor or patch releases, {project_name} will only introduce breaking changes to fix bugs.
=== <TODO>
////
// ------------------------ Notable changes ------------------------ //
////
== Notable changes
Notable changes may include internal behavior changes that prevent common misconfigurations, bugs that are fixed, or changes to simplify running {project_name}.
=== <TODO>
////
// ------------------------ Deprecated features ------------------------ //
////
== Deprecated features
The following sections provide details on deprecated features.
=== <TODO>
////
// ------------------------ Removed features ------------------------ //
////
== Removed features
The following features have been removed from this release.
=== <TODO>
////

View File

@ -58,12 +58,12 @@ test.describe("Linked accounts", () => {
clientId: "groups-idp",
clientSecret: "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw",
validateSignature: "false",
tokenUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/token`,
jwksUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/certs`,
issuer: `${SERVER_URL}realms/${externalRealm}`,
authorizationUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/auth`,
logoutUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/logout`,
userInfoUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/userinfo`,
tokenUrl: `${SERVER_URL}/realms/${externalRealm}/protocol/openid-connect/token`,
jwksUrl: `${SERVER_URL}/realms/${externalRealm}/protocol/openid-connect/certs`,
issuer: `${SERVER_URL}/realms/${externalRealm}`,
authorizationUrl: `${SERVER_URL}/realms/${externalRealm}/protocol/openid-connect/auth`,
logoutUrl: `${SERVER_URL}/realms/${externalRealm}/protocol/openid-connect/logout`,
userInfoUrl: `${SERVER_URL}/realms/${externalRealm}/protocol/openid-connect/userinfo`,
},
});

View File

@ -3,7 +3,7 @@ import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/us
import { ADMIN_PASSWORD, ADMIN_USERNAME, SERVER_URL } from "./common.ts";
export const adminClient = new AdminClient({
baseUrl: SERVER_URL.toString(),
baseUrl: SERVER_URL,
});
await adminClient.auth({

View File

@ -1,7 +1,7 @@
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation.js";
import { generatePath } from "react-router-dom";
export const SERVER_URL = new URL("http://localhost:8080");
export const SERVER_URL = "http://localhost:8080";
export const ACCOUNT_ROOT_PATH = "/realms/:realm/account" as const;
export const ADMIN_ROOT_PATH = "/admin/:realm/console" as const;
export const DEFAULT_REALM = "master";

View File

@ -15,7 +15,7 @@ import { merge } from "lodash-es";
class AdminClient {
readonly #client = new KeycloakAdminClient({
baseUrl: "http://localhost:8080/",
baseUrl: "http://localhost:8080",
realmName: "master",
});

View File

@ -151,4 +151,11 @@ public class HttpOptions {
"Specify a list of comma-separated values defined in milliseconds. Example with buckets from 5ms to 10s: 5,10,25,50,250,500,1000,2500,5000,10000")
.build();
public static final Option<Boolean> HTTP_ACCEPT_NON_NORMALIZED_PATHS = new OptionBuilder<>("http-accept-non-normalized-paths", Boolean.class)
.category(OptionCategory.HTTP)
.description("If the server should accept paths that are not normalized according to RFC3986 or that contain a double slash ('//'). While accepting those requests might be relevant for legacy applications, it is recommended to disable it to allow for more concise URL filtering.")
.deprecated()
.defaultValue(Boolean.FALSE)
.build();
}

View File

@ -49,9 +49,12 @@ import io.quarkus.narayana.jta.runtime.TransactionManagerBuildTimeConfig.UnsafeM
import io.quarkus.resteasy.reactive.server.spi.MethodScannerBuildItem;
import io.quarkus.resteasy.reactive.server.spi.PreExceptionMapperHandlerBuildItem;
import io.quarkus.runtime.configuration.ConfigurationException;
import io.quarkus.vertx.http.deployment.FilterBuildItem;
import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem;
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.runtime.security.SecurityHandlerPriorities;
import jakarta.persistence.Entity;
import jakarta.persistence.PersistenceUnitTransactionType;
import org.eclipse.microprofile.config.spi.ConfigSource;
@ -272,6 +275,26 @@ class KeycloakProcessor {
);
}
@Record(ExecutionTime.STATIC_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));
}
}
@Record(ExecutionTime.STATIC_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));
}
}
@Record(ExecutionTime.STATIC_INIT)
@BuildStep(onlyIf = IsManagementEnabled.class)
@Consume(ConfigBuildItem.class)

View File

@ -32,6 +32,7 @@ import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.crypto.CryptoProvider;
import org.keycloak.common.crypto.FipsMode;
import org.keycloak.config.DatabaseOptions;
import org.keycloak.config.HttpOptions;
import org.keycloak.config.TruststoreOptions;
import org.keycloak.marshalling.Marshalling;
import org.keycloak.provider.Provider;
@ -40,6 +41,7 @@ import org.keycloak.provider.Spi;
import org.keycloak.quarkus.runtime.configuration.Configuration;
import org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider;
import org.keycloak.quarkus.runtime.integration.QuarkusKeycloakSessionFactory;
import org.keycloak.quarkus.runtime.services.RejectNonNormalizedPathFilter;
import org.keycloak.quarkus.runtime.storage.database.liquibase.FastServiceLocator;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.theme.ClasspathThemeProviderFactory;
@ -78,6 +80,10 @@ public class KeycloakRecorder {
return routingContext -> routingContext.response().end("Keycloak Management Interface");
}
public Handler<RoutingContext> getRejectNonNormalizedPathFilter() {
return !Configuration.isTrue(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS) ? new RejectNonNormalizedPathFilter() : null;
}
public void configureTruststore() {
String[] truststores = Configuration.getOptionalKcValue(TruststoreOptions.TRUSTSTORE_PATHS.getKey())
.map(s -> s.split(",")).orElse(new String[0]);

View File

@ -154,6 +154,8 @@ public final class HttpPropertyMappers implements PropertyMapperGrouping {
fromOption(HttpOptions.HTTP_METRICS_SLOS)
.isEnabled(MetricsPropertyMappers::metricsEnabled, MetricsPropertyMappers.METRICS_ENABLED_MSG)
.paramLabel("list of buckets")
.build(),
fromOption(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS)
.build()
);
}

View File

@ -0,0 +1,58 @@
/*
* Copyright 2025 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 com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.vertx.core.Handler;
import io.vertx.ext.web.RoutingContext;
import org.jboss.logging.Logger;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.services.util.ObjectMapperResolver;
import java.util.Objects;
/**
* This filter rejects all paths that need normalization as of RFC3986 or that have double slashes.
* This prevents path traversal that would circumvent path filtering applied by a proxy if that proxy would not apply
* normalization of the path. In addition to that, the reverse proxy might not be aware of the additional path
* of the double slashes that Keycloak performs.
*/
public class RejectNonNormalizedPathFilter implements Handler<RoutingContext> {
private static final Logger LOGGER = Logger.getLogger(RejectNonNormalizedPathFilter.class);
private final ObjectMapper MAPPER = ObjectMapperResolver.createStreamSerializer();
@Override
public void handle(RoutingContext routingContext) {
if (!Objects.equals(routingContext.request().path(), routingContext.normalizedPath())) {
LOGGER.debugf("Request with a non-normalized path blocked: %s vs. %s", routingContext.request().path(), routingContext.normalizedPath());
OAuth2ErrorRepresentation error = new OAuth2ErrorRepresentation("missingNormalization", "Request path not normalized");
routingContext.response().headers().add("Content-Type", "application/json; charset=UTF-8");
String jsonString;
try {
jsonString = MAPPER.writeValueAsString(error);
} catch (JsonProcessingException e) {
jsonString = "";
}
routingContext.response().setStatusCode(400).end(jsonString);
} else {
routingContext.next();
}
}
}

View File

@ -59,7 +59,21 @@ public class HttpDistTest {
assertThat("Some of the requests should be properly rejected", statusCodes, hasItem(503));
assertThat("None of the requests should throw an unhandled exception", statusCodes, not(hasItem(500)));
}
@Test
@Launch({"start-dev", "--log-level=INFO,org.keycloak.quarkus.runtime.services.RejectNonNormalizedPathFilter:debug", "--http-access-log-enabled=true"})
public void preventNonNormalizedURLs() {
when().get("/realms/master").then().statusCode(200);
when().get("/realms/xxx/../master").then().statusCode(400);
}
@Test
@Launch({"start-dev", "--http-access-log-enabled=true", "--http-accept-non-normalized-paths=true"})
public void allowNonNormalizedURLs() {
when().get("/realms/master").then().statusCode(200);
when().get("/realms/xxx/../master").then().statusCode(200);
}
@Test
@Launch({"start-dev", "--https-certificates-reload-period=wrong"})
public void testHttpCertificateReloadPeriod(CLIResult result) {

View File

@ -215,6 +215,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -285,6 +285,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -263,6 +263,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -286,6 +286,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -235,6 +235,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -258,6 +258,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -262,6 +262,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -285,6 +285,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -260,6 +260,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -283,6 +283,11 @@ Hostname v2:
HTTP(S):
--http-accept-non-normalized-paths <true|false>
DEPRECATED. If the server should accept paths that are not normalized
according to RFC3986 or that contain a double slash ('//'). While accepting
those requests might be relevant for legacy applications, it is recommended
to disable it to allow for more concise URL filtering. Default: false.
--http-enabled <true|false>
Enables the HTTP listener. Enabled by default in development mode. Typically
not enabled in production unless the server is fronted by a TLS termination

View File

@ -334,7 +334,11 @@ public final class RawKeycloakDistribution implements KeycloakDistribution {
}
private void waitForReadiness(String scheme, int port) throws MalformedURLException {
URL contextRoot = new URL(scheme + "://localhost:" + port + ("/" + relativePath + "/realms/master/").replace("//", "/"));
var myRelativePath = relativePath;
if (!myRelativePath.endsWith("/")) {
myRelativePath += "/";
}
URL contextRoot = new URL(scheme + "://localhost:" + port + myRelativePath + "realms/master/");
HttpURLConnection connection = null;
long startTime = System.currentTimeMillis();
Exception ex = null;