diff --git a/core/src/main/java/google/registry/gcs/GcsUtils.java b/core/src/main/java/google/registry/gcs/GcsUtils.java index b556ce6d8..e1c972c25 100644 --- a/core/src/main/java/google/registry/gcs/GcsUtils.java +++ b/core/src/main/java/google/registry/gcs/GcsUtils.java @@ -14,12 +14,15 @@ package google.registry.gcs; +import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.Iterables.getLast; import com.google.cloud.storage.Blob; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Bucket; +import com.google.cloud.storage.BucketInfo; import com.google.cloud.storage.Storage; import com.google.cloud.storage.Storage.BlobListOption; import com.google.cloud.storage.StorageException; @@ -33,12 +36,14 @@ import com.google.common.flogger.FluentLogger; import com.google.common.net.MediaType; import google.registry.config.CredentialModule.ApplicationDefaultCredential; import google.registry.util.GoogleCredentialsBundle; +import google.registry.util.RegistryEnvironment; import jakarta.inject.Inject; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; import java.nio.channels.Channels; +import java.util.concurrent.ConcurrentHashMap; import javax.annotation.CheckReturnValue; /** @@ -50,6 +55,8 @@ public class GcsUtils implements Serializable { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final ConcurrentHashMap PAP_CACHE = new ConcurrentHashMap<>(); + private static final ImmutableMap EXTENSIONS = new ImmutableMap.Builder() .put("ghostryde", MediaType.APPLICATION_BINARY) @@ -85,6 +92,7 @@ public class GcsUtils implements Serializable { /** Opens a GCS file for writing as an {@link OutputStream}, overwriting existing files. */ @CheckReturnValue public OutputStream openOutputStream(BlobId blobId) { + verifyPublicAccessPrevention(blobId.getBucket()); return Channels.newOutputStream(storage().writer(createBlobInfo(blobId))); } @@ -94,6 +102,7 @@ public class GcsUtils implements Serializable { */ @CheckReturnValue public OutputStream openOutputStream(BlobId blobId, ImmutableMap metadata) { + verifyPublicAccessPrevention(blobId.getBucket()); return Channels.newOutputStream( storage().writer(BlobInfo.newBuilder(blobId).setMetadata(metadata).build())); } @@ -105,6 +114,7 @@ public class GcsUtils implements Serializable { /** Creates a GCS file with the given byte contents and metadata, overwriting existing files. */ public void createFromBytes(BlobInfo blobInfo, byte[] bytes) throws StorageException { + verifyPublicAccessPrevention(blobInfo.getBucket()); storage().create(blobInfo, bytes); } @@ -120,6 +130,7 @@ public class GcsUtils implements Serializable { /** Update file content type on existing GCS file */ public void updateContentType(BlobId blobId, String contentType) throws StorageException { + verifyPublicAccessPrevention(blobId.getBucket()); if (existsAndNotEmpty(blobId)) { Blob blob = storage().get(blobId); blob.toBuilder().setContentType(contentType).build().update(); @@ -154,12 +165,6 @@ public class GcsUtils implements Serializable { } } - /** Returns the user defined metadata of a GCS file if the file exists, or an empty map. */ - public ImmutableMap getMetadata(BlobId blobId) throws StorageException { - Blob blob = storage().get(blobId); - return blob == null ? ImmutableMap.of() : ImmutableMap.copyOf(blob.getMetadata()); - } - /** * Returns the {@link BlobInfo} of the given GCS file. * @@ -179,6 +184,37 @@ public class GcsUtils implements Serializable { return builder.build(); } + /** + * Asserts that Public Access Prevention (PAP) is enforced on the GCS bucket. + * + * @throws IllegalStateException if PAP is not ENFORCED. + */ + @VisibleForTesting + void verifyPublicAccessPrevention(String bucketName) { + if (RegistryEnvironment.get() != RegistryEnvironment.PRODUCTION) { + return; + } + PAP_CACHE.computeIfAbsent( + bucketName, + name -> { + Bucket bucket = storage().get(name); + checkState(bucket != null, "Bucket %s does not exist", name); + BucketInfo.PublicAccessPrevention pap = + bucket.getIamConfiguration().getPublicAccessPrevention(); + checkState( + pap == BucketInfo.PublicAccessPrevention.ENFORCED, + "Public Access Prevention is not enforced on bucket %s. Current state: %s", + name, + pap); + return true; + }); + } + + @VisibleForTesting + static void clearPapCache() { + PAP_CACHE.clear(); + } + // These two methods are needed to check whether serialization is done correctly in tests. @Override public boolean equals(Object obj) { diff --git a/core/src/test/java/google/registry/gcs/GcsUtilsTest.java b/core/src/test/java/google/registry/gcs/GcsUtilsTest.java index 6e38285b2..adef99981 100644 --- a/core/src/test/java/google/registry/gcs/GcsUtilsTest.java +++ b/core/src/test/java/google/registry/gcs/GcsUtilsTest.java @@ -15,27 +15,40 @@ package google.registry.gcs; import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Bucket; +import com.google.cloud.storage.BucketInfo; +import com.google.cloud.storage.Storage; import com.google.cloud.storage.StorageOptions; import com.google.cloud.storage.contrib.nio.testing.LocalStorageHelper; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.io.ByteStreams; import com.google.common.net.MediaType; +import google.registry.testing.SystemPropertyExtension; +import google.registry.util.RegistryEnvironment; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; /** Unit tests for {@link GcsUtilsTest}. */ class GcsUtilsTest { + @RegisterExtension + final SystemPropertyExtension systemPropertyExtension = new SystemPropertyExtension(); + private GcsUtils gcsUtils = new GcsUtils(LocalStorageHelper.getOptions()); private String bucket = "my-bucket"; @@ -43,9 +56,18 @@ class GcsUtilsTest { private BlobId blobId = BlobId.of(bucket, filename); private ImmutableMap metadata = ImmutableMap.of("key1", "val1", "Key2", "val2"); private final byte[] bytes = new byte[] {'a', 'b', 'c'}; + private RegistryEnvironment previousEnvironment; @BeforeEach - void beforeEach() {} + void beforeEach() { + previousEnvironment = RegistryEnvironment.get(); + GcsUtils.clearPapCache(); + } + + @AfterEach + void afterEach() { + previousEnvironment.setup(); + } @Test void testSerialization_testStorage() throws Exception { @@ -111,6 +133,88 @@ class GcsUtilsTest { assertThat(gcsUtils.existsAndNotEmpty(blobId)).isFalse(); } + @Test + void testVerifyPublicAccessPrevention_enforced() { + RegistryEnvironment.PRODUCTION.setup(systemPropertyExtension); + Storage mockStorage = mock(Storage.class); + StorageOptions mockOptions = mock(StorageOptions.class); + when(mockOptions.getService()).thenReturn(mockStorage); + + Bucket mockBucket = mock(Bucket.class); + when(mockStorage.get("my-bucket")).thenReturn(mockBucket); + + BucketInfo.IamConfiguration mockIamConfig = mock(BucketInfo.IamConfiguration.class); + when(mockBucket.getIamConfiguration()).thenReturn(mockIamConfig); + when(mockIamConfig.getPublicAccessPrevention()) + .thenReturn(BucketInfo.PublicAccessPrevention.ENFORCED); + + GcsUtils utils = new GcsUtils(mockOptions); + utils.verifyPublicAccessPrevention("my-bucket"); + } + + @Test + void testVerifyPublicAccessPrevention_notCheckedInNonProd() { + RegistryEnvironment.SANDBOX.setup(systemPropertyExtension); + Storage mockStorage = mock(Storage.class); + StorageOptions mockOptions = mock(StorageOptions.class); + when(mockOptions.getService()).thenReturn(mockStorage); + + Bucket mockBucket = mock(Bucket.class); + when(mockStorage.get("my-bucket")).thenReturn(mockBucket); + + BucketInfo.IamConfiguration mockIamConfig = mock(BucketInfo.IamConfiguration.class); + when(mockBucket.getIamConfiguration()).thenReturn(mockIamConfig); + when(mockIamConfig.getPublicAccessPrevention()) + .thenReturn(BucketInfo.PublicAccessPrevention.INHERITED); + + GcsUtils utils = new GcsUtils(mockOptions); + // no exception thrown even though PAP isn't enforced + utils.verifyPublicAccessPrevention("my-bucket"); + } + + @Test + void testVerifyPublicAccessPrevention_notEnforced() { + RegistryEnvironment.PRODUCTION.setup(systemPropertyExtension); + Storage mockStorage = mock(Storage.class); + StorageOptions mockOptions = mock(StorageOptions.class); + when(mockOptions.getService()).thenReturn(mockStorage); + + Bucket mockBucket = mock(Bucket.class); + when(mockStorage.get("my-bucket")).thenReturn(mockBucket); + + BucketInfo.IamConfiguration mockIamConfig = mock(BucketInfo.IamConfiguration.class); + when(mockBucket.getIamConfiguration()).thenReturn(mockIamConfig); + when(mockIamConfig.getPublicAccessPrevention()) + .thenReturn(BucketInfo.PublicAccessPrevention.INHERITED); + + GcsUtils utils = new GcsUtils(mockOptions); + + IllegalStateException thrown = + assertThrows( + IllegalStateException.class, () -> utils.verifyPublicAccessPrevention("my-bucket")); + assertThat(thrown) + .hasMessageThat() + .contains("Public Access Prevention is not enforced on bucket my-bucket"); + } + + @Test + void testVerifyPublicAccessPrevention_nonexistentBucket() { + RegistryEnvironment.PRODUCTION.setup(systemPropertyExtension); + Storage mockStorage = mock(Storage.class); + StorageOptions mockOptions = mock(StorageOptions.class); + when(mockOptions.getService()).thenReturn(mockStorage); + + when(mockStorage.get("nonexistent-bucket")).thenReturn(null); + + GcsUtils utils = new GcsUtils(mockOptions); + + IllegalStateException thrown = + assertThrows( + IllegalStateException.class, + () -> utils.verifyPublicAccessPrevention("nonexistent-bucket")); + assertThat(thrown).hasMessageThat().contains("Bucket nonexistent-bucket does not exist"); + } + private static byte[] serialize(Object object) throws IOException { try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { ObjectOutputStream oos = new ObjectOutputStream(baos);