Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions core/src/main/java/google/registry/gcs/GcsUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,12 +36,14 @@
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;

/**
Expand All @@ -50,6 +55,8 @@ public class GcsUtils implements Serializable {

private static final FluentLogger logger = FluentLogger.forEnclosingClass();

private static final ConcurrentHashMap<String, Boolean> PAP_CACHE = new ConcurrentHashMap<>();

private static final ImmutableMap<String, MediaType> EXTENSIONS =
new ImmutableMap.Builder<String, MediaType>()
.put("ghostryde", MediaType.APPLICATION_BINARY)
Expand Down Expand Up @@ -85,6 +92,7 @@ public InputStream openInputStream(BlobId blobId) {
/** 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)));
}

Expand All @@ -94,6 +102,7 @@ public OutputStream openOutputStream(BlobId blobId) {
*/
@CheckReturnValue
public OutputStream openOutputStream(BlobId blobId, ImmutableMap<String, String> metadata) {
verifyPublicAccessPrevention(blobId.getBucket());
return Channels.newOutputStream(
storage().writer(BlobInfo.newBuilder(blobId).setMetadata(metadata).build()));
}
Expand All @@ -105,6 +114,7 @@ public void createFromBytes(BlobId blobId, byte[] bytes) throws StorageException

/** 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);
}

Expand All @@ -120,6 +130,7 @@ public void delete(BlobId blobId) throws StorageException {

/** 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();
Expand Down Expand Up @@ -154,12 +165,6 @@ public boolean existsAndNotEmpty(BlobId blobId) {
}
}

/** Returns the user defined metadata of a GCS file if the file exists, or an empty map. */
public ImmutableMap<String, String> 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.
*
Expand All @@ -179,6 +184,37 @@ private static BlobInfo createBlobInfo(BlobId blobId) {
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) {
Expand Down
106 changes: 105 additions & 1 deletion core/src/test/java/google/registry/gcs/GcsUtilsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,59 @@
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";
private String filename = "my-file";
private BlobId blobId = BlobId.of(bucket, filename);
private ImmutableMap<String, String> 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 {
Expand Down Expand Up @@ -111,6 +133,88 @@ void testEmptyFile() {
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);
Expand Down
Loading