diff --git a/core/src/main/java/google/registry/bsa/persistence/BsaLabel.java b/core/src/main/java/google/registry/bsa/persistence/BsaLabel.java index 976e4d8ed..deb099470 100644 --- a/core/src/main/java/google/registry/bsa/persistence/BsaLabel.java +++ b/core/src/main/java/google/registry/bsa/persistence/BsaLabel.java @@ -28,7 +28,7 @@ import org.joda.time.DateTime; *

The label is valid (wrt IDN) in at least one TLD. */ @Entity -public final class BsaLabel { +final class BsaLabel { @Id String label; @@ -52,7 +52,7 @@ public final class BsaLabel { } /** Returns the label to be blocked. */ - public String getLabel() { + String getLabel() { return label; } diff --git a/core/src/main/java/google/registry/bsa/persistence/BsaLabelUtils.java b/core/src/main/java/google/registry/bsa/persistence/BsaLabelUtils.java new file mode 100644 index 000000000..dd03b0b34 --- /dev/null +++ b/core/src/main/java/google/registry/bsa/persistence/BsaLabelUtils.java @@ -0,0 +1,87 @@ +// Copyright 2023 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.bsa.persistence; + +import static google.registry.config.RegistryConfig.getEppResourceCachingDuration; +import static google.registry.config.RegistryConfig.getEppResourceMaxCachedEntries; +import static google.registry.model.CacheUtils.newCacheBuilder; +import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm; + +import com.github.benmanes.caffeine.cache.CacheLoader; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.google.common.annotations.VisibleForTesting; +import google.registry.persistence.VKey; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; + +/** Helpers for {@link BsaLabel}. */ +public final class BsaLabelUtils { + + private BsaLabelUtils() {} + + static final CacheLoader, Optional> CACHE_LOADER = + new CacheLoader, Optional>() { + + @Override + public Optional load(VKey key) { + return replicaTm().reTransact(() -> replicaTm().loadByKeyIfPresent(key)); + } + + @Override + public Map, Optional> loadAll( + Iterable> keys) { + // TODO(b/309173359): need this for DomainCheckFlow + throw new UnsupportedOperationException( + "LoadAll not supported by the BsaLabel cache loader."); + } + }; + + /** + * A limited size, limited expiry cache of BSA labels. + * + *

BSA labels are used by the domain creation flow to verify that the requested domain name is + * not blocked by the BSA program. Label caching is mainly a defense against two scenarios, the + * initial rush and drop-catching, when clients run back-to-back domain creation requests around + * the time when a domain becomes available. + * + *

Because of caching and the use of the replica database, new BSA labels installed in the + * database will not take effect immediately. A blocked domain may be created due to race + * condition. A `refresh` job will detect such domains and report them to BSA as unblockable + * domains. + * + *

Since the cached BSA labels have the same usage pattern as the cached EppResources, the + * cache configuration for the latter are reused here. + */ + private static LoadingCache, Optional> cacheBsaLabels = + createBsaLabelsCache(getEppResourceCachingDuration()); + + private static LoadingCache, Optional> createBsaLabelsCache( + Duration expiry) { + return newCacheBuilder(expiry) + .maximumSize(getEppResourceMaxCachedEntries()) + .build(CACHE_LOADER); + } + + @VisibleForTesting + void clearCache() { + cacheBsaLabels.invalidateAll(); + } + + /** Checks if the {@code domainLabel} (the leading `part` of a domain name) is blocked by BSA. */ + public static boolean isLabelBlocked(String domainLabel) { + return cacheBsaLabels.get(BsaLabel.vKey(domainLabel)).isPresent(); + } +} diff --git a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java index 8002e9d3a..192499794 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java +++ b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java @@ -40,6 +40,7 @@ import static google.registry.flows.domain.DomainFlowUtils.verifyClaimsNoticeIfA import static google.registry.flows.domain.DomainFlowUtils.verifyClaimsPeriodNotEnded; import static google.registry.flows.domain.DomainFlowUtils.verifyLaunchPhaseMatchesRegistryPhase; import static google.registry.flows.domain.DomainFlowUtils.verifyNoCodeMarks; +import static google.registry.flows.domain.DomainFlowUtils.verifyNotBlockedByBsa; import static google.registry.flows.domain.DomainFlowUtils.verifyNotReserved; import static google.registry.flows.domain.DomainFlowUtils.verifyPremiumNameIsNotBlocked; import static google.registry.flows.domain.DomainFlowUtils.verifyRegistrarIsActive; @@ -168,6 +169,7 @@ import org.joda.time.Duration; * @error {@link DomainFlowUtils.CurrencyUnitMismatchException} * @error {@link DomainFlowUtils.CurrencyValueScaleException} * @error {@link DomainFlowUtils.DashesInThirdAndFourthException} + * @error {@link DomainFlowUtils.DomainLabelBlockedByBsaException} * @error {@link DomainFlowUtils.DomainLabelTooLongException} * @error {@link DomainFlowUtils.DomainReservedException} * @error {@link DomainFlowUtils.DuplicateContactForRoleException} @@ -328,6 +330,7 @@ public final class DomainCreateFlow implements MutatingFlow { .verifySignedMarks(launchCreate.get().getSignedMarks(), domainLabel, now) .getId(); } + verifyNotBlockedByBsa(domainLabel, tld, now); flowCustomLogic.afterValidation( DomainCreateFlowCustomLogic.AfterValidationParameters.newBuilder() .setDomainName(domainName) diff --git a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java index 859f5a0e4..3f64bb841 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java +++ b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java @@ -25,11 +25,13 @@ import static com.google.common.collect.Iterables.any; import static com.google.common.collect.Sets.difference; import static com.google.common.collect.Sets.intersection; import static com.google.common.collect.Sets.union; +import static google.registry.bsa.persistence.BsaLabelUtils.isLabelBlocked; import static google.registry.model.domain.Domain.MAX_REGISTRATION_YEARS; import static google.registry.model.tld.Tld.TldState.GENERAL_AVAILABILITY; import static google.registry.model.tld.Tld.TldState.PREDELEGATION; import static google.registry.model.tld.Tld.TldState.QUIET_PERIOD; import static google.registry.model.tld.Tld.TldState.START_DATE_SUNRISE; +import static google.registry.model.tld.Tld.isEnrolledWithBsa; import static google.registry.model.tld.Tlds.findTldForName; import static google.registry.model.tld.Tlds.getTlds; import static google.registry.model.tld.label.ReservationType.ALLOWED_IN_SUNRISE; @@ -259,6 +261,19 @@ public class DomainFlowUtils { return idnTableName.get(); } + /** + * Verifies that the {@code domainLabel} is not blocked by any BSA block label for the given + * {@code tld} at the specified time. + * + * @throws DomainLabelBlockedByBsaException + */ + public static void verifyNotBlockedByBsa(String domainLabel, Tld tld, DateTime now) + throws DomainLabelBlockedByBsaException { + if (isEnrolledWithBsa(tld, now) && isLabelBlocked(domainLabel)) { + throw new DomainLabelBlockedByBsaException(); + } + } + /** Returns whether a given domain create request is for a valid anchor tenant. */ public static boolean isAnchorTenant( InternetDomainName domainName, @@ -1742,4 +1757,12 @@ public class DomainFlowUtils { super("Registrar must be active in order to perform this operation"); } } + + /** Domain label is blocked by the Brand Safety Alliance. */ + static class DomainLabelBlockedByBsaException extends ParameterValuePolicyErrorException { + public DomainLabelBlockedByBsaException() { + // TODO(b/309174065): finalize the exception message. + super("Domain label is blocked by the Brand Safety Alliance"); + } + } } diff --git a/core/src/test/java/google/registry/bsa/persistence/BsaLabelTest.java b/core/src/test/java/google/registry/bsa/persistence/BsaLabelTest.java index b49686a63..968caceaa 100644 --- a/core/src/test/java/google/registry/bsa/persistence/BsaLabelTest.java +++ b/core/src/test/java/google/registry/bsa/persistence/BsaLabelTest.java @@ -41,4 +41,15 @@ public class BsaLabelTest { assertThat(persisted.getLabel()).isEqualTo("label"); assertThat(persisted.creationTime).isEqualTo(fakeClock.nowUtc()); } + + @Test + void isLabelBlocked_no() { + assertThat(tm().transact(() -> BsaLabelUtils.isLabelBlocked("abc"))).isFalse(); + } + + @Test + void isLabelBlocked_yes() { + tm().transact(() -> tm().put(new BsaLabel("abc", fakeClock.nowUtc()))); + assertThat(tm().transact(() -> BsaLabelUtils.isLabelBlocked("abc"))).isTrue(); + } } diff --git a/core/src/test/java/google/registry/bsa/persistence/BsaLabelTestingUtils.java b/core/src/test/java/google/registry/bsa/persistence/BsaLabelTestingUtils.java new file mode 100644 index 000000000..8ab720f47 --- /dev/null +++ b/core/src/test/java/google/registry/bsa/persistence/BsaLabelTestingUtils.java @@ -0,0 +1,29 @@ +// Copyright 2023 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.bsa.persistence; + +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; + +import org.joda.time.DateTime; + +/** Testing utils for users of {@link BsaLabel}. */ +public final class BsaLabelTestingUtils { + + private BsaLabelTestingUtils() {} + + public static void persistBsaLabel(String domainLabel, DateTime creationTime) { + tm().transact(() -> tm().put(new BsaLabel(domainLabel, creationTime))); + } +} diff --git a/core/src/test/java/google/registry/bsa/persistence/BsaLabelUtilsTest.java b/core/src/test/java/google/registry/bsa/persistence/BsaLabelUtilsTest.java new file mode 100644 index 000000000..936b8bd99 --- /dev/null +++ b/core/src/test/java/google/registry/bsa/persistence/BsaLabelUtilsTest.java @@ -0,0 +1,102 @@ +// Copyright 2023 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.bsa.persistence; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.bsa.persistence.BsaLabelTestingUtils.persistBsaLabel; +import static google.registry.bsa.persistence.BsaLabelUtils.isLabelBlocked; +import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm; +import static google.registry.persistence.transaction.TransactionManagerFactory.setJpaTm; +import static google.registry.persistence.transaction.TransactionManagerFactory.setReplicaJpaTm; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; +import static org.joda.time.DateTimeZone.UTC; +import static org.joda.time.Duration.millis; +import static org.joda.time.Duration.standardMinutes; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationWithCoverageExtension; +import google.registry.persistence.transaction.JpaTransactionManager; +import google.registry.testing.FakeClock; +import org.joda.time.DateTime; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Unit tests for {@link BsaLabelUtils}. */ +public class BsaLabelUtilsTest { + + protected FakeClock fakeClock = new FakeClock(DateTime.now(UTC)); + + @RegisterExtension + final JpaIntegrationWithCoverageExtension jpa = + new JpaTestExtensions.Builder().withClock(fakeClock).buildIntegrationWithCoverageExtension(); + + @Test + void isLabelBlocked_yes() { + persistBsaLabel("abc", fakeClock.nowUtc()); + assertThat(isLabelBlocked("abc")).isTrue(); + } + + @Test + void isLabelBlocked_no() { + assertThat(isLabelBlocked("abc")).isFalse(); + } + + @Test + void isLabelBlocked_isCacheUsed_withReplica() throws Throwable { + JpaTransactionManager primaryTmSave = tm(); + JpaTransactionManager replicaTmSave = replicaTm(); + + JpaTransactionManager primaryTm = mock(JpaTransactionManager.class); + JpaTransactionManager replicaTm = mock(JpaTransactionManager.class); + setJpaTm(() -> primaryTm); + setReplicaJpaTm(() -> replicaTm); + when(replicaTm.loadByKey(any())).thenReturn(new BsaLabel("abc", fakeClock.nowUtc())); + try { + assertThat(isLabelBlocked("abc")).isTrue(); + assertThat(isLabelBlocked("abc")).isTrue(); + verify(replicaTm, times(1)).loadByKey(any()); + verify(primaryTm, never()).loadByKey(any()); + } catch (Throwable e) { + setJpaTm(() -> primaryTmSave); + setReplicaJpaTm(() -> replicaTmSave); + } + } + + @Test + void isLabelBlocked_isCacheUsed_withOneMinuteExpiry() throws Throwable { + JpaTransactionManager replicaTmSave = replicaTm(); + JpaTransactionManager replicaTm = mock(JpaTransactionManager.class); + setReplicaJpaTm(() -> replicaTm); + when(replicaTm.loadByKey(any())).thenReturn(new BsaLabel("abc", fakeClock.nowUtc())); + try { + assertThat(isLabelBlocked("abc")).isTrue(); + /** + * If test fails, check and fix cache expiry in the config file. Do not increase the duration + * on the line below without proper discussion. + */ + fakeClock.advanceBy(standardMinutes(1).plus(millis(1))); + assertThat(isLabelBlocked("abc")).isTrue(); + verify(replicaTm, times(2)).loadByKey(any()); + } catch (Throwable e) { + setReplicaJpaTm(() -> replicaTmSave); + } + } +} diff --git a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java index 035f04190..f9e720992 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java @@ -18,6 +18,7 @@ import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.io.BaseEncoding.base16; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth8.assertThat; +import static google.registry.bsa.persistence.BsaLabelTestingUtils.persistBsaLabel; import static google.registry.flows.FlowTestCase.UserPrivileges.SUPERUSER; import static google.registry.model.billing.BillingBase.Flag.ANCHOR_TENANT; import static google.registry.model.billing.BillingBase.Flag.RESERVED; @@ -30,6 +31,7 @@ import static google.registry.model.domain.token.AllocationToken.TokenType.BULK_ import static google.registry.model.domain.token.AllocationToken.TokenType.DEFAULT_PROMO; import static google.registry.model.domain.token.AllocationToken.TokenType.SINGLE_USE; import static google.registry.model.domain.token.AllocationToken.TokenType.UNLIMITED_USE; +import static google.registry.model.eppcommon.EppXmlTransformer.marshal; import static google.registry.model.eppcommon.StatusValue.PENDING_DELETE; import static google.registry.model.eppcommon.StatusValue.SERVER_HOLD; import static google.registry.model.tld.Tld.TldState.GENERAL_AVAILABILITY; @@ -96,6 +98,7 @@ import google.registry.flows.domain.DomainFlowUtils.ClaimsPeriodEndedException; import google.registry.flows.domain.DomainFlowUtils.CurrencyUnitMismatchException; import google.registry.flows.domain.DomainFlowUtils.CurrencyValueScaleException; import google.registry.flows.domain.DomainFlowUtils.DashesInThirdAndFourthException; +import google.registry.flows.domain.DomainFlowUtils.DomainLabelBlockedByBsaException; import google.registry.flows.domain.DomainFlowUtils.DomainLabelTooLongException; import google.registry.flows.domain.DomainFlowUtils.DomainNameExistsAsTldException; import google.registry.flows.domain.DomainFlowUtils.DomainReservedException; @@ -165,6 +168,9 @@ import google.registry.model.domain.secdns.DomainDsData; import google.registry.model.domain.token.AllocationToken; import google.registry.model.domain.token.AllocationToken.RegistrationBehavior; import google.registry.model.domain.token.AllocationToken.TokenStatus; +import google.registry.model.eppcommon.Trid; +import google.registry.model.eppoutput.EppOutput; +import google.registry.model.eppoutput.EppResponse; import google.registry.model.poll.PendingActionNotificationResponse.DomainPendingActionNotificationResponse; import google.registry.model.poll.PollMessage; import google.registry.model.registrar.Registrar; @@ -183,7 +189,9 @@ import google.registry.tmch.LordnTaskUtils.LordnPhase; import google.registry.tmch.SmdrlCsvParser; import google.registry.tmch.TmchData; import google.registry.tmch.TmchTestData; +import google.registry.xml.ValidationMode; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Optional; import javax.annotation.Nullable; @@ -2562,6 +2570,53 @@ class DomainCreateFlowTest extends ResourceFlowTestCase + + + + Domain label is blocked by the Brand Safety Alliance + + + server-trid + + + diff --git a/docs/flows.md b/docs/flows.md index 18d489d57..c6fd540ec 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -384,6 +384,7 @@ An EPP flow that creates a new domain resource. * The requested fees cannot be provided in the requested currency. * Non-IDN domain names cannot contain hyphens in the third or fourth position. + * Domain label is blocked by the Brand Safety Alliance. * Domain labels cannot be longer than 63 characters. * More than one contact for a given role is not allowed. * No part of a domain name can be empty.