diff --git a/java/google/registry/config/RegistryConfig.java b/java/google/registry/config/RegistryConfig.java index 13684ac48..6a6ec3a9a 100644 --- a/java/google/registry/config/RegistryConfig.java +++ b/java/google/registry/config/RegistryConfig.java @@ -1101,9 +1101,8 @@ public final class RegistryConfig { return config.registryPolicy.reservedTermsExportDisclaimer; } - /** - * Returns the clientId of the registrar used by the {@code CheckApiServlet}. - */ + /** Returns the clientId of the registrar used by the {@code CheckApiServlet}. */ + // TODO(b/80417678): remove this once CheckApiAction no longer uses this id. @Provides @Config("checkApiServletRegistrarClientId") public static String provideCheckApiServletRegistrarClientId(RegistryConfigSettings config) { diff --git a/java/google/registry/env/common/default/WEB-INF/web.xml b/java/google/registry/env/common/default/WEB-INF/web.xml index dc37a1e7f..c2e5ef808 100644 --- a/java/google/registry/env/common/default/WEB-INF/web.xml +++ b/java/google/registry/env/common/default/WEB-INF/web.xml @@ -73,6 +73,15 @@ /check + + + frontend-servlet + /check2 + + diff --git a/java/google/registry/env/common/pubapi/WEB-INF/web.xml b/java/google/registry/env/common/pubapi/WEB-INF/web.xml index ca266e06e..2fb923326 100644 --- a/java/google/registry/env/common/pubapi/WEB-INF/web.xml +++ b/java/google/registry/env/common/pubapi/WEB-INF/web.xml @@ -37,6 +37,15 @@ /check + + + pubapi-servlet + /check2 + + diff --git a/java/google/registry/flows/CheckApi2Action.java b/java/google/registry/flows/CheckApi2Action.java new file mode 100644 index 000000000..60bf5d95f --- /dev/null +++ b/java/google/registry/flows/CheckApi2Action.java @@ -0,0 +1,148 @@ +// Copyright 2018 The Nomulus Authors. All Rights Reserved. +// +// 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 google.registry.flows; + +import static com.google.common.base.Strings.nullToEmpty; +import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN; +import static google.registry.flows.domain.DomainFlowUtils.validateDomainName; +import static google.registry.flows.domain.DomainFlowUtils.validateDomainNameWithIdnTables; +import static google.registry.flows.domain.DomainFlowUtils.verifyNotInPredelegation; +import static google.registry.model.registry.label.ReservationType.getTypeOfHighestSeverity; +import static google.registry.model.registry.label.ReservedList.getReservationTypes; +import static google.registry.pricing.PricingEngineProxy.isDomainPremium; +import static google.registry.util.DomainNameUtils.canonicalizeDomainName; +import static org.json.simple.JSONValue.toJSONString; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.flogger.FluentLogger; +import com.google.common.net.InternetDomainName; +import com.google.common.net.MediaType; +import dagger.Module; +import google.registry.flows.domain.DomainFlowUtils.BadCommandForRegistryPhaseException; +import google.registry.model.domain.DomainResource; +import google.registry.model.index.ForeignKeyIndex; +import google.registry.model.registry.Registry; +import google.registry.model.registry.label.ReservationType; +import google.registry.request.Action; +import google.registry.request.Parameter; +import google.registry.request.Response; +import google.registry.request.auth.Auth; +import google.registry.util.Clock; +import java.util.Map; +import java.util.Optional; +import javax.inject.Inject; +import org.joda.time.DateTime; + +/** + * An action that returns availability and premium checks as JSON. + * + *

This action returns plain JSON without a safety prefix, so it's vital that the output not be + * user controlled, lest it open an XSS vector. Do not modify this to return the domain name in the + * response. + */ +@Action(path = "/check2", auth = Auth.AUTH_PUBLIC_ANONYMOUS) +// TODO(b/80417678): rename this class to CheckApiAction and change path to "/check". +public class CheckApi2Action implements Runnable { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + @Inject + @Parameter("domain") + String domain; + + @Inject Response response; + @Inject Clock clock; + + @Inject + CheckApi2Action() {} + + @Override + public void run() { + response.setHeader("Content-Disposition", "attachment"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + response.setContentType(MediaType.JSON_UTF_8); + response.setPayload(toJSONString(doCheck())); + } + + private Map doCheck() { + String domainString; + InternetDomainName domainName; + try { + domainString = canonicalizeDomainName(nullToEmpty(domain)); + domainName = validateDomainName(domainString); + } catch (IllegalArgumentException | EppException e) { + return fail("Must supply a valid domain name on an authoritative TLD"); + } + try { + // Throws an EppException with a reasonable error message which will be sent back to caller. + validateDomainNameWithIdnTables(domainName); + + DateTime now = clock.nowUtc(); + Registry registry = Registry.get(domainName.parent().toString()); + try { + verifyNotInPredelegation(registry, now); + } catch (BadCommandForRegistryPhaseException e) { + return fail("Check in this TLD is not allowed in the current registry phase"); + } + + String errorMsg = + checkExists(domainString, now) + ? "In use" + : checkReserved(domainName).orElse(null); + + boolean available = (errorMsg == null); + ImmutableMap.Builder responseBuilder = new ImmutableMap.Builder<>(); + responseBuilder.put("status", "success").put("available", available); + if (available) { + responseBuilder.put("tier", isDomainPremium(domainString, now) ? "premium" : "standard"); + } else { + responseBuilder.put("reason", errorMsg); + } + return responseBuilder.build(); + } catch (EppException e) { + return fail(e.getResult().getMsg()); + } catch (Exception e) { + logger.atWarning().withCause(e).log("Unknown error"); + return fail("Invalid request"); + } + } + + private boolean checkExists(String domainString, DateTime now) { + return !ForeignKeyIndex.loadCached(DomainResource.class, ImmutableList.of(domainString), now) + .isEmpty(); + } + + private Optional checkReserved(InternetDomainName domainName) { + ImmutableSet reservationTypes = + getReservationTypes(domainName.parts().get(0), domainName.parent().toString()); + if (!reservationTypes.isEmpty()) { + return Optional.of(getTypeOfHighestSeverity(reservationTypes).getMessageForCheck()); + } + return Optional.empty(); + } + + private Map fail(String reason) { + return ImmutableMap.of("status", "error", "reason", reason); + } + + /** Dagger module for the check api endpoint. */ + @Module + public static final class CheckApi2Module { + // TODO(b/80417678): provide Parameter("domain") once CheckApiAction is replaced by this class. + } +} diff --git a/java/google/registry/flows/domain/DomainFlowUtils.java b/java/google/registry/flows/domain/DomainFlowUtils.java index bedab7f9f..0bddd38a9 100644 --- a/java/google/registry/flows/domain/DomainFlowUtils.java +++ b/java/google/registry/flows/domain/DomainFlowUtils.java @@ -231,7 +231,7 @@ public class DomainFlowUtils { * @throws InvalidIdnDomainLabelException if IDN table or language validation failed * @see #validateDomainName(String) */ - static String validateDomainNameWithIdnTables(InternetDomainName domainName) + public static String validateDomainNameWithIdnTables(InternetDomainName domainName) throws InvalidIdnDomainLabelException { Optional idnTableName = findValidIdnTableForTld(domainName.parts().get(0), domainName.parent().toString()); @@ -856,7 +856,7 @@ public class DomainFlowUtils { } /** Check that the registry phase is not predelegation, during which some flows are forbidden. */ - static void verifyNotInPredelegation(Registry registry, DateTime now) + public static void verifyNotInPredelegation(Registry registry, DateTime now) throws BadCommandForRegistryPhaseException { if (registry.getTldState(now) == TldState.PREDELEGATION) { throw new BadCommandForRegistryPhaseException(); diff --git a/java/google/registry/module/frontend/FrontendRequestComponent.java b/java/google/registry/module/frontend/FrontendRequestComponent.java index 118343789..fe86a0ece 100644 --- a/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -17,6 +17,8 @@ package google.registry.module.frontend; import dagger.Module; import dagger.Subcomponent; import google.registry.dns.DnsModule; +import google.registry.flows.CheckApi2Action; +import google.registry.flows.CheckApi2Action.CheckApi2Module; import google.registry.flows.CheckApiAction; import google.registry.flows.CheckApiAction.CheckApiModule; import google.registry.flows.EppConsoleAction; @@ -49,16 +51,18 @@ import google.registry.whois.WhoisModule; @RequestScope @Subcomponent( modules = { - CheckApiModule.class, - DnsModule.class, - EppTlsModule.class, - RdapModule.class, - RequestModule.class, - WhiteboxModule.class, - WhoisModule.class, + CheckApiModule.class, + CheckApi2Module.class, + DnsModule.class, + EppTlsModule.class, + RdapModule.class, + RequestModule.class, + WhiteboxModule.class, + WhoisModule.class, }) interface FrontendRequestComponent { CheckApiAction checkApiAction(); + CheckApi2Action checkApi2Action(); ConsoleUiAction consoleUiAction(); EppConsoleAction eppConsoleAction(); EppTlsAction eppTlsAction(); diff --git a/java/google/registry/module/pubapi/PubApiRequestComponent.java b/java/google/registry/module/pubapi/PubApiRequestComponent.java index 2e3612517..95dd56826 100644 --- a/java/google/registry/module/pubapi/PubApiRequestComponent.java +++ b/java/google/registry/module/pubapi/PubApiRequestComponent.java @@ -17,6 +17,8 @@ package google.registry.module.pubapi; import dagger.Module; import dagger.Subcomponent; import google.registry.dns.DnsModule; +import google.registry.flows.CheckApi2Action; +import google.registry.flows.CheckApi2Action.CheckApi2Module; import google.registry.flows.CheckApiAction; import google.registry.flows.CheckApiAction.CheckApiModule; import google.registry.flows.FlowComponent; @@ -43,16 +45,18 @@ import google.registry.whois.WhoisModule; @RequestScope @Subcomponent( modules = { - CheckApiModule.class, - DnsModule.class, - EppTlsModule.class, - RdapModule.class, - RequestModule.class, - WhiteboxModule.class, - WhoisModule.class, + CheckApiModule.class, + CheckApi2Module.class, + DnsModule.class, + EppTlsModule.class, + RdapModule.class, + RequestModule.class, + WhiteboxModule.class, + WhoisModule.class, }) interface PubApiRequestComponent { CheckApiAction checkApiAction(); + CheckApi2Action checkApi2Action(); // TODO(b/79692981): Remove flow-related includes once check API is rewritten to not wrap flow. FlowComponent.Builder flowComponentBuilder(); RdapAutnumAction rdapAutnumAction(); diff --git a/javatests/google/registry/flows/CheckApi2ActionTest.java b/javatests/google/registry/flows/CheckApi2ActionTest.java new file mode 100644 index 000000000..ce24d9c0a --- /dev/null +++ b/javatests/google/registry/flows/CheckApi2ActionTest.java @@ -0,0 +1,182 @@ +// Copyright 2018 The Nomulus Authors. All Rights Reserved. +// +// 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 google.registry.flows; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.testing.DatastoreHelper.createTld; +import static google.registry.testing.DatastoreHelper.persistActiveDomain; +import static google.registry.testing.DatastoreHelper.persistReservedList; +import static google.registry.testing.DatastoreHelper.persistResource; + +import google.registry.model.registry.Registry; +import google.registry.model.registry.Registry.TldState; +import google.registry.testing.AppEngineRule; +import google.registry.testing.FakeClock; +import google.registry.testing.FakeResponse; +import java.util.Map; +import org.json.simple.JSONValue; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link CheckApi2Action}. */ +@RunWith(JUnit4.class) +public class CheckApi2ActionTest { + + @Rule public final AppEngineRule appEngine = AppEngineRule.builder().withDatastore().build(); + + @Before + public void init() throws Exception { + createTld("example"); + persistResource( + Registry.get("example") + .asBuilder() + .setReservedLists(persistReservedList("example-reserved", "foo,FULLY_BLOCKED")) + .build()); + } + + @SuppressWarnings("unchecked") + private Map getCheckResponse(String domain) { + CheckApi2Action action = new CheckApi2Action(); + action.domain = domain; + action.response = new FakeResponse(); + action.clock = new FakeClock(); + action.run(); + return (Map) JSONValue.parse(((FakeResponse) action.response).getPayload()); + } + + @Test + public void testFailure_nullDomain() throws Exception { + assertThat(getCheckResponse(null)) + .containsExactly( + "status", "error", + "reason", "Must supply a valid domain name on an authoritative TLD"); + } + + @Test + public void testFailure_emptyDomain() throws Exception { + assertThat(getCheckResponse("")) + .containsExactly( + "status", "error", + "reason", "Must supply a valid domain name on an authoritative TLD"); + } + + @Test + public void testFailure_invalidDomain() throws Exception { + assertThat(getCheckResponse("@#$%^")) + .containsExactly( + "status", "error", + "reason", "Must supply a valid domain name on an authoritative TLD"); + } + + @Test + public void testFailure_singlePartDomain() throws Exception { + assertThat(getCheckResponse("foo")) + .containsExactly( + "status", "error", + "reason", "Must supply a valid domain name on an authoritative TLD"); + } + + @Test + public void testFailure_nonExistentTld() throws Exception { + assertThat(getCheckResponse("foo.bar")) + .containsExactly( + "status", "error", + "reason", "Must supply a valid domain name on an authoritative TLD"); + } + + @Test + public void testFailure_invalidIdnTable() throws Exception { + assertThat(getCheckResponse("ΑΒΓ.example")) + .containsExactly( + "status", "error", + "reason", "Domain label is not allowed by IDN table"); + } + + @Test + public void testFailure_tldInPredelegation() throws Exception { + createTld("predelegated", TldState.PREDELEGATION); + assertThat(getCheckResponse("foo.predelegated")) + .containsExactly( + "status", "error", + "reason", "Check in this TLD is not allowed in the current registry phase"); + } + + @Test + public void testSuccess_availableStandard() throws Exception { + assertThat(getCheckResponse("somedomain.example")) + .containsExactly( + "status", "success", + "available", true, + "tier", "standard"); + } + + @Test + public void testSuccess_availableCapital() throws Exception { + assertThat(getCheckResponse("SOMEDOMAIN.EXAMPLE")) + .containsExactly( + "status", "success", + "available", true, + "tier", "standard"); + } + + @Test + public void testSuccess_availableUnicode() throws Exception { + assertThat(getCheckResponse("ééé.example")) + .containsExactly( + "status", "success", + "available", true, + "tier", "standard"); + } + + @Test + public void testSuccess_availablePunycode() throws Exception { + assertThat(getCheckResponse("xn--9caaa.example")) + .containsExactly( + "status", "success", + "available", true, + "tier", "standard"); + } + + @Test + public void testSuccess_availablePremium() throws Exception { + assertThat(getCheckResponse("rich.example")) + .containsExactly( + "status", "success", + "available", true, + "tier", "premium"); + } + + @Test + public void testSuccess_alreadyRegistered() throws Exception { + persistActiveDomain("somedomain.example"); + assertThat(getCheckResponse("somedomain.example")) + .containsExactly( + "status", "success", + "available", false, + "reason", "In use"); + } + + @Test + public void testSuccess_reserved() throws Exception { + assertThat(getCheckResponse("foo.example")) + .containsExactly( + "status", "success", + "available", false, + "reason", "Reserved"); + } +} diff --git a/javatests/google/registry/module/frontend/testdata/frontend_routing.txt b/javatests/google/registry/module/frontend/testdata/frontend_routing.txt index 1b771c337..9cb3fa248 100644 --- a/javatests/google/registry/module/frontend/testdata/frontend_routing.txt +++ b/javatests/google/registry/module/frontend/testdata/frontend_routing.txt @@ -2,6 +2,7 @@ PATH CLASS METHODS OK AUTH_METHODS /_dr/epp EppTlsAction POST n INTERNAL,API APP PUBLIC /_dr/whois WhoisAction POST n INTERNAL,API APP PUBLIC /check CheckApiAction GET n INTERNAL NONE PUBLIC +/check2 CheckApi2Action GET n INTERNAL NONE PUBLIC /rdap/autnum/(*) RdapAutnumAction GET,HEAD n INTERNAL NONE PUBLIC /rdap/domain/(*) RdapDomainAction GET,HEAD n INTERNAL,API,LEGACY NONE PUBLIC /rdap/domains RdapDomainSearchAction GET,HEAD n INTERNAL,API,LEGACY NONE PUBLIC diff --git a/javatests/google/registry/module/pubapi/testdata/pubapi_routing.txt b/javatests/google/registry/module/pubapi/testdata/pubapi_routing.txt index 089d67c16..89cf2d5b0 100644 --- a/javatests/google/registry/module/pubapi/testdata/pubapi_routing.txt +++ b/javatests/google/registry/module/pubapi/testdata/pubapi_routing.txt @@ -1,6 +1,7 @@ PATH CLASS METHODS OK AUTH_METHODS MIN USER_POLICY /_dr/whois WhoisAction POST n INTERNAL,API APP PUBLIC /check CheckApiAction GET n INTERNAL NONE PUBLIC +/check2 CheckApi2Action GET n INTERNAL NONE PUBLIC /rdap/autnum/(*) RdapAutnumAction GET,HEAD n INTERNAL NONE PUBLIC /rdap/domain/(*) RdapDomainAction GET,HEAD n INTERNAL,API,LEGACY NONE PUBLIC /rdap/domains RdapDomainSearchAction GET,HEAD n INTERNAL,API,LEGACY NONE PUBLIC