diff --git a/Dockerfile b/Dockerfile index 33a6016..94716cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,9 @@ ENV \ S3PROXY_IDENTITY="local-identity" \ S3PROXY_CREDENTIAL="local-credential" \ S3PROXY_CORS_ALLOW_ALL="false" \ + S3PROXY_CORS_ALLOW_ORIGINS="" \ + S3PROXY_CORS_ALLOW_METHODS="" \ + S3PROXY_CORS_ALLOW_HEADERS="" \ S3PROXY_IGNORE_UNKNOWN_HEADERS="false" \ JCLOUDS_PROVIDER="filesystem" \ JCLOUDS_ENDPOINT="" \ diff --git a/README.md b/README.md index 07844b3..cee8318 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ S3Proxy has broad compatibility with the S3 API, however, it does not support: * ACLs other than private and public-read * BitTorrent hosting * bucket logging -* cross-origin resource sharing, see [#142](https://github.com/gaul/s3proxy/issues/142) +* [CORS bucket operations](https://docs.aws.amazon.com/AmazonS3/latest/dev/cors.html#how-do-i-enable-cors) like getting or setting the CORS configuration for a bucket. S3Proxy only supports a static configuration (see below). * hosting static websites * list objects v2, see [#168](https://github.com/gaul/s3proxy/issues/168) * object server-side encryption @@ -117,6 +117,18 @@ S3Proxy emulates the following operations: * copy multi-part objects, see [#76](https://github.com/gaul/s3proxy/issues/76) +S3Proxy has basic CORS preflight and actual request/response handling. It can be configured within the properties +file (and corresponding ENV variables for Docker): + +``` +s3proxy.cors-allow-origins=https://example\.com https://.+\.example\.com https://example\.cloud +s3proxy.cors-allow-methods=GET PUT +s3proxy.cors-allow-headers=Accept Content-Type +``` + +CORS cannot be configured per bucket. `s3proxy.cors-allow-all=true` will accept any origin and header. +Actual CORS requests are supported for GET, PUT and POST methods. + The wiki collects [compatibility notes](https://github.com/gaul/s3proxy/wiki/Storage-backend-compatibility) for specific storage backends. diff --git a/src/main/java/org/gaul/s3proxy/AwsSignature.java b/src/main/java/org/gaul/s3proxy/AwsSignature.java index c837d22..4d85885 100644 --- a/src/main/java/org/gaul/s3proxy/AwsSignature.java +++ b/src/main/java/org/gaul/s3proxy/AwsSignature.java @@ -280,13 +280,32 @@ final class AwsSignature { signedHeaders = Splitter.on(';').splitToList(request.getParameter( "X-Amz-SignedHeaders")); } + + /* + * CORS Preflight + * + * The signature is based on the canonical request, which includes the + * HTTP Method. + * For presigned URLs, the method must be replaced for OPTIONS request + * to match + */ + String method = request.getMethod(); + if ("OPTIONS".equals(method)) { + String corsMethod = request.getHeader( + HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD); + if (corsMethod != null) { + method = corsMethod; + } + } + String canonicalRequest = Joiner.on("\n").join( - request.getMethod(), + method, uri, buildCanonicalQueryString(request), buildCanonicalHeaders(request, signedHeaders) + "\n", Joiner.on(';').join(signedHeaders), digest); + return getMessageDigest( canonicalRequest.getBytes(StandardCharsets.UTF_8), hashAlgorithm); diff --git a/src/main/java/org/gaul/s3proxy/CrossOriginResourceSharing.java b/src/main/java/org/gaul/s3proxy/CrossOriginResourceSharing.java new file mode 100644 index 0000000..53fed28 --- /dev/null +++ b/src/main/java/org/gaul/s3proxy/CrossOriginResourceSharing.java @@ -0,0 +1,163 @@ +/* + * Copyright 2014-2018 Andrew Gaul + * + * 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 org.gaul.s3proxy; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class CrossOriginResourceSharing { + private static final String HEADER_VALUE_SEPARATOR = ", "; + private static final String ALLOW_ANY_HEADER = "*"; + + private static final Logger logger = LoggerFactory.getLogger( + CrossOriginResourceSharing.class); + + private final String allowedMethodsRaw; + private final String allowedHeadersRaw; + private final Set allowedOrigins; + private final Set allowedMethods; + private final Set allowedHeaders; + + protected CrossOriginResourceSharing() { + // CORS Allow all + this(Lists.newArrayList(".*"), Lists.newArrayList("GET", "PUT", "POST"), + Lists.newArrayList(ALLOW_ANY_HEADER)); + } + + protected CrossOriginResourceSharing(Collection allowedOrigins, + Collection allowedMethods, + Collection allowedHeaders) { + Set allowedPattern = new HashSet(); + if (allowedOrigins != null) { + for (String origin : allowedOrigins) { + allowedPattern.add(Pattern.compile( + origin, Pattern.CASE_INSENSITIVE)); + } + } + this.allowedOrigins = ImmutableSet.copyOf(allowedPattern); + + if (allowedMethods == null) { + this.allowedMethods = ImmutableSet.of(); + } else { + this.allowedMethods = ImmutableSet.copyOf(allowedMethods); + } + this.allowedMethodsRaw = Joiner.on(HEADER_VALUE_SEPARATOR).join( + this.allowedMethods); + + if (allowedHeaders == null) { + this.allowedHeaders = ImmutableSet.of(); + } else { + this.allowedHeaders = ImmutableSet.copyOf(allowedHeaders); + } + this.allowedHeadersRaw = Joiner.on(HEADER_VALUE_SEPARATOR).join( + this.allowedHeaders); + + logger.info("CORS allowed origins: {}", allowedOrigins); + logger.info("CORS allowed methods: {}", allowedMethods); + logger.info("CORS allowed headers: {}", allowedHeaders); + } + + public String getAllowedMethods() { + return this.allowedMethodsRaw; + } + + public boolean isOriginAllowed(String origin) { + if (!Strings.isNullOrEmpty(origin)) { + for (Pattern pattern : this.allowedOrigins) { + Matcher matcher = pattern.matcher(origin); + if (matcher.matches()) { + logger.debug("CORS origin allowed: {}", origin); + return true; + } + } + } + logger.debug("CORS origin not allowed: {}", origin); + return false; + } + + public boolean isMethodAllowed(String method) { + if (!Strings.isNullOrEmpty(method)) { + if (this.allowedMethods.contains(method)) { + logger.debug("CORS method allowed: {}", method); + return true; + } + } + logger.debug("CORS method not allowed: {}", method); + return false; + } + + public boolean isEveryHeaderAllowed(String headers) { + boolean result = false; + + if (!Strings.isNullOrEmpty(headers)) { + if (this.allowedHeadersRaw.equals(ALLOW_ANY_HEADER)) { + result = true; + } else { + for (String header : Splitter.on(HEADER_VALUE_SEPARATOR).split( + headers)) { + result = this.allowedHeaders.contains(header); + if (!result) { + // First not matching header breaks + break; + } + } + } + } + + if (result) { + logger.debug("CORS headers allowed: {}", headers); + } else { + logger.debug("CORS headers not allowed: {}", headers); + } + + return result; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (object == null || !(object instanceof CrossOriginResourceSharing)) { + return false; + } + + CrossOriginResourceSharing that = (CrossOriginResourceSharing) object; + return this.allowedOrigins.equals(that.allowedOrigins) && + this.allowedMethodsRaw.equals(that.allowedMethodsRaw) && + this.allowedHeadersRaw.equals(that.allowedHeadersRaw); + } + + @Override + public int hashCode() { + return Objects.hash(this.allowedOrigins, this.allowedMethodsRaw, + this.allowedHeadersRaw); + } +} diff --git a/src/main/java/org/gaul/s3proxy/S3ErrorCode.java b/src/main/java/org/gaul/s3proxy/S3ErrorCode.java index 4677fbe..895dde6 100644 --- a/src/main/java/org/gaul/s3proxy/S3ErrorCode.java +++ b/src/main/java/org/gaul/s3proxy/S3ErrorCode.java @@ -46,6 +46,10 @@ enum S3ErrorCode { INVALID_ARGUMENT(HttpServletResponse.SC_BAD_REQUEST, "Bad Request"), INVALID_BUCKET_NAME(HttpServletResponse.SC_BAD_REQUEST, "The specified bucket is not valid."), + INVALID_CORS_ORIGIN(HttpServletResponse.SC_BAD_REQUEST, + "Insufficient information. Origin request header needed."), + INVALID_CORS_METHOD(HttpServletResponse.SC_BAD_REQUEST, + "The specified Access-Control-Request-Method is not valid."), INVALID_DIGEST(HttpServletResponse.SC_BAD_REQUEST, "Bad Request"), INVALID_LOCATION_CONSTRAINT(HttpServletResponse.SC_BAD_REQUEST, "The specified location constraint is not valid. For" + diff --git a/src/main/java/org/gaul/s3proxy/S3Proxy.java b/src/main/java/org/gaul/s3proxy/S3Proxy.java index d3d259e..082334b 100644 --- a/src/main/java/org/gaul/s3proxy/S3Proxy.java +++ b/src/main/java/org/gaul/s3proxy/S3Proxy.java @@ -25,7 +25,9 @@ import java.net.URISyntaxException; import java.util.Objects; import java.util.Properties; +import com.google.common.base.Splitter; import com.google.common.base.Strings; +import com.google.common.collect.Lists; import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; @@ -114,7 +116,7 @@ public final class S3Proxy { builder.authenticationType, builder.identity, builder.credential, builder.virtualHost, builder.v4MaxNonChunkedRequestSize, - builder.ignoreUnknownHeaders, builder.corsAllowAll, + builder.ignoreUnknownHeaders, builder.corsRules, builder.servicePath); server.setHandler(handler); } @@ -133,7 +135,7 @@ public final class S3Proxy { private String virtualHost; private long v4MaxNonChunkedRequestSize = 32 * 1024 * 1024; private boolean ignoreUnknownHeaders; - private boolean corsAllowAll; + private CrossOriginResourceSharing corsRules; private int jettyMaxThreads = 200; // sourced from QueuedThreadPool() Builder() { @@ -240,9 +242,23 @@ public final class S3Proxy { String corsAllowAll = properties.getProperty( S3ProxyConstants.PROPERTY_CORS_ALLOW_ALL); - if (corsAllowAll != null) { - builder.corsAllowAll(Boolean.parseBoolean( - corsAllowAll)); + if (!Strings.isNullOrEmpty(corsAllowAll) && Boolean.parseBoolean( + corsAllowAll)) { + builder.corsRules(new CrossOriginResourceSharing()); + } else { + String corsAllowOrigins = properties.getProperty( + S3ProxyConstants.PROPERTY_CORS_ALLOW_ORIGINS, ""); + String corsAllowMethods = properties.getProperty( + S3ProxyConstants.PROPERTY_CORS_ALLOW_METHODS, ""); + String corsAllowHeaders = properties.getProperty( + S3ProxyConstants.PROPERTY_CORS_ALLOW_HEADERS, ""); + Splitter splitter = Splitter.on(" ").trimResults() + .omitEmptyStrings(); + + builder.corsRules(new CrossOriginResourceSharing( + Lists.newArrayList(splitter.split(corsAllowOrigins)), + Lists.newArrayList(splitter.split(corsAllowMethods)), + Lists.newArrayList(splitter.split(corsAllowHeaders)))); } String jettyMaxThreads = properties.getProperty( @@ -304,8 +320,8 @@ public final class S3Proxy { return this; } - public Builder corsAllowAll(boolean corsAllowAll) { - this.corsAllowAll = corsAllowAll; + public Builder corsRules(CrossOriginResourceSharing corsRules) { + this.corsRules = corsRules; return this; } @@ -368,7 +384,7 @@ public final class S3Proxy { that.v4MaxNonChunkedRequestSize) && Objects.equals(this.ignoreUnknownHeaders, that.ignoreUnknownHeaders) && - Objects.equals(this.corsAllowAll, that.corsAllowAll); + this.corsRules.equals(that.corsRules); } @Override @@ -376,7 +392,7 @@ public final class S3Proxy { return Objects.hash(endpoint, secureEndpoint, keyStorePath, keyStorePassword, virtualHost, servicePath, v4MaxNonChunkedRequestSize, ignoreUnknownHeaders, - corsAllowAll); + corsRules); } } diff --git a/src/main/java/org/gaul/s3proxy/S3ProxyConstants.java b/src/main/java/org/gaul/s3proxy/S3ProxyConstants.java index 94ac449..21c7b3a 100644 --- a/src/main/java/org/gaul/s3proxy/S3ProxyConstants.java +++ b/src/main/java/org/gaul/s3proxy/S3ProxyConstants.java @@ -32,6 +32,12 @@ public final class S3ProxyConstants { /** When true, include "Access-Control-Allow-Origin: *" in all responses. */ public static final String PROPERTY_CORS_ALLOW_ALL = "s3proxy.cors-allow-all"; + public static final String PROPERTY_CORS_ALLOW_ORIGINS = + "s3proxy.cors-allow-origins"; + public static final String PROPERTY_CORS_ALLOW_METHODS = + "s3proxy.cors-allow-methods"; + public static final String PROPERTY_CORS_ALLOW_HEADERS = + "s3proxy.cors-allow-headers"; public static final String PROPERTY_CREDENTIAL = "s3proxy.credential"; public static final String PROPERTY_IGNORE_UNKNOWN_HEADERS = diff --git a/src/main/java/org/gaul/s3proxy/S3ProxyHandler.java b/src/main/java/org/gaul/s3proxy/S3ProxyHandler.java index 6ef21e7..6b266b4 100644 --- a/src/main/java/org/gaul/s3proxy/S3ProxyHandler.java +++ b/src/main/java/org/gaul/s3proxy/S3ProxyHandler.java @@ -191,7 +191,7 @@ public class S3ProxyHandler { private final Optional virtualHost; private final long v4MaxNonChunkedRequestSize; private final boolean ignoreUnknownHeaders; - private final boolean corsAllowAll; + private final CrossOriginResourceSharing corsRules; private final String servicePath; private final XMLOutputFactory xmlOutputFactory = XMLOutputFactory.newInstance(); @@ -214,7 +214,7 @@ public class S3ProxyHandler { AuthenticationType authenticationType, final String identity, final String credential, @Nullable String virtualHost, long v4MaxNonChunkedRequestSize, boolean ignoreUnknownHeaders, - boolean corsAllowAll, final String servicePath) { + CrossOriginResourceSharing corsRules, final String servicePath) { if (authenticationType != AuthenticationType.NONE) { anonymousIdentity = false; blobStoreLocator = new BlobStoreLocator() { @@ -244,7 +244,7 @@ public class S3ProxyHandler { this.virtualHost = Optional.fromNullable(virtualHost); this.v4MaxNonChunkedRequestSize = v4MaxNonChunkedRequestSize; this.ignoreUnknownHeaders = ignoreUnknownHeaders; - this.corsAllowAll = corsAllowAll; + this.corsRules = corsRules; this.defaultBlobStore = blobStore; xmlOutputFactory.setProperty("javax.xml.stream.isRepairingNamespaces", Boolean.FALSE); @@ -326,7 +326,7 @@ public class S3ProxyHandler { // treat it as anonymous, return all public accessible information if (!anonymousIdentity && (method.equals("GET") || method.equals("HEAD") || - method.equals("POST")) && + method.equals("POST") || method.equals("OPTIONS")) && request.getHeader(HttpHeaders.AUTHORIZATION) == null && // v2 or /v4 request.getParameter("X-Amz-Algorithm") == null && // v4 query @@ -729,6 +729,10 @@ public class S3ProxyHandler { path[2]); return; } + case "OPTIONS": + handleOptionsBlob(request, response, blobStore, path[1], + path[2]); + return; default: break; } @@ -807,6 +811,24 @@ public class S3ProxyHandler { return; } break; + case "OPTIONS": + if (uri.equals("/")) { + throw new S3Exception(S3ErrorCode.ACCESS_DENIED); + } else { + String containerName = path[1]; + /* + * Only check access on bucket level. The preflight request + * might be for a PUT, so the object is not yet there. + */ + ContainerAccess access = blobStore.getContainerAccess( + containerName); + if (access == ContainerAccess.PRIVATE) { + throw new S3Exception(S3ErrorCode.ACCESS_DENIED); + } + handleOptionsBlob(request, response, blobStore, containerName, + ""); + return; + } default: break; } @@ -1322,6 +1344,14 @@ public class S3ProxyHandler { PageSet set = blobStore.list(containerName, options); + String corsOrigin = request.getHeader(HttpHeaders.ORIGIN); + if (!Strings.isNullOrEmpty(corsOrigin) && + corsRules.isOriginAllowed(corsOrigin)) { + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, + corsOrigin); + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET"); + } + response.setCharacterEncoding(UTF_8); try (Writer writer = response.getWriter()) { response.setContentType(XML_CONTENT_TYPE); @@ -1519,6 +1549,48 @@ public class S3ProxyHandler { addMetadataToResponse(request, response, metadata); } + private void handleOptionsBlob(HttpServletRequest request, + HttpServletResponse response, + BlobStore blobStore, String containerName, + String blobName) throws IOException, S3Exception { + if (!blobStore.containerExists(containerName)) { + // Don't leak internal information, although authenticated + throw new S3Exception(S3ErrorCode.ACCESS_DENIED); + } + + String corsOrigin = request.getHeader(HttpHeaders.ORIGIN); + if (Strings.isNullOrEmpty(corsOrigin)) { + throw new S3Exception(S3ErrorCode.INVALID_CORS_ORIGIN); + } + if (!corsRules.isOriginAllowed(corsOrigin)) { + throw new S3Exception(S3ErrorCode.ACCESS_DENIED); + } + + String corsMethod = request.getHeader( + HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD); + if (!corsRules.isMethodAllowed(corsMethod)) { + throw new S3Exception(S3ErrorCode.INVALID_CORS_METHOD); + } + + String corsHeaders = request.getHeader( + HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS); + if (!Strings.isNullOrEmpty(corsHeaders)) { + if (corsRules.isEveryHeaderAllowed(corsHeaders)) { + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, + corsHeaders); + } else { + throw new S3Exception(S3ErrorCode.ACCESS_DENIED); + } + } + + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, corsOrigin); + response.addHeader(HttpHeaders.VARY, HttpHeaders.ORIGIN); + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, + corsRules.getAllowedMethods()); + + response.setStatus(HttpServletResponse.SC_OK); + } + private void handleGetBlob(HttpServletRequest request, HttpServletResponse response, BlobStore blobStore, String containerName, String blobName) @@ -1572,8 +1644,12 @@ public class S3ProxyHandler { response.setStatus(status); - if (corsAllowAll) { - response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + String corsOrigin = request.getHeader(HttpHeaders.ORIGIN); + if (!Strings.isNullOrEmpty(corsOrigin) && + corsRules.isOriginAllowed(corsOrigin)) { + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, + corsOrigin); + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET"); } addMetadataToResponse(request, response, blob.getMetadata()); @@ -1715,7 +1791,7 @@ public class S3ProxyHandler { } } - private static void handlePutBlob(HttpServletRequest request, + private void handlePutBlob(HttpServletRequest request, HttpServletResponse response, InputStream is, BlobStore blobStore, String containerName, String blobName) throws IOException, S3Exception { @@ -1810,6 +1886,14 @@ public class S3ProxyHandler { eTag = blobStore.putBlob(containerName, builder.build(), options); + String corsOrigin = request.getHeader(HttpHeaders.ORIGIN); + if (!Strings.isNullOrEmpty(corsOrigin) && + corsRules.isOriginAllowed(corsOrigin)) { + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, + corsOrigin); + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "PUT"); + } + response.addHeader(HttpHeaders.ETAG, maybeQuoteETag(eTag)); } @@ -1974,8 +2058,13 @@ public class S3ProxyHandler { response.setStatus(HttpServletResponse.SC_NO_CONTENT); - if (corsAllowAll) { - response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + String corsOrigin = request.getHeader(HttpHeaders.ORIGIN); + if (!Strings.isNullOrEmpty(corsOrigin) && + corsRules.isOriginAllowed(corsOrigin)) { + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, + corsOrigin); + response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, + "POST"); } } diff --git a/src/main/java/org/gaul/s3proxy/S3ProxyHandlerJetty.java b/src/main/java/org/gaul/s3proxy/S3ProxyHandlerJetty.java index 38326d0..4722d94 100644 --- a/src/main/java/org/gaul/s3proxy/S3ProxyHandlerJetty.java +++ b/src/main/java/org/gaul/s3proxy/S3ProxyHandlerJetty.java @@ -44,10 +44,10 @@ final class S3ProxyHandlerJetty extends AbstractHandler { AuthenticationType authenticationType, final String identity, final String credential, @Nullable String virtualHost, long v4MaxNonChunkedRequestSize, boolean ignoreUnknownHeaders, - boolean corsAllowAll, String servicePath) { + CrossOriginResourceSharing corsRules, String servicePath) { handler = new S3ProxyHandler(blobStore, authenticationType, identity, credential, virtualHost, v4MaxNonChunkedRequestSize, - ignoreUnknownHeaders, corsAllowAll, servicePath); + ignoreUnknownHeaders, corsRules, servicePath); } private void sendS3Exception(HttpServletRequest request, diff --git a/src/main/resources/run-docker-container.sh b/src/main/resources/run-docker-container.sh index dee490a..9aa7e40 100755 --- a/src/main/resources/run-docker-container.sh +++ b/src/main/resources/run-docker-container.sh @@ -8,6 +8,9 @@ exec java \ -Ds3proxy.identity=${S3PROXY_IDENTITY} \ -Ds3proxy.credential=${S3PROXY_CREDENTIAL} \ -Ds3proxy.cors-allow-all=${S3PROXY_CORS_ALLOW_ALL} \ + -Ds3proxy.cors-allow-origins="${S3PROXY_CORS_ALLOW_ORIGINS}" \ + -Ds3proxy.cors-allow-methods="${S3PROXY_CORS_ALLOW_METHODS}" \ + -Ds3proxy.cors-allow-headers="${S3PROXY_CORS_ALLOW_HEADERS}" \ -Ds3proxy.ignore-unknown-headers=${S3PROXY_IGNORE_UNKNOWN_HEADERS} \ -Djclouds.provider=${JCLOUDS_PROVIDER} \ -Djclouds.identity=${JCLOUDS_IDENTITY} \ diff --git a/src/test/java/org/gaul/s3proxy/CrossOriginResourceSharingResponseTest.java b/src/test/java/org/gaul/s3proxy/CrossOriginResourceSharingResponseTest.java new file mode 100644 index 0000000..7d5a749 --- /dev/null +++ b/src/test/java/org/gaul/s3proxy/CrossOriginResourceSharingResponseTest.java @@ -0,0 +1,366 @@ +/* + * Copyright 2014-2018 Andrew Gaul + * + * 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 org.gaul.s3proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLContext; + +import com.amazonaws.HttpMethod; +import com.amazonaws.SDKGlobalConfiguration; +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.CannedAccessControlList; + +import com.google.common.io.ByteSource; +import com.google.common.net.HttpHeaders; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpOptions; +import org.apache.http.config.Registry; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.conn.socket.ConnectionSocketFactory; +import org.apache.http.conn.socket.PlainConnectionSocketFactory; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.conn.ssl.TrustStrategy; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.ssl.SSLContextBuilder; + +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.blobstore.domain.Blob; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public final class CrossOriginResourceSharingResponseTest { + static { + System.setProperty( + SDKGlobalConfiguration.DISABLE_CERT_CHECKING_SYSTEM_PROPERTY, + "true"); + AwsSdkTest.disableSslVerification(); + } + + private URI s3Endpoint; + private EndpointConfiguration s3EndpointConfig; + private S3Proxy s3Proxy; + private BlobStoreContext context; + private String blobStoreType; + private String containerName; + private AWSCredentials awsCreds; + private AmazonS3 s3Client; + private String servicePath; + private CloseableHttpClient httpClient; + private URI presignedGET; + private URI publicGET; + + @Before + public void setUp() throws Exception { + TestUtils.S3ProxyLaunchInfo info = TestUtils.startS3Proxy( + "s3proxy-cors.conf"); + awsCreds = new BasicAWSCredentials(info.getS3Identity(), + info.getS3Credential()); + context = info.getBlobStore().getContext(); + s3Proxy = info.getS3Proxy(); + s3Endpoint = info.getSecureEndpoint(); + servicePath = info.getServicePath(); + s3EndpointConfig = new EndpointConfiguration( + s3Endpoint.toString() + servicePath, "us-east-1"); + s3Client = AmazonS3ClientBuilder.standard() + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .withEndpointConfiguration(s3EndpointConfig) + .build(); + httpClient = getHttpClient(); + + containerName = createRandomContainerName(); + info.getBlobStore().createContainerInLocation(null, containerName); + + s3Client.setBucketAcl(containerName, + CannedAccessControlList.PublicRead); + + String blobName = "test"; + ByteSource payload = ByteSource.wrap("blob-content".getBytes( + StandardCharsets.UTF_8)); + Blob blob = info.getBlobStore().blobBuilder(blobName) + .payload(payload).contentLength(payload.size()).build(); + info.getBlobStore().putBlob(containerName, blob); + + Date expiration = new Date(System.currentTimeMillis() + + TimeUnit.HOURS.toMillis(1)); + presignedGET = s3Client.generatePresignedUrl(containerName, blobName, + expiration, HttpMethod.GET).toURI(); + + publicGET = s3Client.getUrl(containerName, blobName).toURI(); + } + + @After + public void tearDown() throws Exception { + if (s3Proxy != null) { + s3Proxy.stop(); + } + if (context != null) { + context.getBlobStore().deleteContainer(containerName); + context.close(); + } + if (httpClient != null) { + httpClient.close(); + } + } + + @Test + public void testCorsPreflightNegative() throws Exception { + // No CORS headers + HttpOptions request = new HttpOptions(presignedGET); + HttpResponse response = httpClient.execute(request); + /* + * For non presigned URLs that should give a 400, but the + * Access-Control-Request-Method header is needed for presigned URLs + * to calculate the same signature. If this is missing it fails already + * with 403 - Signature mismatch before processing the OPTIONS request + * See testCorsPreflightPublicRead for that cases + */ + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_FORBIDDEN); + + // Not allowed origin + request.reset(); + request.setHeader(HttpHeaders.ORIGIN, "https://example.org"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_FORBIDDEN); + + // Not allowed method + request.reset(); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "PATCH"); + response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_FORBIDDEN); + + // Not allowed header + request.reset(); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, + "Accept-Encoding"); + response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_FORBIDDEN); + + // Not allowed header combination + request.reset(); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, + "Accept, Accept-Encoding"); + response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_FORBIDDEN); + } + + @Test + public void testCorsPreflight() throws Exception { + // Allowed origin and method + HttpOptions request = new HttpOptions(presignedGET); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + HttpResponse response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_OK); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN).getValue()) + .isEqualTo("https://example.com"); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS).getValue()) + .isEqualTo("GET, PUT"); + + // Allowed origin, method and header + request.reset(); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Accept"); + response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_OK); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN).getValue()) + .isEqualTo("https://example.com"); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS).getValue()) + .isEqualTo("GET, PUT"); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS).getValue()) + .isEqualTo("Accept"); + + // Allowed origin, method and header combination + request.reset(); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, + "Accept, Content-Type"); + response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_OK); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN).getValue()) + .isEqualTo("https://example.com"); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS).getValue()) + .isEqualTo("GET, PUT"); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS).getValue()) + .isEqualTo("Accept, Content-Type"); + } + + @Test + public void testCorsPreflightPublicRead() throws Exception { + // No CORS headers + HttpOptions request = new HttpOptions(publicGET); + HttpResponse response = httpClient.execute(request); + + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_BAD_REQUEST); + + // Not allowed method + request.reset(); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "PATCH"); + response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_BAD_REQUEST); + + // Allowed origin and method + request.reset(); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + request.setHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, + "Accept, Content-Type"); + response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_OK); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN).getValue()) + .isEqualTo("https://example.com"); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS).getValue()) + .isEqualTo("GET, PUT"); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS).getValue()) + .isEqualTo("Accept, Content-Type"); + } + + @Test + public void testCorsActual() throws Exception { + HttpGet request = new HttpGet(presignedGET); + request.setHeader(HttpHeaders.ORIGIN, "https://example.com"); + HttpResponse response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_OK); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN).getValue()) + .isEqualTo("https://example.com"); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).isTrue(); + assertThat(response.getFirstHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS).getValue()) + .isEqualTo("GET"); + } + + @Test + public void testNonCors() throws Exception { + HttpGet request = new HttpGet(presignedGET); + HttpResponse response = httpClient.execute(request); + assertThat(response.getStatusLine().getStatusCode()) + .isEqualTo(HttpStatus.SC_OK); + assertThat(response.containsHeader( + HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isFalse(); + } + + private static String createRandomContainerName() { + return "s3proxy-" + new Random().nextInt(Integer.MAX_VALUE); + } + + private static CloseableHttpClient getHttpClient() throws + KeyManagementException, NoSuchAlgorithmException, + KeyStoreException { + // Relax SSL Certificate check + SSLContext sslContext = new SSLContextBuilder().loadTrustMaterial( + null, new TrustStrategy() { + public boolean isTrusted(X509Certificate[] arg0, + String arg1) throws CertificateException { + return true; + } + }).build(); + + Registry registry = RegistryBuilder + .create() + .register("http", PlainConnectionSocketFactory.INSTANCE) + .register("https", new SSLConnectionSocketFactory(sslContext, + NoopHostnameVerifier.INSTANCE)).build(); + + PoolingHttpClientConnectionManager connectionManager = new + PoolingHttpClientConnectionManager(registry); + + return HttpClients.custom().setConnectionManager(connectionManager) + .build(); + } +} diff --git a/src/test/java/org/gaul/s3proxy/CrossOriginResourceSharingRuleTest.java b/src/test/java/org/gaul/s3proxy/CrossOriginResourceSharingRuleTest.java new file mode 100644 index 0000000..1ddab5c --- /dev/null +++ b/src/test/java/org/gaul/s3proxy/CrossOriginResourceSharingRuleTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2014-2018 Andrew Gaul + * + * 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 org.gaul.s3proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.Lists; + +import org.junit.Before; +import org.junit.Test; + +public final class CrossOriginResourceSharingRuleTest { + private CrossOriginResourceSharing corsAll; + private CrossOriginResourceSharing corsCfg; + private CrossOriginResourceSharing corsOff; + + @Before + public void setUp() throws Exception { + // CORS Allow All + corsAll = new CrossOriginResourceSharing(); + // CORS Configured + corsCfg = new CrossOriginResourceSharing( + Lists.newArrayList("https://example\\.com", + "https://.+\\.example\\.com", + "https://example\\.cloud"), + Lists.newArrayList("GET", "PUT"), + Lists.newArrayList("Accept", "Content-Type")); + // CORS disabled + corsOff = new CrossOriginResourceSharing(null, null, null); + } + + @Test + public void testCorsOffOrigin() throws Exception { + String probe = ""; + assertThat(corsOff.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isFalse(); + probe = "https://example.com"; + assertThat(corsOff.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isFalse(); + } + + @Test + public void testCorsOffMethod() throws Exception { + String probe = ""; + assertThat(corsOff.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isFalse(); + probe = "GET"; + assertThat(corsOff.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isFalse(); + } + + @Test + public void testCorsOffHeader() throws Exception { + String probe = ""; + assertThat(corsOff.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isFalse(); + probe = "Accept"; + assertThat(corsOff.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isFalse(); + probe = "Accept, Content-Type"; + assertThat(corsOff.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isFalse(); + } + + @Test + public void testCorsAllOrigin() throws Exception { + String probe = ""; + assertThat(corsAll.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isFalse(); + probe = "https://example.com"; + assertThat(corsAll.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isTrue(); + probe = "https://sub.example.com"; + assertThat(corsAll.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isTrue(); + } + + @Test + public void testCorsAllMethod() throws Exception { + String probe = ""; + assertThat(corsAll.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isFalse(); + probe = "PATCH"; + assertThat(corsAll.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isFalse(); + probe = "GET"; + assertThat(corsAll.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isTrue(); + } + + @Test + public void testCorsAllHeader() throws Exception { + String probe = ""; + assertThat(corsAll.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isFalse(); + probe = "Accept"; + assertThat(corsAll.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isTrue(); + probe = "Accept, Content-Type"; + assertThat(corsAll.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isTrue(); + } + + @Test + public void testCorsCfgOrigin() throws Exception { + String probe = ""; + assertThat(corsCfg.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isFalse(); + probe = "https://example.org"; + assertThat(corsCfg.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isFalse(); + probe = "https://example.com"; + assertThat(corsCfg.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isTrue(); + probe = "https://sub.example.com"; + assertThat(corsCfg.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isTrue(); + probe = "https://example.cloud"; + assertThat(corsCfg.isOriginAllowed(probe)) + .as("check '%s' as origin", probe).isTrue(); + } + + @Test + public void testCorsCfgMethod() throws Exception { + String probe = ""; + assertThat(corsCfg.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isFalse(); + probe = "PATCH"; + assertThat(corsCfg.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isFalse(); + probe = "GET"; + assertThat(corsCfg.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isTrue(); + probe = "PUT"; + assertThat(corsCfg.isMethodAllowed(probe)) + .as("check '%s' as method", probe).isTrue(); + } + + @Test + public void testCorsCfgHeader() throws Exception { + String probe = ""; + assertThat(corsCfg.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isFalse(); + probe = "Accept-Language"; + assertThat(corsCfg.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isFalse(); + probe = "Accept, Accept-Encoding"; + assertThat(corsCfg.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isFalse(); + probe = "Accept"; + assertThat(corsCfg.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isTrue(); + probe = "Accept, Content-Type"; + assertThat(corsCfg.isEveryHeaderAllowed(probe)) + .as("check '%s' as header", probe).isTrue(); + } +} diff --git a/src/test/resources/s3proxy-cors.conf b/src/test/resources/s3proxy-cors.conf new file mode 100644 index 0000000..b86b7d3 --- /dev/null +++ b/src/test/resources/s3proxy-cors.conf @@ -0,0 +1,18 @@ +s3proxy.endpoint=http://127.0.0.1:0 +s3proxy.secure-endpoint=https://127.0.0.1:0 +# authorization must be aws-v2, aws-v4, aws-v2-or-v4, or none +s3proxy.authorization=aws-v2-or-v4 +s3proxy.identity=local-identity +s3proxy.credential=local-credential +s3proxy.keystore-path=keystore.jks +s3proxy.keystore-password=password +s3proxy.cors-allow-origins=https://example\.com https://.+\.example\.com https://example\.cloud +s3proxy.cors-allow-methods=GET PUT +s3proxy.cors-allow-headers=Accept Content-Type + +jclouds.provider=transient +jclouds.identity=remote-identity +jclouds.credential=remote-credential +# endpoint is optional for some providers +#jclouds.endpoint=http://127.0.0.1:8081 +jclouds.filesystem.basedir=/tmp/blobstore