8000 [CVE-2026-37977] CORS Access-Control-Allow-Origin reflected from unverified JWT azp claim on UMA token endpoint by pedroigor · Pull Request #49512 · 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 @@ -31,8 +31,6 @@
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.ClientModel;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
Expand Down Expand Up @@ -67,14 +65,12 @@ public Response process(Context context) {
AccessToken accessToken = Tokens.getAccessToken(session);

if (accessToken == null) {
try {
// In case the access token is invalid because it's expired or the user is disabled, identify the client
// from the access token anyway in order to set correct CORS headers.
AccessToken invalidToken = new JWSInput(accessTokenString).readJsonContent(AccessToken.class);
ClientModel client = realm.getClientByClientId(invalidToken.getIssuedFor());
cors.checkAllowedOrigins(session, client);
event.client(client);
} catch (JWSInputException ignore) {
// This allows SPA clients to receive CORS headers on error responses (e.g. expired tokens, disabled users)
AccessToken signatureVerifiedToken = session.tokens().decode(accessTokenString, AccessToken.class);
if (signatureVerifiedToken != null) {
ClientModel clientFromToken = realm.getClientByClientId(signatureVerifiedToken.getIssuedFor());
cors.checkAllowedOrigins(session, clientFromToken);
event.client(clientFromToken);
}
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Invalid bearer token", Response.Status.BAD_REQUEST);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,10 +447,69 @@ public void testCORSHeadersInFailedRptRequest() throws Exception {

try (CloseableHttpResponse response = oauth.httpClient().get().execute(post)) {
assertEquals(400, response.getStatusLine().getStatusCode());
// Signature is valid (token was issued by Keycloak), so CORS headers should be set
// even though token validation failed due to the user being disabled (KEYCLOAK-15429)
assertEquals("http://localhost", response.getFirstHeader("Access-Control-Allow-Origin").getValue());
}
}

@Test
public void testCORSHeadersSetForExpiredToken() throws Exception {
AccessTokenResponse accessTokenResponse = getAuthzClient().obtainAccessToken("marta", "password");

PermissionRequest permissions = new PermissionRequest("Resource A", "ScopeA", "ScopeB");
String ticket = getAuthzClient().protection().permission().create(Arrays.asList(permissions)).getTicket();

String tokenEndpoint = getAuthzClient().getServerConfiguration().getTokenEndpoint();
HttpPost post = new HttpPost(tokenEndpoint);
post.addHeader("Origin", "http://localhost");
post.addHeader("Authorization", "Bearer " + accessTokenResponse.getToken());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.UMA_GRANT_TYPE));
parameters.add(new BasicNameValuePair("ticket", ticket));

UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
post.setEntity(formEntity);

// Advance server time past access token lifespan to force expiration
try {
timeOffSet.set(600);

try (CloseableHttpResponse response = oauth.httpClient().get().execute(post)) {
assertEquals(400, response.getStatusLine().getStatusCode());
// Signature is still valid on expired tokens, so CORS headers should be set (KEYCLOAK-15429)
assertEquals("http://localhost", response.getFirstHeader("Access-Control-Allow-Origin").getValue());
}
} finally {
timeOffSet.set(0);
}
}

@Test
public void testCORSHeadersNotSetForForgedToken() throws Exception {
// CVE-2026-37977: CORS headers must not be set when the JWT signature is invalid
PermissionRequest permissions = new PermissionRequest("Resource A", "ScopeA", "ScopeB");
String ticket = getAuthzClient().protection().permission().create(Arrays.asList(permissions)).getTicket();

String tokenEndpoint = getAuthzClient().getServerConfiguration().getTokenEndpoint();
HttpPost post = new HttpPost(tokenEndpoint);
post.addHeader("Origin", "http://localhost");
// Forged token: valid structure but signed with an unknown key
post.addHeader("Authorization", "Bearer eyJhbGciOiJSUzI1NiJ9.eyJhenAiOiJyZXNvdXJjZS1zZXJ2ZXItdGVzdCJ9.invalidsignature");
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.UMA_GRANT_TYPE));
parameters.add(new BasicNameValuePair("ticket", ticket));

UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
post.setEntity(formEntity);

try (CloseableHttpResponse response = oauth.httpClient().get().execute(post)) {
assertEquals(400, response.getStatusLine().getStatusCode());
// Invalid signature: CORS headers must not be set based on unverified claims
assertNull(response.getFirstHeader("Access-Control-Allow-Origin"));
}
}

@Test
public void testRefreshRpt() {
AccessTokenResponse accessTokenResponse = getAuthzClient().obtainAccessToken("marta", "password");
Expand Down
Loading
0