diff --git a/services/src/main/java/org/keycloak/services/managers/RealmManager.java b/services/src/main/java/org/keycloak/services/managers/RealmManager.java index 979363ba8b..a590240194 100755 --- a/services/src/main/java/org/keycloak/services/managers/RealmManager.java +++ b/services/src/main/java/org/keycloak/services/managers/RealmManager.java @@ -59,6 +59,7 @@ import org.keycloak.services.clientregistration.policy.DefaultClientRegistration import java.util.Collections; import java.util.HashSet; import java.util.List; +import org.keycloak.utils.ReservedCharValidator; /** * Per request object @@ -98,6 +99,7 @@ public class RealmManager { public RealmModel createRealm(String id, String name) { if (id == null) id = KeycloakModelUtils.generateId(); + ReservedCharValidator.validate(name); RealmModel realm = model.createRealm(id, name); realm.setName(name); @@ -505,6 +507,7 @@ public class RealmManager { id = KeycloakModelUtils.generateId(); } RealmModel realm = model.createRealm(id, rep.getRealm()); + ReservedCharValidator.validate(rep.getRealm()); realm.setName(rep.getRealm()); // setup defaults diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java index 464a1e622a..2d73fd4d0d 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java @@ -72,6 +72,7 @@ import java.util.Map; import java.util.Optional; import static javax.ws.rs.core.Response.Status.NOT_FOUND; +import org.keycloak.utils.ReservedCharValidator; /** * @resource Authentication Management @@ -214,6 +215,8 @@ public class AuthenticationManagementResource { if (realm.getFlowByAlias(flow.getAlias()) != null) { return ErrorResponse.exists("Flow " + flow.getAlias() + " already exists"); } + + ReservedCharValidator.validate(flow.getAlias()); AuthenticationFlowModel createdModel = realm.addAuthenticationFlow(RepresentationToModel.toModel(flow)); @@ -788,6 +791,8 @@ public class AuthenticationManagementResource { @Consumes(MediaType.APPLICATION_JSON) public Response newExecutionConfig(@PathParam("executionId") String execution, AuthenticatorConfigRepresentation json) { auth.realm().requireManageRealm(); + + ReservedCharValidator.validate(json.getAlias()); AuthenticationExecutionModel model = realm.getAuthenticationExecutionById(execution); if (model == null) { @@ -1137,6 +1142,8 @@ public class AuthenticationManagementResource { public Response createAuthenticatorConfig(AuthenticatorConfigRepresentation rep) { auth.realm().requireManageRealm(); + ReservedCharValidator.validate(rep.getAlias()); + AuthenticatorConfigModel config = realm.addAuthenticatorConfig(RepresentationToModel.toModel(rep)); adminEvent.operation(OperationType.CREATE).resource(ResourceType.AUTHENTICATOR_CONFIG).resourcePath(session.getContext().getUri(), config.getId()).representation(rep).success(); return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(config.getId()).build()).build(); @@ -1202,11 +1209,12 @@ public class AuthenticationManagementResource { public void updateAuthenticatorConfig(@PathParam("id") String id, AuthenticatorConfigRepresentation rep) { auth.realm().requireManageRealm(); + ReservedCharValidator.validate(rep.getAlias()); AuthenticatorConfigModel exists = realm.getAuthenticatorConfigById(id); if (exists == null) { throw new NotFoundException("Could not find authenticator config"); - } + exists.setAlias(rep.getAlias()); exists.setConfig(RepresentationToModel.removeEmptyString(rep.getConfig())); realm.updateAuthenticatorConfig(exists); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java index 1c27563410..bf89d6ff59 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java @@ -87,6 +87,7 @@ import java.util.Map; import java.util.Properties; import static java.lang.Boolean.TRUE; +import org.keycloak.utils.ReservedCharValidator; /** @@ -565,6 +566,9 @@ public class ClientResource { if (node == null) { throw new BadRequestException("Node not found in params"); } + + ReservedCharValidator.validate(node); + if (logger.isDebugEnabled()) logger.debug("Register node: " + node); client.registerNode(node, Time.currentTime()); adminEvent.operation(OperationType.CREATE).resource(ResourceType.CLUSTER_NODE).resourcePath(session.getContext().getUri(), node).success(); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java index f88293dc42..e1103621a5 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java @@ -55,6 +55,7 @@ import java.util.List; import java.util.Map; import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import org.keycloak.utils.ReservedCharValidator; /** * @resource Identity Providers @@ -134,6 +135,9 @@ public class IdentityProvidersResource { if (!(data.containsKey("providerId") && data.containsKey("fromUrl"))) { throw new BadRequestException(); } + + ReservedCharValidator.validate((String)data.get("alias")); + String providerId = data.get("providerId").toString(); String from = data.get("fromUrl").toString(); InputStream inputStream = session.getProvider(HttpClientProvider.class).get(from); @@ -182,6 +186,8 @@ public class IdentityProvidersResource { public Response create(IdentityProviderRepresentation representation) { this.auth.realm().requireManageIdentityProviders(); + ReservedCharValidator.validate(representation.getAlias()); + try { IdentityProviderModel identityProvider = RepresentationToModel.toModel(realm, representation, session); this.realm.addIdentityProvider(identityProvider); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java index eed2632a1f..b99ae25191 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java @@ -106,6 +106,7 @@ import java.util.stream.Collectors; import static org.keycloak.models.utils.StripSecretsUtils.stripForExport; import static org.keycloak.util.JsonSerialization.readValue; +import org.keycloak.utils.ReservedCharValidator; /** * Base resource class for the admin REST api of one realm @@ -389,6 +390,8 @@ public class RealmAdminResource { if (Config.getAdminRealm().equals(realm.getName()) && (rep.getRealm() != null && !rep.getRealm().equals(Config.getAdminRealm()))) { return ErrorResponse.error("Can't rename master realm", Status.BAD_REQUEST); } + + ReservedCharValidator.validate(rep.getRealm()); try { if (!Constants.GENERATE.equals(rep.getPublicKey()) && (rep.getPrivateKey() != null && rep.getPublicKey() != null)) { diff --git a/services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java b/services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java index 2f6d7f6684..d4f531a25c 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java @@ -17,7 +17,6 @@ package org.keycloak.services.resources.admin; -import org.apache.commons.lang.StringUtils; import org.jboss.resteasy.annotations.cache.NoCache; import javax.ws.rs.NotFoundException; import org.keycloak.events.admin.OperationType; @@ -61,6 +60,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import org.keycloak.utils.ReservedCharValidator; /** * @resource Roles @@ -136,6 +136,8 @@ public class RoleContainerResource extends RoleResource { if (rep.getName() == null) { throw new BadRequestException(); } + + ReservedCharValidator.validate(rep.getName()); try { RoleModel role = roleContainer.addRole(rep.getName()); diff --git a/services/src/main/java/org/keycloak/utils/ReservedCharValidator.java b/services/src/main/java/org/keycloak/utils/ReservedCharValidator.java new file mode 100644 index 0000000000..214c490b75 --- /dev/null +++ b/services/src/main/java/org/keycloak/utils/ReservedCharValidator.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020 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.utils; + +import javax.ws.rs.BadRequestException; +import org.jboss.logging.Logger; + +/** + * + * @author Stan Silvert + */ +public class ReservedCharValidator { + protected static final Logger logger = Logger.getLogger(ReservedCharValidator.class); + + // https://tools.ietf.org/html/rfc3986#section-2.2 + private static final String[] RESERVED_CHARS = { ":", "/", "?", "#", "[", "@", "!", "$", + "&", "(", ")", "*", "+", ",", ";", "=", + "]", "[", "\\" }; + private ReservedCharValidator() {} + + public static void validate(String str) throws ReservedCharException { + if (str == null) return; + + for (String c : RESERVED_CHARS) { + if (str.contains(c)) { + String message = "Character '" + c + "' not allowed."; + ReservedCharException e = new ReservedCharException(message); + logger.warn(message, e); + throw e; + } + } + } + + public static class ReservedCharException extends BadRequestException { + ReservedCharException(String msg) { + super(msg); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/page/PatternFlyClosableAlert.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/page/PatternFlyClosableAlert.java index 549f9a68ed..a8fa5caeb6 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/page/PatternFlyClosableAlert.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/page/PatternFlyClosableAlert.java @@ -40,7 +40,7 @@ public class PatternFlyClosableAlert extends AbstractPatternFlyAlert { } public boolean isWarning() { - return checkAlertType("waring"); + return checkAlertType("warning"); } public boolean isDanger() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientTest.java index bf97b0d5c9..555c83ffcf 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientTest.java @@ -422,6 +422,16 @@ public class ClientTest extends AbstractAdminTest { return client; } + @Test (expected = BadRequestException.class) + public void testAddNodeWithReservedCharacter() { + testingClient.testApp().clearAdminActions(); + + ClientRepresentation client = createAppClient(); + String id = client.getId(); + + realm.clients().get(id).registerNode(Collections.singletonMap("node", "foo#")); + } + @Test public void nodes() { testingClient.testApp().clearAdminActions(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java index 328d8c581b..69d5976136 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java @@ -123,6 +123,17 @@ public class IdentityProviderTest extends AbstractAdminTest { Assert.assertNames(realm.identityProviders().findAll(), "google", "facebook"); } + @Test + public void testCreateWithReservedCharacterForAlias() { + IdentityProviderRepresentation newIdentityProvider = createRep("ne$&w-identity-provider", "oidc"); + + newIdentityProvider.getConfig().put("clientId", "clientId"); + newIdentityProvider.getConfig().put("clientSecret", "some secret value"); + + Response response = realm.identityProviders().create(newIdentityProvider); + Assert.assertEquals(400, response.getStatus()); + } + @Test public void testCreate() { IdentityProviderRepresentation newIdentityProvider = createRep("new-identity-provider", "oidc"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/AuthenticatorConfigTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/AuthenticatorConfigTest.java index 64f862205f..7069595649 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/AuthenticatorConfigTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/AuthenticatorConfigTest.java @@ -34,6 +34,7 @@ import javax.ws.rs.core.Response; import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.ws.rs.BadRequestException; /** * @author Marek Posolda @@ -58,6 +59,12 @@ public class AuthenticatorConfigTest extends AbstractAuthenticationTest { executionId = exec.getId(); } + @Test + public void testCreateConfigWithReservedChar() { + AuthenticatorConfigRepresentation cfg = newConfig("f!oo", IdpCreateUserIfUniqueAuthenticatorFactory.REQUIRE_PASSWORD_UPDATE_AFTER_REGISTRATION, "true"); + Response resp = authMgmtResource.newExecutionConfig(executionId, cfg); + Assert.assertEquals(400, resp.getStatus()); + } @Test public void testCreateConfig() { @@ -80,7 +87,16 @@ public class AuthenticatorConfigTest extends AbstractAuthenticationTest { assertAdminEvents.assertEvent(REALM_NAME, OperationType.DELETE, AdminEventPaths.authExecutionConfigPath(cfgId), ResourceType.AUTHENTICATOR_CONFIG); } - + @Test (expected = BadRequestException.class) + public void testUpdateConfigWithBadChar() { + AuthenticatorConfigRepresentation cfg = newConfig("foo", IdpCreateUserIfUniqueAuthenticatorFactory.REQUIRE_PASSWORD_UPDATE_AFTER_REGISTRATION, "true"); + String cfgId = createConfig(executionId, cfg); + AuthenticatorConfigRepresentation cfgRep = authMgmtResource.getAuthenticatorConfig(cfgId); + + cfgRep.setAlias("Bad@Char"); + authMgmtResource.updateAuthenticatorConfig(cfgRep.getId(), cfgRep); + } + @Test public void testUpdateConfig() { AuthenticatorConfigRepresentation cfg = newConfig("foo", IdpCreateUserIfUniqueAuthenticatorFactory.REQUIRE_PASSWORD_UPDATE_AFTER_REGISTRATION, "true"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/FlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/FlowTest.java index a728c8e7c0..4bef0cf760 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/FlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/FlowTest.java @@ -72,6 +72,12 @@ public class FlowTest extends AbstractAuthenticationTest { authMgmtResource.addExecutionFlow(parentAlias, data); } + @Test + public void testAddFlowWithRestrictedCharInAlias() { + Response resp = authMgmtResource.createFlow(newFlow("fo]o", "Browser flow", "basic-flow", true, false)); + Assert.assertEquals(400, resp.getStatus()); + } + @Test public void testAddRemoveFlow() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ClientRolesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ClientRolesTest.java index 5d8d3a1624..e636b04c70 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ClientRolesTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ClientRolesTest.java @@ -39,6 +39,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import javax.ws.rs.BadRequestException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -87,6 +88,12 @@ public class ClientRolesTest extends AbstractClientTest { assertAdminEvents.assertEvent(getRealmId(), OperationType.CREATE, AdminEventPaths.clientRoleResourcePath(clientDbId, "role1"), role1, ResourceType.CLIENT_ROLE); assertTrue(hasRole(rolesRsc, "role1")); } + + @Test(expected = BadRequestException.class) + public void testAddRoleWithReservedCharacter() { + RoleRepresentation role1 = makeRole("r&ole1"); + rolesRsc.create(role1); + } @Test public void testRemoveRole() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java index ed0f3e3b4e..cd15828166 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java @@ -157,6 +157,13 @@ public class RealmTest extends AbstractAdminTest { Assert.assertNames(adminClient.realms().findAll(), "master", AuthRealm.TEST, REALM_NAME); } + + @Test(expected = BadRequestException.class) + public void createRealmRejectReservedChar() { + RealmRepresentation rep = new RealmRepresentation(); + rep.setRealm("new-re;alm"); + adminClient.realms().create(rep); + } /** * Checks attributes exposed as fields are not also included as attributes @@ -379,6 +386,13 @@ public class RealmTest extends AbstractAdminTest { checkRealmEventsConfigRepresentation(repOrig, actual); } + @Test(expected = BadRequestException.class) + public void updateRealmWithReservedCharInName() { + RealmRepresentation rep = realm.toRepresentation(); + rep.setRealm("fo#o"); + realm.update(rep); + } + @Test public void updateRealm() { // first change diff --git a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/AbstractConsoleTest.java b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/AbstractConsoleTest.java index f400aabf80..ad656c8582 100644 --- a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/AbstractConsoleTest.java +++ b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/AbstractConsoleTest.java @@ -137,6 +137,10 @@ public abstract class AbstractConsoleTest extends AbstractAuthTest { public void assertAlertDanger() { alert.assertDanger(); } + + public void assertAlertWarning() { + alert.assertWarning(); + } public ConfigureMenu configure() { return adminConsoleRealmPage.configure(); diff --git a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/roles/RealmRolesTest.java b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/roles/RealmRolesTest.java index 19d7d2e76a..6257b5542b 100644 --- a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/roles/RealmRolesTest.java +++ b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/roles/RealmRolesTest.java @@ -112,6 +112,21 @@ public class RealmRolesTest extends AbstractRolesTest { assertNotNull(realmRolesPage.table().findRole(name)); } + // KEYCLOAK-12768: Certain characters in names cause bad URIs. Disallow. + @Test + public void testAddRoleWithBadCharsInName() { + String roleName = "hello;:]!@#role"; + assertCurrentUrlEquals(realmRolesPage); + realmRolesPage.table().addRole(); + assertCurrentUrlEquals(createRolePage); + createRolePage.form().setName(roleName); + assertAlertWarning(); + createRolePage.form().save(); + assertAlertSuccess(); + realmRolesPage.navigateTo(); + assertNotNull(realmRolesPage.table().findRole("hellorole")); + } + @Test public void testAddExistingRole() { addRole(testRole); diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index e54b06c89a..fb11d93f3f 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1608,3 +1608,5 @@ subjectdn-tooltip=A regular expression for validating Subject DN in the Client C pkce-code-challenge-method=Proof Key for Code Exchange Code Challenge Method pkce-code-challenge-method.tooltip=Choose which code challenge method for PKCE is used. If not specified, keycloak does not applies PKCE to a client unless the client sends an authorization request with appropriate code challenge and code exchange method. + +key-not-allowed-here=Key '{{character}}' is not allowed here. diff --git a/themes/src/main/resources/theme/base/admin/resources/js/app.js b/themes/src/main/resources/theme/base/admin/resources/js/app.js index 93c89680ba..3b49000b76 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/app.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/app.js @@ -2384,6 +2384,23 @@ module.directive('kcEnter', function() { }; }); +// Don't allow URI reserved characters +module.directive('kcNoReservedChars', function (Notifications, $translate) { + return function($scope, element) { + element.bind("keydown keypress", function(event) { + var keyPressed = String.fromCharCode(event.which || event.keyCode || 0); + + // ] and ' can not be used inside a character set on POSIX and GNU + if (keyPressed.match('[:/?#[@!$&()*+,;=]') || keyPressed === ']' || keyPressed === '\'') { + event.preventDefault(); + $scope.$apply(function() { + Notifications.warn($translate.instant('key-not-allowed-here', {character: keyPressed})); + }); + } + }); + }; +}); + module.directive('kcSave', function ($compile, $timeout, Notifications) { var clickDelay = 500; // 500 ms diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/authenticator-config.html b/themes/src/main/resources/theme/base/admin/resources/partials/authenticator-config.html index 8542060564..1b3440669a 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/authenticator-config.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/authenticator-config.html @@ -26,7 +26,7 @@