Skip to content
Merged
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
14 changes: 14 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ For anyone implementing a custom federated user authentication identity provider
by Keycloak or `AbstractIdentityProvider` you need to update your implementation to implement
the new `UserAuthenticationIdentityProvider` interface (all methods remain the same, they have just been moved).

=== 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 previoius 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
Expand Down Expand Up @@ -104,6 +112,12 @@ This behavior is deprecated and will be removed in a future version of Keycloak.
The inner class `AuthenticationManager.AuthResult` in the `keycloak-services` module is now a record.
The getter methods like `getSession()` have been deprecated in favor of the `session()` accessors.

=== 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,12 @@ public enum ClientAuth {
.description("Service level objectives for HTTP server requests. Use this instead of the default histogram, or use it in combination to add additional buckets. " +
"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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really want to make it configurable? It's a breaking change but to fix a security issue. What would be a real and valid use case for the non-normalized paths?

Sorry if this has been already discussed somewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What would be a real and valid use case for the non-normalized paths?

I would say adding .. to the path is not a valid use case, maybe someone used it as a workaround for some issue in a machine-to-machine communication.

The most common thing I could think of is a double // in the URL, where someone might have concatenated URLs, and they end up with two //. We would break those setups. And to back out of such a setup, there is this option to revert it. And it is deprecated, so we can remove it again soon - like in 27.0

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd say if someone uses // it's probably an oversight and a misconfiguration. So having it configurable is more like a convenience feature. But it's not a blocker for me, let's keep it there.

.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();

}
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@
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;
Expand Down Expand Up @@ -272,6 +275,26 @@ void configureRedirectForRootPath(BuildProducer<RouteBuildItem> routes,
);
}

@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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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;
Expand All @@ -40,6 +41,7 @@
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;
Expand Down Expand Up @@ -78,6 +80,10 @@ public Handler<RoutingContext> getManagementHandler() {
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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ public List<PropertyMapper<?>> getPropertyMappers() {
.build(),
fromFeature(Profile.Feature.HTTP_OPTIMIZED_SERIALIZERS)
.to("quarkus.rest.jackson.optimization.enable-reflection-free-serializers")
.build(),
fromOption(HttpOptions.HTTP_ACCEPT_NON_NORMALIZED_PATHS)
.build()
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,21 @@ public void maxQueuedRequestsTest(KeycloakDistribution dist) {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,11 @@ private void waitForReadiness() throws MalformedURLException {
}

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;
Expand Down
Loading