From c7fedb77e3c828f18be5844ec12ef8e8be55bea8 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 19 Aug 2025 09:42:33 -0300 Subject: [PATCH] Skip processing HEAD requests for action tokens Closes #41834 Signed-off-by: Pedro Igor --- .../DefaultSecurityHeadersProvider.java | 13 ++++++++-- .../resources/LoginActionsService.java | 14 ++++++++++ ...onUpdateEmailTestWithVerificationTest.java | 26 ++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/services/src/main/java/org/keycloak/headers/DefaultSecurityHeadersProvider.java b/services/src/main/java/org/keycloak/headers/DefaultSecurityHeadersProvider.java index aa49f89f4cf..78b7b69447c 100644 --- a/services/src/main/java/org/keycloak/headers/DefaultSecurityHeadersProvider.java +++ b/services/src/main/java/org/keycloak/headers/DefaultSecurityHeadersProvider.java @@ -30,6 +30,8 @@ import jakarta.ws.rs.core.MultivaluedMap; import java.util.Collections; import java.util.Map; +import static jakarta.ws.rs.HttpMethod.HEAD; +import static jakarta.ws.rs.HttpMethod.OPTIONS; import static org.keycloak.models.BrowserSecurityHeaders.CONTENT_SECURITY_POLICY; public class DefaultSecurityHeadersProvider implements SecurityHeadersProvider { @@ -147,10 +149,17 @@ public class DefaultSecurityHeadersProvider implements SecurityHeadersProvider { status == 400 || status == 401 || status == 403 || status == 404) { return true; } - if (requestContext.getMethod().equalsIgnoreCase("OPTIONS")) { - return true; + + String method = requestContext.getMethod().toUpperCase(); + + switch (method) { + case OPTIONS: + return true; + case HEAD: + return status == 200; } } + return false; } diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index dc672593461..af517fab1a1 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -16,6 +16,7 @@ */ package org.keycloak.services.resources; +import jakarta.ws.rs.HEAD; import org.jboss.logging.Logger; import org.keycloak.common.Profile; import org.keycloak.common.Profile.Feature; @@ -548,6 +549,19 @@ public class LoginActionsService { return handleActionToken(key, execution, clientId, tabId, clientData, null); } + /** + * Skip processing {@link jakarta.ws.rs.HttpMethod#HEAD} requests for action tokens + * as they are usually used by mail servers to validate links. The actual request will eventually be + * processed by the {@link #executeActionToken} method. + * + * @return a {@link Response.Status#OK} response with no message body + */ + @Path("action-token") + @HEAD + public Response executeActionTokenHead() { + return Response.ok().build(); + } + protected Response handleActionToken(String tokenString, String execution, String clientId, String tabId, String clientData, TriFunction, T, ActionTokenContext, Response> preHandleToken) { T token; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java index fad6a589ced..0b1688b7f24 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateEmailTestWithVerificationTest.java @@ -30,8 +30,10 @@ import java.io.IOException; import java.util.List; import java.util.UUID; +import jakarta.ws.rs.core.Response.Status; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; import org.jboss.arquillian.graphene.page.Page; -import org.jetbrains.annotations.NotNull; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; @@ -39,6 +41,7 @@ import org.keycloak.admin.client.resource.AuthenticationManagementResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.authentication.actiontoken.updateemail.UpdateEmailActionToken; import org.keycloak.authentication.requiredactions.UpdateEmail; +import org.keycloak.broker.provider.util.SimpleHttp.Response; import org.keycloak.events.Details; import org.keycloak.events.EventType; import org.keycloak.models.UserModel; @@ -48,6 +51,7 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserSessionRepresentation; +import org.keycloak.testsuite.broker.util.SimpleHttpDefault; import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.util.GreenMailRule; @@ -174,6 +178,26 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR assertTrue(ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost").getRequiredActions().contains(UserModel.RequiredAction.UPDATE_EMAIL.name())); } + @Test + public void testSkipHeadRequestWhenFollowingVerificationLink() throws MessagingException, IOException { + oauth.openLoginForm(); + loginPage.login("test-user@localhost", "password"); + + updateEmailPage.assertCurrent(); + updateEmailPage.changeEmail("new@localhost"); + + String confirmationLink = fetchEmailConfirmationLink("new@localhost"); + + try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + try (Response response = SimpleHttpDefault.doHead(confirmationLink, httpClient).asResponse()) { + assertEquals(Status.OK.getStatusCode(), response.getStatus()); + } + } + + driver.navigate().to(confirmationLink); + infoPage.assertCurrent(); + } + @Test public void testForceEmailVerification() throws MessagingException, IOException { // disables verify email at the realm level