diff --git a/google-cloud-clients/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-clients/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java index 04ad43f25057..8a05d024a930 100644 --- a/google-cloud-clients/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java +++ b/google-cloud-clients/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java @@ -892,7 +892,7 @@ class SignUrlOption implements Serializable { private final Object value; enum Option { - HTTP_METHOD, CONTENT_TYPE, MD5, EXT_HEADERS, SERVICE_ACCOUNT_CRED + HTTP_METHOD, CONTENT_TYPE, MD5, EXT_HEADERS, SERVICE_ACCOUNT_CRED, HOST_NAME } private SignUrlOption(Option option, Object value) { @@ -953,6 +953,13 @@ public static SignUrlOption withExtHeaders(Map extHeaders) { public static SignUrlOption signWith(ServiceAccountSigner signer) { return new SignUrlOption(Option.SERVICE_ACCOUNT_CRED, signer); } + + /** + * Use a different host name than the default host name 'storage.googleapis.com' + */ + public static SignUrlOption withHostName(String hostName){ + return new SignUrlOption(Option.HOST_NAME, hostName); + } } /** @@ -2107,6 +2114,8 @@ public static Builder newBuilder() { * granularity supported is 1 second, finer granularities will be truncated * @param unit time unit of the {@code duration} parameter * @param options optional URL signing options + * {@code SignUrlOption.withHostName()} option to set a custom host name instead of using + * https://storage.googleapis.com. * @throws IllegalStateException if {@link SignUrlOption#signWith(ServiceAccountSigner)} was not * used and no implementation of {@link ServiceAccountSigner} was provided to * {@link StorageOptions} diff --git a/google-cloud-clients/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java b/google-cloud-clients/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java index 787388006571..0c7dd5f5e558 100644 --- a/google-cloud-clients/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java +++ b/google-cloud-clients/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java @@ -84,6 +84,10 @@ final class StorageImpl extends BaseService implements Storage { private static final String EMPTY_BYTE_ARRAY_MD5 = "1B2M2Y8AsgTpgAmY7PhCfg=="; private static final String EMPTY_BYTE_ARRAY_CRC32C = "AAAAAA=="; private static final String PATH_DELIMITER = "/"; + /** + * Signed URLs are only supported through the GCS XML API endpoint. + */ + private static final String STORAGE_XML_HOST_NAME = "https://storage.googleapis.com"; private static final Function, Boolean> DELETE_FUNCTION = new Function, Boolean>() { @@ -535,7 +539,14 @@ public URL signUrl(BlobInfo blobInfo, long duration, TimeUnit unit, SignUrlOptio SignatureInfo signatureInfo = buildSignatureInfo(optionMap, blobInfo, expiration, path); byte[] signatureBytes = credentials.sign(signatureInfo.constructUnsignedPayload().getBytes(UTF_8)); - StringBuilder stBuilder = new StringBuilder("https://storage.googleapis.com").append(path); + StringBuilder stBuilder = new StringBuilder(); + if (optionMap.get(SignUrlOption.Option.HOST_NAME) == null) { + stBuilder.append(STORAGE_XML_HOST_NAME).append(path); + } + else { + stBuilder.append(optionMap.get(SignUrlOption.Option.HOST_NAME)).append(path); + } + String signature = URLEncoder.encode(BaseEncoding.base64().encode(signatureBytes), UTF_8.name()); stBuilder.append("?GoogleAccessId=").append(credentials.getAccount()); diff --git a/google-cloud-clients/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java b/google-cloud-clients/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java index 574c02ea001f..18621928434d 100644 --- a/google-cloud-clients/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java +++ b/google-cloud-clients/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java @@ -1633,6 +1633,47 @@ public void testSignUrl() assertTrue( signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); } + + @Test + public void testSignUrlWithHostName() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + UnsupportedEncodingException { + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + new ServiceAccountCredentials(null, ACCOUNT, privateKey, null, null); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + URL url = storage.signUrl(BLOB_INFO1, 14, TimeUnit.DAYS, Storage.SignUrlOption.withHostName("https://example.com")); + String stringUrl = url.toString(); + String expectedUrl = + new StringBuilder("https://example.com/") + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1) + .append("?GoogleAccessId=") + .append(ACCOUNT) + .append("&Expires=") + .append(42L + 1209600) + .append("&Signature=") + .toString(); + assertTrue(stringUrl.startsWith(expectedUrl)); + String signature = stringUrl.substring(expectedUrl.length()); + + StringBuilder signedMessageBuilder = new StringBuilder(); + signedMessageBuilder + .append(HttpMethod.GET) + .append("\n\n\n") + .append(42L + 1209600) + .append("\n/") + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1); + + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initVerify(publicKey); + signer.update(signedMessageBuilder.toString().getBytes(UTF_8)); + assertTrue( + signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); + } @Test public void testSignUrlLeadingSlash() @@ -1675,6 +1716,48 @@ public void testSignUrlLeadingSlash() assertTrue( signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); } + + @Test + public void testSignUrlLeadingSlashWithHostName() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + UnsupportedEncodingException { + String blobName = "/b1"; + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + new ServiceAccountCredentials(null, ACCOUNT, privateKey, null, null); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + URL url = + storage.signUrl(BlobInfo.newBuilder(BUCKET_NAME1, blobName).build(), 14, TimeUnit.DAYS, Storage.SignUrlOption.withHostName("https://example.com")); + String escapedBlobName = UrlEscapers.urlFragmentEscaper().escape(blobName); + String stringUrl = url.toString(); + String expectedUrl = + new StringBuilder("https://example.com/") + .append(BUCKET_NAME1) + .append(escapedBlobName) + .append("?GoogleAccessId=") + .append(ACCOUNT) + .append("&Expires=") + .append(42L + 1209600) + .append("&Signature=") + .toString(); + assertTrue(stringUrl.startsWith(expectedUrl)); + String signature = stringUrl.substring(expectedUrl.length()); + + StringBuilder signedMessageBuilder = new StringBuilder(); + signedMessageBuilder + .append(HttpMethod.GET) + .append("\n\n\n") + .append(42L + 1209600) + .append("\n/") + .append(BUCKET_NAME1) + .append(escapedBlobName); + + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initVerify(publicKey); + signer.update(signedMessageBuilder.toString().getBytes(UTF_8)); + assertTrue( + signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); + } @Test public void testSignUrlWithOptions() @@ -1727,6 +1810,59 @@ public void testSignUrlWithOptions() assertTrue( signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); } + + @Test + public void testSignUrlWithOptionsAndHostName() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + UnsupportedEncodingException { + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + new ServiceAccountCredentials(null, ACCOUNT, privateKey, null, null); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + URL url = + storage.signUrl( + BLOB_INFO1, + 14, + TimeUnit.DAYS, + Storage.SignUrlOption.httpMethod(HttpMethod.POST), + Storage.SignUrlOption.withContentType(), + Storage.SignUrlOption.withMd5(), + Storage.SignUrlOption.withHostName("https://example.com")); + String stringUrl = url.toString(); + String expectedUrl = + new StringBuilder("https://example.com/") + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1) + .append("?GoogleAccessId=") + .append(ACCOUNT) + .append("&Expires=") + .append(42L + 1209600) + .append("&Signature=") + .toString(); + assertTrue(stringUrl.startsWith(expectedUrl)); + String signature = stringUrl.substring(expectedUrl.length()); + + StringBuilder signedMessageBuilder = new StringBuilder(); + signedMessageBuilder + .append(HttpMethod.POST) + .append('\n') + .append(BLOB_INFO1.getMd5()) + .append('\n') + .append(BLOB_INFO1.getContentType()) + .append('\n') + .append(42L + 1209600) + .append("\n/") + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1); + + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initVerify(publicKey); + signer.update(signedMessageBuilder.toString().getBytes(UTF_8)); + assertTrue( + signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); + } @Test public void testSignUrlForBlobWithSpecialChars() @@ -1780,6 +1916,58 @@ public void testSignUrlForBlobWithSpecialChars() } } + @Test + public void testSignUrlForBlobWithSpecialCharsAndHostName() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + UnsupportedEncodingException { + // List of chars under test were taken from + // https://en.wikipedia.org/wiki/Percent-encoding#Percent-encoding_reserved_characters + char[] specialChars = + new char[] { + '!', '#', '$', '&', '\'', '(', ')', '*', '+', ',', ':', ';', '=', '?', '@', '[', ']' + }; + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + new ServiceAccountCredentials(null, ACCOUNT, privateKey, null, null); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + + for (char specialChar : specialChars) { + String blobName = "/a" + specialChar + "b"; + URL url = + storage.signUrl(BlobInfo.newBuilder(BUCKET_NAME1, blobName).build(), 14, TimeUnit.DAYS, Storage.SignUrlOption.withHostName("https://example.com")); + String escapedBlobName = + UrlEscapers.urlFragmentEscaper().escape(blobName).replace("?", "%3F"); + String stringUrl = url.toString(); + String expectedUrl = + new StringBuilder("https://example.com/") + .append(BUCKET_NAME1) + .append(escapedBlobName) + .append("?GoogleAccessId=") + .append(ACCOUNT) + .append("&Expires=") + .append(42L + 1209600) + .append("&Signature=") + .toString(); + assertTrue(stringUrl.startsWith(expectedUrl)); + String signature = stringUrl.substring(expectedUrl.length()); + + StringBuilder signedMessageBuilder = new StringBuilder(); + signedMessageBuilder + .append(HttpMethod.GET) + .append("\n\n\n") + .append(42L + 1209600) + .append("\n/") + .append(BUCKET_NAME1) + .append(escapedBlobName); + + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initVerify(publicKey); + signer.update(signedMessageBuilder.toString().getBytes(UTF_8)); + assertTrue( + signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); + } + } + @Test public void testSignUrlWithExtHeaders() throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, @@ -1836,7 +2024,65 @@ public void testSignUrlWithExtHeaders() assertTrue( signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); } + + @Test + public void testSignUrlWithExtHeadersAndHostName() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + UnsupportedEncodingException { + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + new ServiceAccountCredentials(null, ACCOUNT, privateKey, null, null); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + Map extHeaders = new HashMap(); + extHeaders.put("x-goog-acl", "public-read"); + extHeaders.put("x-goog-meta-owner", "myself"); + URL url = + storage.signUrl( + BLOB_INFO1, + 14, + TimeUnit.DAYS, + Storage.SignUrlOption.httpMethod(HttpMethod.PUT), + Storage.SignUrlOption.withContentType(), + Storage.SignUrlOption.withExtHeaders(extHeaders), + Storage.SignUrlOption.withHostName("https://example.com")); + String stringUrl = url.toString(); + String expectedUrl = + new StringBuilder("https://example.com/") + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1) + .append("?GoogleAccessId=") + .append(ACCOUNT) + .append("&Expires=") + .append(42L + 1209600) + .append("&Signature=") + .toString(); + assertTrue(stringUrl.startsWith(expectedUrl)); + String signature = stringUrl.substring(expectedUrl.length()); + + StringBuilder signedMessageBuilder = new StringBuilder(); + signedMessageBuilder + .append(HttpMethod.PUT) + .append('\n') + .append('\n') + .append(BLOB_INFO1.getContentType()) + .append('\n') + .append(42L + 1209600) + .append('\n') + .append("x-goog-acl:public-read\n") + .append("x-goog-meta-owner:myself\n") + .append('/') + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1); + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initVerify(publicKey); + signer.update(signedMessageBuilder.toString().getBytes(UTF_8)); + assertTrue( + signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); + } + @Test public void testSignUrlForBlobWithSlashes() throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, @@ -1879,6 +2125,49 @@ public void testSignUrlForBlobWithSlashes() assertTrue( signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); } + + @Test + public void testSignUrlForBlobWithSlashesAndHostName() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + UnsupportedEncodingException { + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + new ServiceAccountCredentials(null, ACCOUNT, privateKey, null, null); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + + String blobName = "/foo/bar/baz #%20other cool stuff.txt"; + URL url = + storage.signUrl(BlobInfo.newBuilder(BUCKET_NAME1, blobName).build(), 14, TimeUnit.DAYS, Storage.SignUrlOption.withHostName("https://example.com")); + String escapedBlobName = UrlEscapers.urlFragmentEscaper().escape(blobName); + String stringUrl = url.toString(); + String expectedUrl = + new StringBuilder("https://example.com/") + .append(BUCKET_NAME1) + .append(escapedBlobName) + .append("?GoogleAccessId=") + .append(ACCOUNT) + .append("&Expires=") + .append(42L + 1209600) + .append("&Signature=") + .toString(); + assertTrue(stringUrl.startsWith(expectedUrl)); + String signature = stringUrl.substring(expectedUrl.length()); + + StringBuilder signedMessageBuilder = new StringBuilder(); + signedMessageBuilder + .append(HttpMethod.GET) + .append("\n\n\n") + .append(42L + 1209600) + .append("\n/") + .append(BUCKET_NAME1) + .append(escapedBlobName); + + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initVerify(publicKey); + signer.update(signedMessageBuilder.toString().getBytes(UTF_8)); + assertTrue( + signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); + } @Test public void testGetAllArray() {