Skip to content

Commit 2a0ce46

Browse files
authored
Prevent frontend endpoint redirect to admin endpoint (keycloak#38464)
Closes keycloak#38463 Signed-off-by: Václav Muzikář <[email protected]>
1 parent a27d666 commit 2a0ce46

File tree

4 files changed

+99
-10
lines changed

4 files changed

+99
-10
lines changed

services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.keycloak.jose.jws.JWSInput;
2828
import org.keycloak.jose.jws.JWSInputException;
2929
import org.keycloak.models.KeycloakSession;
30+
import org.keycloak.models.KeycloakUriInfo;
3031
import org.keycloak.models.RealmModel;
3132
import org.keycloak.protocol.oidc.TokenManager;
3233
import org.keycloak.representations.AccessToken;
@@ -93,14 +94,18 @@ public static UriBuilder adminBaseUrl(UriBuilder base) {
9394
@GET
9495
@Operation(hidden = true)
9596
public Response masterRealmAdminConsoleRedirect() {
97+
String requestUrl = session.getContext().getUri().getRequestUri().toString();
98+
KeycloakUriInfo adminUriInfo = session.getContext().getUri(UrlType.ADMIN);
99+
String adminUrl = adminUriInfo.getBaseUri().toString();
100+
String localAdminUrl = session.getContext().getUri(UrlType.LOCAL_ADMIN).getBaseUri().toString();
96101

97-
if (!isAdminConsoleEnabled()) {
102+
if (!isAdminConsoleEnabled() || (!requestUrl.startsWith(adminUrl) && !requestUrl.startsWith(localAdminUrl))) {
98103
return Response.status(Response.Status.NOT_FOUND).build();
99104
}
100105

101106
RealmModel master = new RealmManager(session).getKeycloakAdminstrationRealm();
102107
return Response.status(302).location(
103-
session.getContext().getUri(UrlType.ADMIN).getBaseUriBuilder().path(AdminRoot.class).path(AdminRoot.class, "getAdminConsole").path("/").build(master.getName())
108+
adminUriInfo.getBaseUriBuilder().path(AdminRoot.class).path(AdminRoot.class, "getAdminConsole").path("/").build(master.getName())
104109
).build();
105110
}
106111

test-framework/core/src/main/java/org/keycloak/testframework/annotations/InjectHttpClient.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
@Retention(RetentionPolicy.RUNTIME)
99
@Target(ElementType.FIELD)
1010
public @interface InjectHttpClient {
11-
11+
boolean followRedirects() default true;
1212
}

test-framework/core/src/main/java/org/keycloak/testframework/http/HttpClientSupplier.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import org.apache.http.impl.client.HttpClientBuilder;
66
import org.keycloak.testframework.annotations.InjectHttpClient;
77
import org.keycloak.testframework.injection.InstanceContext;
8-
import org.keycloak.testframework.injection.LifeCycle;
98
import org.keycloak.testframework.injection.RequestedInstance;
109
import org.keycloak.testframework.injection.Supplier;
1110

@@ -15,7 +14,13 @@ public class HttpClientSupplier implements Supplier<HttpClient, InjectHttpClient
1514

1615
@Override
1716
public HttpClient getValue(InstanceContext<HttpClient, InjectHttpClient> instanceContext) {
18-
return HttpClientBuilder.create().build();
17+
HttpClientBuilder builder = HttpClientBuilder.create();
18+
19+
if (!instanceContext.getAnnotation().followRedirects()) {
20+
builder.disableRedirectHandling();
21+
}
22+
23+
return builder.build();
1924
}
2025

2126
@Override
@@ -27,11 +32,6 @@ public void close(InstanceContext<HttpClient, InjectHttpClient> instanceContext)
2732
}
2833
}
2934

30-
@Override
31-
public LifeCycle getDefaultLifecycle() {
32-
return LifeCycle.GLOBAL;
33-
}
34-
3535
@Override
3636
public boolean compatible(InstanceContext<HttpClient, InjectHttpClient> a, RequestedInstance<HttpClient, InjectHttpClient> b) {
3737
return true;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
* and other contributors as indicated by the @author tags.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.keycloak.tests.admin;
19+
20+
import org.apache.http.HttpResponse;
21+
import org.apache.http.client.HttpClient;
22+
import org.apache.http.client.methods.HttpGet;
23+
import org.apache.http.util.EntityUtils;
24+
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.params.ParameterizedTest;
26+
import org.junit.jupiter.params.provider.ValueSource;
27+
import org.keycloak.testframework.annotations.InjectHttpClient;
28+
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
29+
import org.keycloak.testframework.server.KeycloakServerConfig;
30+
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
31+
32+
import java.util.Arrays;
33+
import java.util.Map;
34+
35+
import static org.hamcrest.MatcherAssert.assertThat;
36+
import static org.hamcrest.Matchers.containsString;
37+
import static org.hamcrest.Matchers.not;
38+
import static org.hamcrest.Matchers.startsWith;
39+
import static org.junit.jupiter.api.Assertions.assertEquals;
40+
import static org.junit.jupiter.api.Assertions.assertFalse;
41+
42+
/**
43+
* @author Vaclav Muzikar <[email protected]>
44+
*/
45+
@KeycloakIntegrationTest(config = AdminRootTest.AdminUrlConfig.class)
46+
public class AdminRootTest {
47+
// This might not be robust enough. If something made KC on a different port, this would fail.
48+
private static final String HOSTNAME = "http://127.0.0.1.nip.io:8080";
49+
private static final String HOSTNAME_ADMIN = "http://admin.127.0.0.1.nip.io:8080";
50+
private static final String HOSTNAME_LOCAL_ADMIN = "http://localhost:8080";
51+
52+
@InjectHttpClient(followRedirects = false)
53+
private HttpClient client;
54+
55+
@ParameterizedTest
56+
@ValueSource(strings = {HOSTNAME_ADMIN, HOSTNAME_LOCAL_ADMIN})
57+
public void testRedirect(String hostname) throws Exception {
58+
HttpResponse response = client.execute(new HttpGet(hostname + "/admin"));
59+
60+
assertEquals(302, response.getStatusLine().getStatusCode());
61+
assertThat(response.getFirstHeader("Location").getValue(), startsWith(HOSTNAME_ADMIN + "/admin/master/console"));
62+
}
63+
64+
@Test
65+
public void testNoRedirectWithFrontendUrl() throws Exception {
66+
HttpResponse response = client.execute(new HttpGet(HOSTNAME + "/admin"));
67+
68+
assertEquals(404, response.getStatusLine().getStatusCode());
69+
70+
// Check for leaks of hostname-admin in headers and body
71+
assertFalse(Arrays.stream(response.getAllHeaders()).anyMatch(header -> header.getValue().contains(HOSTNAME_ADMIN)));
72+
assertThat(EntityUtils.toString(response.getEntity()), not(containsString(HOSTNAME_ADMIN))); // just in case of a JS redirect
73+
}
74+
75+
public static class AdminUrlConfig implements KeycloakServerConfig {
76+
@Override
77+
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
78+
return config.options(Map.of(
79+
"hostname", HOSTNAME,
80+
"hostname-admin", HOSTNAME_ADMIN
81+
));
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)