KEYCLOAK-12190 Add validation for client root and base URLs

This commit is contained in:
stianst 2019-12-13 09:34:28 +01:00 committed by Stian Thorgersen
parent 27f6f7bf40
commit 7545749632
19 changed files with 506 additions and 8 deletions

View file

@ -58,6 +58,7 @@ public interface Errors {
String INVALID_FORM = "invalid_form";
String INVALID_CONFIG = "invalid_config";
String EXPIRED_CODE = "expired_code";
String INVALID_INPUT = "invalid_input";
String REGISTRATION_DISABLED = "registration_disabled";
String RESET_CREDENTIAL_DISABLED = "reset_credential_disabled";

View file

@ -133,6 +133,9 @@ import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageProviderModel;
import org.keycloak.storage.federated.UserFederatedStorageProvider;
import org.keycloak.util.JsonSerialization;
import org.keycloak.validation.ClientValidationContext;
import org.keycloak.validation.ClientValidationProvider;
import org.keycloak.validation.ClientValidationUtil;
public class RepresentationToModel {
@ -1235,6 +1238,10 @@ public class RepresentationToModel {
for (ClientRepresentation resourceRep : rep.getClients()) {
ClientModel app = createClient(session, realm, resourceRep, false, mappedFlows);
appMap.put(app.getClientId(), app);
ClientValidationUtil.validate(session, app, false, c -> {
throw new RuntimeException("Invalid client " + app.getClientId() + ": " + c.getError());
});
}
return appMap;
}

View file

@ -0,0 +1,39 @@
/*
* Copyright 2019 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.validation;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
public interface ClientValidationContext {
enum Event {
CREATE,
UPDATE
}
Event getEvent();
KeycloakSession getSession();
ClientModel getClient();
String getError();
ClientValidationContext invalid(String error);
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2019 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.validation;
import org.keycloak.provider.Provider;
public interface ClientValidationProvider extends Provider {
void validate(ClientValidationContext context);
@Override
default void close() {
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 2019 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.validation;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderFactory;
public interface ClientValidationProviderFactory extends ProviderFactory<ClientValidationProvider> {
@Override
default void init(Config.Scope config) {
}
@Override
default void postInit(KeycloakSessionFactory factory) {
}
@Override
default void close() {
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2019 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.validation;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ClientValidationSPI implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "clientValidation";
}
@Override
public Class<? extends Provider> getProviderClass() {
return ClientValidationProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ClientValidationProviderFactory.class;
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright 2019 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.validation;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import javax.ws.rs.BadRequestException;
public class ClientValidationUtil {
public static void validate(KeycloakSession session, ClientModel client, boolean create, ErrorHandler errorHandler) throws BadRequestException {
ClientValidationProvider provider = session.getProvider(ClientValidationProvider.class);
if (provider != null) {
DefaultClientValidationContext context = new DefaultClientValidationContext(create ? ClientValidationContext.Event.CREATE : ClientValidationContext.Event.UPDATE, session, client);
provider.validate(context);
if (!context.isValid()) {
errorHandler.onError(context);
}
}
}
public interface ErrorHandler {
void onError(ClientValidationContext context);
}
private static class DefaultClientValidationContext implements ClientValidationContext {
private Event event;
private KeycloakSession session;
private ClientModel client;
private String error;
public DefaultClientValidationContext(Event event, KeycloakSession session, ClientModel client) {
this.event = event;
this.session = session;
this.client = client;
}
public boolean isValid() {
return error == null;
}
public String getError() {
return error;
}
@Override
public Event getEvent() {
return event;
}
@Override
public KeycloakSession getSession() {
return session;
}
@Override
public ClientModel getClient() {
return client;
}
@Override
public ClientValidationContext invalid(String error) {
this.error = error;
return this;
}
}
}

View file

@ -78,3 +78,4 @@ org.keycloak.crypto.HashSpi
org.keycloak.vault.VaultSpi
org.keycloak.crypto.CekManagementSpi
org.keycloak.crypto.ContentEncryptionSpi
org.keycloak.validation.ClientValidationSPI

View file

@ -30,6 +30,7 @@ import org.keycloak.services.clientregistration.policy.RegistrationAuth;
import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.validation.ValidationMessages;
import org.keycloak.validation.ClientValidationUtil;
import javax.ws.rs.core.Response;
@ -81,6 +82,11 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist
client.setSecret(clientModel.getSecret());
ClientValidationUtil.validate(session, clientModel, true, c -> {
session.getTransactionManager().setRollbackOnly();
throw new ErrorResponseException(ErrorCodes.INVALID_CLIENT_METADATA, c.getError(), Response.Status.BAD_REQUEST);
});
String registrationAccessToken = ClientRegistrationTokenUtils.updateRegistrationAccessToken(session, clientModel, registrationAuth);
client.setRegistrationAccessToken(registrationAccessToken);
@ -140,6 +146,11 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist
RepresentationToModel.updateClient(rep, client);
RepresentationToModel.updateClientProtocolMappers(rep, client);
ClientValidationUtil.validate(session, client, false, c -> {
session.getTransactionManager().setRollbackOnly();
throw new ErrorResponseException(ErrorCodes.INVALID_CLIENT_METADATA, c.getError(), Response.Status.BAD_REQUEST);
});
rep = ModelToRepresentation.toRepresentation(client, session);
if (auth.isRegistrationAccessToken()) {

View file

@ -24,6 +24,7 @@ import org.keycloak.authorization.admin.AuthorizationService;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.events.Errors;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.AuthenticatedClientSessionModel;
@ -63,6 +64,7 @@ import org.keycloak.services.validation.ClientValidator;
import org.keycloak.services.validation.PairwiseClientValidator;
import org.keycloak.services.validation.ValidationMessages;
import org.keycloak.utils.ProfileHelper;
import org.keycloak.validation.ClientValidationUtil;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
@ -151,10 +153,16 @@ public class ClientResource {
try {
updateClientFromRep(rep, client, session);
ClientValidationUtil.validate(session, client, false, c -> {
session.getTransactionManager().setRollbackOnly();
throw new ErrorResponseException(Errors.INVALID_INPUT ,c.getError(), Response.Status.BAD_REQUEST);
});
adminEvent.operation(OperationType.UPDATE).resourcePath(session.getContext().getUri()).representation(rep).success();
return Response.noContent().build();
} catch (ModelDuplicateException e) {
return ErrorResponse.exists("Client " + rep.getClientId() + " already exists");
return ErrorResponse.exists("Client already exists");
}
}

View file

@ -20,6 +20,7 @@ import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.authorization.admin.AuthorizationService;
import org.keycloak.events.Errors;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.ClientModel;
@ -39,6 +40,7 @@ import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluato
import org.keycloak.services.validation.ClientValidator;
import org.keycloak.services.validation.PairwiseClientValidator;
import org.keycloak.services.validation.ValidationMessages;
import org.keycloak.validation.ClientValidationUtil;
import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
@ -203,6 +205,11 @@ public class ClientsResource {
}
}
ClientValidationUtil.validate(session, clientModel, true, c -> {
session.getTransactionManager().setRollbackOnly();
throw new ErrorResponseException(Errors.INVALID_INPUT, c.getError(), Response.Status.BAD_REQUEST);
});
return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(clientModel.getId()).build()).build();
} catch (ModelDuplicateException e) {
return ErrorResponse.exists("Client " + rep.getClientId() + " already exists");

View file

@ -0,0 +1,86 @@
/*
* Copyright 2019 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.validation;
import org.keycloak.models.ClientModel;
import org.keycloak.services.util.ResolveRelative;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
public class DefaultClientValidationProvider implements ClientValidationProvider {
private ClientValidationContext context;
// TODO Before adding more validation consider using a library for validation
@Override
public void validate(ClientValidationContext context) {
this.context = context;
try {
validate(context.getClient());
} catch (ValidationException e) {
context.invalid(e.getMessage());
}
}
private void validate(ClientModel client) throws ValidationException {
String resolvedRootUrl = ResolveRelative.resolveRootUrl(context.getSession(), client.getRootUrl());
String resolvedBaseUrl = ResolveRelative.resolveRelativeUri(context.getSession(), resolvedRootUrl, client.getBaseUrl());
validateRootUrl(resolvedRootUrl);
validateBaseUrl(resolvedBaseUrl);
}
private void validateRootUrl(String rootUrl) throws ValidationException {
if (rootUrl != null && !rootUrl.isEmpty()) {
basicHttpUrlCheck("rootUrl", rootUrl);
}
}
private void validateBaseUrl(String baseUrl) throws ValidationException {
if (baseUrl != null && !baseUrl.isEmpty()) {
basicHttpUrlCheck("baseUrl", baseUrl);
}
}
private void basicHttpUrlCheck(String field, String url) throws ValidationException {
boolean valid = true;
try {
URI uri = new URL(url).toURI();
if (uri.getScheme() == null || uri.getScheme().isEmpty()) {
valid = false;
}
} catch (MalformedURLException | URISyntaxException e) {
valid = false;
}
if (!valid) {
throw new ValidationException("Invalid URL in " + field);
}
}
class ValidationException extends Exception {
public ValidationException(String message) {
super(message, null, false, false);
}
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2019 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.validation;
import org.keycloak.models.KeycloakSession;
public class DefaultClientValidationProviderFactory implements ClientValidationProviderFactory {
private final DefaultClientValidationProvider provider = new DefaultClientValidationProvider();
@Override
public ClientValidationProvider create(KeycloakSession session) {
return provider;
}
@Override
public String getId() {
return "default";
}
}

View file

@ -0,0 +1 @@
org.keycloak.validation.DefaultClientValidationProviderFactory

View file

@ -103,6 +103,67 @@ public class ClientTest extends AbstractAdminTest {
Assert.assertNames(realm.clients().findAll(), "account", "account-console", "realm-management", "security-admin-console", "broker", "my-app", Constants.ADMIN_CLI_CLIENT_ID);
}
@Test
public void createClientValidation() {
ClientRepresentation rep = new ClientRepresentation();
rep.setClientId("my-app");
rep.setDescription("my-app description");
rep.setEnabled(true);
rep.setRootUrl("invalid");
createClientExpectingValidationError(rep, "Invalid URL in rootUrl");
rep.setRootUrl(null);
rep.setBaseUrl("invalid");
createClientExpectingValidationError(rep, "Invalid URL in baseUrl");
}
@Test
public void updateClientValidation() {
ClientRepresentation rep = createClient();
rep.setClientId("my-app");
rep.setDescription("my-app description");
rep.setEnabled(true);
rep.setRootUrl("invalid");
updateClientExpectingValidationError(rep, "Invalid URL in rootUrl");
rep.setRootUrl(null);
rep.setBaseUrl("invalid");
updateClientExpectingValidationError(rep, "Invalid URL in baseUrl");
ClientRepresentation stored = realm.clients().get(rep.getId()).toRepresentation();
assertNull(stored.getRootUrl());
assertNull(stored.getBaseUrl());
}
private void createClientExpectingValidationError(ClientRepresentation rep, String expectedError) {
Response response = realm.clients().create(rep);
assertEquals(400, response.getStatus());
OAuth2ErrorRepresentation error = response.readEntity(OAuth2ErrorRepresentation.class);
assertEquals("invalid_input", error.getError());
assertEquals(expectedError, error.getErrorDescription());
assertNull(response.getLocation());
response.close();
}
private void updateClientExpectingValidationError(ClientRepresentation rep, String expectedError) {
try {
realm.clients().get(rep.getId()).update(rep);
fail("Expected exception");
} catch (BadRequestException e) {
Response response = e.getResponse();
assertEquals(400, response.getStatus());
OAuth2ErrorRepresentation error = response.readEntity(OAuth2ErrorRepresentation.class);
assertEquals("invalid_input", error.getError());
assertEquals(expectedError, error.getErrorDescription());
}
}
@Test
public void removeClient() {
String id = createClient().getId();

View file

@ -116,7 +116,6 @@ public abstract class AbstractClientTest extends AbstractAuthTest {
ClientRepresentation clientRep = new ClientRepresentation();
clientRep.setClientId(name);
clientRep.setName(name);
clientRep.setRootUrl("foo");
clientRep.setProtocol("openid-connect");
return clientRep;
}

View file

@ -345,7 +345,6 @@ public class ClientScopeTest extends AbstractClientTest {
ClientRepresentation clientRep = new ClientRepresentation();
clientRep.setClientId("bar-client");
clientRep.setName("bar-client");
clientRep.setRootUrl("foo");
clientRep.setProtocol("openid-connect");
clientRep.setDefaultClientScopes(Collections.singletonList("foo-scope"));
String clientDbId = createClient(clientRep);

View file

@ -125,7 +125,6 @@ public class PartialImportTest extends AbstractAuthTest {
ClientRepresentation client = new ClientRepresentation();
client.setClientId(CLIENT_ROLES_CLIENT);
client.setName(CLIENT_ROLES_CLIENT);
client.setRootUrl("foo");
client.setProtocol("openid-connect");
try (Response resp = testRealmResource().clients().create(client)) {
@ -274,7 +273,6 @@ public class PartialImportTest extends AbstractAuthTest {
ClientRepresentation client = new ClientRepresentation();
client.setClientId(CLIENT_PREFIX + i);
client.setName(CLIENT_PREFIX + i);
client.setRootUrl("foo");
clients.add(client);
if (withServiceAccounts) {
client.setServiceAccountsEnabled(true);

View file

@ -24,11 +24,15 @@ import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.client.registration.HttpErrorException;
import org.keycloak.models.Constants;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.services.clientregistration.ErrorCodes;
import org.keycloak.util.JsonSerialization;
import javax.ws.rs.NotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
@ -156,6 +160,48 @@ public class ClientRegistrationTest extends AbstractClientRegistrationTest {
assertEquals(name, createdClient.getName());
}
@Test
public void registerClientValidation() throws IOException {
authCreateClients();
ClientRepresentation client = buildClient();
client.setRootUrl("invalid");
try {
registerClient(client);
} catch (ClientRegistrationException e) {
HttpErrorException c = (HttpErrorException) e.getCause();
assertEquals(400, c.getStatusLine().getStatusCode());
OAuth2ErrorRepresentation error = JsonSerialization.readValue(c.getErrorResponse(), OAuth2ErrorRepresentation.class);
assertEquals("invalid_client_metadata", error.getError());
assertEquals("Invalid URL in rootUrl", error.getErrorDescription());
}
}
@Test
public void updateClientValidation() throws IOException, ClientRegistrationException {
registerClientAsAdmin();
ClientRepresentation client = reg.get(CLIENT_ID);
client.setRootUrl("invalid");
try {
reg.update(client);
} catch (ClientRegistrationException e) {
HttpErrorException c = (HttpErrorException) e.getCause();
assertEquals(400, c.getStatusLine().getStatusCode());
OAuth2ErrorRepresentation error = JsonSerialization.readValue(c.getErrorResponse(), OAuth2ErrorRepresentation.class);
assertEquals("invalid_client_metadata", error.getError());
assertEquals("Invalid URL in rootUrl", error.getErrorDescription());
}
ClientRepresentation updatedClient = reg.get(CLIENT_ID);
assertNull(updatedClient.getRootUrl());
}
@Test
public void getClientAsAdmin() throws ClientRegistrationException {
registerClientAsAdmin();