8000 Client uris validation improvements (26.8) by graziang · Pull Request #55040 · keycloak/keycloak · GitHub
[go: up one dir, main page]

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
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,30 @@ It is good practice to add as much protection as possible for these URIs to prev

Keycloak, with client policies, provides specific executors to protect the various client URIs:

* *Secure Client URIs*: A generic executor that enforces all client URIs to only accept HTTPS and to not accept any wildcards.
* *Secure Client URIs*: Enforces HTTPS without wildcards for all configured client URI fields, including sector identifier attributes and SAML endpoint and metadata URLs. Supplied fields are checked regardless of the client's protocol.
* *SAML Secure Client URIs*: Enforces HTTPS for all configured URI fields of SAML clients, including metadata and attributes for other protocols. It checks the stored configuration on creation, update, login and logout. Wildcard redirect URIs can be enabled through its configuration.
* *Secure Redirect URIs Enforcer*: An executor that allows different levels of validations for redirect URIs, such as IPv4 and IPv6 loopback addresses, HTTP scheme, wildcards, only permitted domains, or ensuring OAuth 2.1 compliance.
* *Secure Client URIs Pattern Executor*: This executor is the most generic and flexible for validating client URLs. It allows you to select from a list only the URIs you want to validate, and then validates them providing a list of permitted pattern using regular expressions. Explore this <<ssrf,dedicated section>> to see how this executor can be used to prevent an SSRF attack.

The URI validators read client fields from a common catalog. Each validator applies its own constraints:

* Basic client validation checks the syntax of all configured URI fields, including attributes for inactive protocols. It accepts relative URLs where supported, embedded `data:` logos, valid web origin markers and legacy origins without a scheme. CIBA notification endpoints also follow the realm SSL requirements.
* The *Trusted Hosts* client registration policy checks all configured client URI fields against its trusted hosts and domains. This includes logout endpoints, post-logout redirects, web origins, JWKS and request URIs, the CIBA notification endpoint, logo, policy, terms of service and sector identifier attributes, and SAML endpoints and metadata URLs. The check runs on registration and update when *Client URIs Must Match* is enabled.
* The *Secure Client URIs* executor checks all URI fields in the catalog. The *SAML Secure Client URIs* executor checks the same catalog for SAML clients. Use the *Secure Client URIs Pattern* executor to apply custom constraints to selected fields.

Trusted Hosts checks supplied URI fields regardless of the client's protocol. Web origins without a scheme are checked against their host.
It preserves web origin markers `+`, `*` and `null`, post-logout markers `+` and `-`, and embedded `data:` logos.
The `+` marker refers to the client's redirect URIs, which Trusted Hosts checks separately. Explicit URLs supplied alongside a marker are also checked.
For front-channel logout and its home URL fallback, a scheme-relative URL such as `//example.org/logout` is checked against `example.org`, even if the client has a different root URL.
Relative logo URLs are checked against the host of the {project_name} frontend URL, where the browser loads them, independently of the client's root URL. The configured logo value is preserved.
Redirect paths retain the runtime resolution against the root URL, including paths starting with `//`.

The Secure Client URIs executor resolves `+` in web origins and post-logout redirects using the client's redirect URIs; `-` disables post-logout redirects.
The Secure Client URIs Pattern executor matches configured values literally, including `+`, `-`, and `*`, in the configured field order.
Include patterns for these literal values when your policy allows them. Selected attributes are checked regardless of the client's protocol.

The sector identifier URL stored in a pairwise protocol mapper is validated separately and is outside this catalog.

[[_full_scope_disabled_policy]]
== Enforcing Full Scope Allowed to be disabled

Expand Down
17 changes: 17 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_8_3.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
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.

=== Validation of client URI fields
// https://github.com/keycloak/keycloak/issues/54046

Client URI validation and client policies now share a catalog of URI fields. Existing policy configuration keys, selected field names, regular expressions, and wildcard options retain their meaning.
The following additional checks can reject previously accepted client configurations:

* With *Client URIs Must Match* enabled, the *Trusted Hosts* client registration policy checks all supplied URI fields on creation and update, including post-logout redirects, logout endpoints, web origins, JWKS and request URIs, CIBA notification endpoints, logo, policy, terms of service, sector identifier attributes, and SAML endpoints and metadata URLs. Add any legitimate external hosts to the trusted-host list before updating these clients. Post-logout `*` is rejected because it permits redirects to any host. Explicit URLs accompanying `+` or `-` are also checked.
* The *Secure Client URIs* executor additionally requires HTTPS without wildcards for sector identifier attributes and SAML endpoints and metadata URLs, regardless of the configured client protocol.
* The *SAML Secure Client URIs* executor checks all catalog fields on SAML clients, including stored metadata during login and logout. Replace HTTP URLs with HTTPS URLs or remove unused values before upgrading with this executor enabled or enabling it for the first time. Stored `*` or `null` web origins and embedded `data:` logos also fail its HTTPS check, even if they are unused by SAML. The `allow-wildcard-redirects` option still applies only to redirect URIs.
* General client validation additionally checks web origins, sector identifier attributes, and supplied SAML URL attributes even on clients using another protocol. Malformed or disallowed values must be corrected before saving or importing those clients.

These changes close gaps where a URL could evade a policy simply because it was stored in a different client field. A policy configuration migration is not required, but continuing to accept those previously unchecked URLs would leave the gaps open.
Relative URLs, embedded `data:` logos, and web origin markers `+`, `*`, and `null` retain their meaning in general validation and Trusted Hosts. The HTTPS executors still enforce their own scheme restrictions.
General validation also preserves the legacy web origin literals `${authAdminUrl}`, `${authBaseUrl}`, and `file://`. These values remain subject to the host, HTTPS, and pattern restrictions of any enabled client policies.
Trusted Hosts resolves relative logo URLs against the {project_name} frontend URL and checks that host against its allowlist. The client's root URL does not determine where the browser loads a logo.
The pattern executor continues to match literal configured values, including markers and relative URLs, and respects an explicit field selection. Sector identifier URLs in pairwise protocol mappers remain subject to their separate validation.

=== CIBA requires client authentication (CVE-2026-109082)
// https://github.com/keycloak/keycloak/issues/53937

Expand Down
14 changes: 11 additions & 3 deletions docs/guides/securing-apps/client-registration.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,17 @@ when `Consent Required` policy is present etc.

Currently we have these policy implementations:

* Trusted Hosts Policy - You can configure list of trusted hosts and trusted domains. Request to Client Registration Service can be sent just from those hosts or domains.
Request sent from some untrusted IP will be rejected. URLs of newly registered client must also use just those trusted hosts or domains. For example it won't be allowed
to set `Redirect URI` of client pointing to some untrusted host. By default, there is not any whitelisted host, so anonymous client registration is de-facto disabled.
* Trusted Hosts Policy - Configures trusted hosts and domains for registration requests and client URLs.
When *Client URIs Must Match* is enabled, all supplied client URI fields must use a trusted host or domain on both registration and update.
This includes root, home and admin URLs, redirect and post-logout redirect URIs, logout endpoints, web origins, JWKS and request URIs,
the CIBA notification endpoint, logo, policy, terms of service and sector identifier attributes, and SAML endpoints and metadata URLs.
The policy checks supplied fields regardless of the client's protocol. Add external hosts, such as a logo CDN, to the policy when needed.
Web origin markers `+`, `*` and `null`, post-logout markers `+` and `-`, and embedded `data:` logos remain accepted.
Redirect URIs referenced by `+` are checked separately; explicit URLs supplied alongside the marker are also checked.
Web origins without a scheme and scheme-relative browser URLs, including front-channel logout and its home URL fallback, are checked against their own host.
Relative logo URLs are checked against the {project_name} frontend host instead of the client's root URL, and their configured values are preserved.
Redirect paths retain the runtime resolution against the client's root URL, including paths starting with `//`.
By default, the trusted-host list is empty, so anonymous registration is disabled.

NOTE: {project_name} normalizes loopback addresses to `localhost` to avoid platform-specific
reverse-DNS differences (for example on Windows a reverse lookup of loopback may return the numeric IP rather than `localhost`). All other trusted domains must be based upon the primary reverse-DNS lookup returned by the platform.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
package org.keycloak.services.clientpolicy.executor;

import java.net.URI;
import java.util.Collection;
import java.util.Map;
import java.util.LinkedHashSet;
import java.util.List;

import org.keycloak.OAuthErrorException;
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
Expand All @@ -35,6 +35,7 @@
import org.keycloak.services.clientpolicy.context.SamlAuthnRequestContext;
import org.keycloak.services.clientpolicy.context.SamlLogoutRequestContext;
import org.keycloak.utils.StringUtil;
import org.keycloak.validation.ClientUriField;

import com.fasterxml.jackson.annotation.JsonProperty;

Expand Down Expand Up @@ -91,10 +92,10 @@ public String getProviderId() {
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case REGISTERED -> {
confirmSecureUris(((AdminClientRegisteredContext)context).getTargetClient());
confirmSecureUris(((AdminClientRegisteredContext)context).getTargetClient(), OAuthErrorException.INVALID_CLIENT_METADATA);
}
case UPDATED -> {
confirmSecureUris(((AdminClientUpdatedContext)context).getTargetClient());
confirmSecureUris(((AdminClientUpdatedContext)context).getTargetClient(), OAuthErrorException.INVALID_CLIENT_METADATA);
}
case SAML_AUTHN_REQUEST -> {
confirmLoginRedirectUri((SamlAuthnRequestContext) context);
Expand All @@ -110,37 +111,31 @@ private void confirmLoginRedirectUri(SamlAuthnRequestContext context) throws Cli
URI uri = request.getAssertionConsumerServiceURL();
if (uri != null) {
confirmSecureUri(uri.toString(), "AssertionConsumerServiceURL", OAuthErrorException.INVALID_REQUEST);
} else {
// use configuration for login, check URLs for login are all secure
ClientModel client = context.getClient();
confirmSecureUri(client.getManagementUrl(),
"Master SAML Processing URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE),
"Assertion Consumer Service POST Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_REDIRECT_ATTRIBUTE),
"Assertion Consumer Service Redirect Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_ARTIFACT_ATTRIBUTE),
"Artifact Binding URL", OAuthErrorException.INVALID_REQUEST);
}
confirmSecureUris(context.getClient(), OAuthErrorException.INVALID_REQUEST);
}

private void confirmLogoutRedirectUri(SamlLogoutRequestContext context) throws ClientPolicyException {
// check logout URLs are all secure
ClientModel client = context.getClient();
confirmSecureUri(client.getManagementUrl(),
"Master SAML Processing URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE),
"Logout Service POST Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE),
"Logout Service ARTIFACT Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE),
"Logout Service Redirect Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE),
"Logout Service SOAP Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUris(context.getClient(), OAuthErrorException.INVALID_REQUEST);
}

private void confirmSecureUri(String uri, String uriType) throws ClientPolicyException {
confirmSecureUri(uri, uriType, OAuthErrorException.INVALID_CLIENT_METADATA);
private String getUriType(ClientUriField field) {
return switch (field) {
case ROOT_URL -> "Root URL";
case ADMIN_URL -> "Master SAML Processing URL";
case BASE_URL -> "Home URL";
case REDIRECT_URIS -> "Valid redirect URIs";
case SAML_ASSERTION_CONSUMER_URL_POST_URI -> "Assertion Consumer Service POST Binding URL";
case SAML_ASSERTION_CONSUMER_URL_REDIRECT_URI -> "Assertion Consumer Service Redirect Binding URL";
case SAML_ASSERTION_CONSUMER_URL_ARTIFACT_URI -> "Artifact Binding URL";
case SAML_SINGLE_LOGOUT_SERVICE_URL_POST_URI -> "Logout Service POST Binding URL";
case SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_URI -> "Logout Service ARTIFACT Binding URL";
case SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_URI -> "Logout Service Redirect Binding URL";
case SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_URI -> "Logout Service SOAP Binding URL";
case SAML_ARTIFACT_RESOLUTION_SERVICE_URL_URI -> "Artifact Resolution Service";
case SAML_METADATA_DESCRIPTOR_URL -> "Metadata descriptor URL";
default -> field.getName();
};
}

private void confirmSecureUri(String uri, String uriType, String error) throws ClientPolicyException {
Expand All @@ -153,53 +148,35 @@ private void confirmSecureUri(String uri, String uriType, String error) throws C
}
}

private void confirmNoWildcard(String uri, String uriType) throws ClientPolicyException {
private void confirmNoWildcard(String uri, String uriType, String error) throws ClientPolicyException {
if (uri.endsWith("*") && !uri.contains("?") && !uri.contains("#")) {
throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT_METADATA,
throw new ClientPolicyException(error,
"Unsecure wildcard redirect " + uri + " for " + uriType);
}
}

private void confirmRedirectUris(Collection<String> uris, String uriType) throws ClientPolicyException {
if (uris == null) {
return;
}

for (String uri : uris) {
confirmSecureUri(uri, uriType);
if (!config.isAllowWildcardResirects()) {
confirmNoWildcard(uri, uriType);
}
}
}

private void confirmSecureUris(ClientModel client) throws ClientPolicyException {
private void confirmSecureUris(ClientModel client, String error) throws ClientPolicyException {
if (!SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
return;
}

confirmSecureUri(client.getRootUrl(), "Root URL");
confirmSecureUri(client.getManagementUrl(), "Master SAML Processing URL");
confirmSecureUri(client.getBaseUrl(), "Home URL");

confirmRedirectUris(RedirectUtils.resolveValidRedirects(session, client.getRootUrl(), client.getRedirectUris()),
"Valid redirect URIs");

Map<String, String> attrs = Map.of(
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE, "Assertion Consumer Service POST Binding URL",
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_REDIRECT_ATTRIBUTE, "Assertion Consumer Service Redirect Binding URL",
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_ARTIFACT_ATTRIBUTE, "Artifact Binding URL",
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, "Logout Service POST Binding URL",
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE, "Logout Service ARTIFACT Binding URL",
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, "Logout Service Redirect Binding URL",
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, "Logout Service SOAP Binding URL",
SamlProtocol.SAML_ARTIFACT_RESOLUTION_SERVICE_URL_ATTRIBUTE, "Artifact Resolution Service"
);

if (client.getAttributes() != null) {
for (Map.Entry<String, String> attr : attrs.entrySet()) {
confirmSecureUri(client.getAttribute(attr.getKey()), attr.getValue());
ClientUriField.validate(client, (field, values) -> {
if (field == ClientUriField.REDIRECT_URIS) {
values = List.copyOf(RedirectUtils.resolveValidRedirects(session, client.getRootUrl(), new LinkedHashSet<>(values)));
} else if (field == ClientUriField.WEB_ORIGINS || field == ClientUriField.POST_LOGOUT_REDIRECT_URIS) {
if (field == ClientUriField.POST_LOGOUT_REDIRECT_URIS) {
values = values.stream().filter(value -> !"-".equals(value)).toList();
}
values = List.copyOf(RedirectUtils.resolveUrlsWithRedirects(session, values, client.getRootUrl(),
ClientUriField.REDIRECT_URIS.getValues(client), field == ClientUriField.WEB_ORIGINS));
}
}
String uriType = getUriType(field);
for (String value : values) {
confirmSecureUri(value, uriType, error);
if (field == ClientUriField.REDIRECT_URIS && !config.isAllowWildcardResirects()) {
confirmNoWildcard(value, uriType, error);
}
}
});
}
}
Loading
Loading
0