diff --git a/server/pom.xml b/server/pom.xml index f64059c72..fdf0b001b 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -124,6 +124,11 @@ spring-boot-starter-test test + + com.google.guava + guava + 31.1-jre + org.springframework.boot spring-boot-starter-web diff --git a/server/src/main/java/com/adobe/testing/s3mock/BucketController.java b/server/src/main/java/com/adobe/testing/s3mock/BucketController.java index c190ac27f..1aca73c6e 100644 --- a/server/src/main/java/com/adobe/testing/s3mock/BucketController.java +++ b/server/src/main/java/com/adobe/testing/s3mock/BucketController.java @@ -16,6 +16,7 @@ package com.adobe.testing.s3mock; +import static com.adobe.testing.s3mock.BucketNameFilter.BUCKET_ATTRIBUTE; import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_BUCKET_OBJECT_LOCK_ENABLED; import static com.adobe.testing.s3mock.util.AwsHttpParameters.CONTINUATION_TOKEN; import static com.adobe.testing.s3mock.util.AwsHttpParameters.ENCODING_TYPE; @@ -33,6 +34,7 @@ import static org.springframework.http.MediaType.APPLICATION_XML_VALUE; import com.adobe.testing.s3mock.dto.BucketLifecycleConfiguration; +import com.adobe.testing.s3mock.dto.BucketName; import com.adobe.testing.s3mock.dto.ListAllMyBucketsResult; import com.adobe.testing.s3mock.dto.ListBucketResult; import com.adobe.testing.s3mock.dto.ListBucketResultV2; @@ -42,6 +44,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -110,7 +113,10 @@ public ResponseEntity listBuckets() { ) public ResponseEntity createBucket(@PathVariable final String bucketName, @RequestHeader(value = X_AMZ_BUCKET_OBJECT_LOCK_ENABLED, - required = false, defaultValue = "false") boolean objectLockEnabled) { + required = false, defaultValue = "false") boolean objectLockEnabled, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + //TODO: does subdomain access work for #createBucket in S3? + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketNameIsAllowed(bucketName); bucketService.verifyBucketDoesNotExist(bucketName); bucketService.createBucket(bucketName, objectLockEnabled); @@ -129,7 +135,9 @@ public ResponseEntity createBucket(@PathVariable final String bucketName, value = "/{bucketName:.+}", method = RequestMethod.HEAD ) - public ResponseEntity headBucket(@PathVariable final String bucketName) { + public ResponseEntity headBucket(@PathVariable final String bucketName, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); return ResponseEntity.ok().build(); } @@ -149,7 +157,9 @@ public ResponseEntity headBucket(@PathVariable final String bucketName) { }, method = RequestMethod.DELETE ) - public ResponseEntity deleteBucket(@PathVariable String bucketName) { + public ResponseEntity deleteBucket(@PathVariable String bucketName, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); bucketService.verifyBucketIsEmpty(bucketName); bucketService.deleteBucket(bucketName); @@ -176,7 +186,9 @@ public ResponseEntity deleteBucket(@PathVariable String bucketName) { } ) public ResponseEntity getObjectLockConfiguration( - @PathVariable String bucketName) { + @PathVariable String bucketName, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); ObjectLockConfiguration configuration = bucketService.getObjectLockConfiguration(bucketName); return ResponseEntity.ok(configuration); @@ -200,7 +212,9 @@ public ResponseEntity getObjectLockConfiguration( ) public ResponseEntity putObjectLockConfiguration( @PathVariable String bucketName, - @RequestBody ObjectLockConfiguration configuration) { + @RequestBody ObjectLockConfiguration configuration, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); bucketService.setObjectLockConfiguration(bucketName, configuration); return ResponseEntity.ok().build(); @@ -226,7 +240,9 @@ public ResponseEntity putObjectLockConfiguration( } ) public ResponseEntity getBucketLifecycleConfiguration( - @PathVariable String bucketName) { + @PathVariable String bucketName, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); BucketLifecycleConfiguration configuration = bucketService.getBucketLifecycleConfiguration(bucketName); @@ -251,7 +267,9 @@ public ResponseEntity getBucketLifecycleConfigurat ) public ResponseEntity putBucketLifecycleConfiguration( @PathVariable String bucketName, - @RequestBody BucketLifecycleConfiguration configuration) { + @RequestBody BucketLifecycleConfiguration configuration, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); bucketService.setBucketLifecycleConfiguration(bucketName, configuration); return ResponseEntity.ok().build(); @@ -273,7 +291,9 @@ public ResponseEntity putBucketLifecycleConfiguration( method = RequestMethod.DELETE ) public ResponseEntity deleteBucketLifecycleConfiguration( - @PathVariable String bucketName) { + @PathVariable String bucketName, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); bucketService.deleteBucketLifecycleConfiguration(bucketName); return ResponseEntity.noContent().build(); @@ -332,7 +352,9 @@ public ResponseEntity listObjects( @RequestParam(required = false) String delimiter, @RequestParam(required = false) String marker, @RequestParam(name = ENCODING_TYPE, required = false) String encodingType, - @RequestParam(name = MAX_KEYS, defaultValue = "1000", required = false) Integer maxKeys) { + @RequestParam(name = MAX_KEYS, defaultValue = "1000", required = false) Integer maxKeys, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); bucketService.verifyMaxKeys(maxKeys); bucketService.verifyEncodingType(encodingType); @@ -370,7 +392,9 @@ public ResponseEntity listObjectsV2( @RequestParam(name = ENCODING_TYPE, required = false) String encodingType, @RequestParam(name = START_AFTER, required = false) String startAfter, @RequestParam(name = MAX_KEYS, defaultValue = "1000", required = false) Integer maxKeys, - @RequestParam(name = CONTINUATION_TOKEN, required = false) String continuationToken) { + @RequestParam(name = CONTINUATION_TOKEN, required = false) String continuationToken, + @RequestAttribute(BUCKET_ATTRIBUTE) BucketName bucket) { + assert bucketName.equals(bucket.getName()); bucketService.verifyBucketExists(bucketName); bucketService.verifyMaxKeys(maxKeys); bucketService.verifyEncodingType(encodingType); diff --git a/server/src/main/java/com/adobe/testing/s3mock/BucketNameFilter.java b/server/src/main/java/com/adobe/testing/s3mock/BucketNameFilter.java new file mode 100644 index 000000000..0b35714f0 --- /dev/null +++ b/server/src/main/java/com/adobe/testing/s3mock/BucketNameFilter.java @@ -0,0 +1,111 @@ +/* + * Copyright 2017-2022 Adobe. + * + * 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 com.adobe.testing.s3mock; + +import static org.springframework.http.HttpHeaders.HOST; + +import com.adobe.testing.s3mock.dto.BucketName; +import com.google.common.net.InetAddresses; +import java.io.IOException; +import java.util.regex.Pattern; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.filter.OncePerRequestFilter; + +class BucketNameFilter extends OncePerRequestFilter { + private static final Logger LOG = LoggerFactory.getLogger(BucketNameFilter.class); + private static final Pattern BUCKET_AND_KEY_PATTERN = Pattern.compile("/.+/.*"); + private static final Pattern BUCKET_PATTERN = Pattern.compile("/.+/?"); + static final String BUCKET_ATTRIBUTE = "bucketName"; + + private final String contextPath; + + BucketNameFilter(String contextPath) { + this.contextPath = contextPath; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + BucketName bucketName = null; + try { + bucketName = fromHost(request); + if (bucketName == null) { + bucketName = fromURI(request); + } + if (bucketName != null) { + request.setAttribute(BUCKET_ATTRIBUTE, bucketName); + } + } finally { + LOG.info("Found bucketName {}", bucketName); + filterChain.doFilter(request, response); + } + } + + private BucketName fromURI(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + LOG.info("Check for bucket name in request URI={}.", requestURI); + if (BUCKET_AND_KEY_PATTERN.matcher(requestURI).matches() + || BUCKET_PATTERN.matcher(requestURI).matches()) { + String bucketName = fromURIString(requestURI); + return new BucketName(bucketName); + } + + return null; + } + + private String fromURIString(String uri) { + String bucketName = null; + String[] uriComponents = uri.split("/"); + if (uriComponents.length > 1) { + String firstElement = uriComponents[1]; + if (firstElement.equals(contextPath) && uriComponents.length > 2) { + bucketName = uriComponents[2]; + } else { + bucketName = firstElement; + } + } + + return bucketName; + } + + private BucketName fromHost(HttpServletRequest request) { + String host = request.getHeader(HOST); + LOG.info("Check for bucket name in host={}.", host); + if (host == null || InetAddresses.isUriInetAddress(host)) { + return null; + } + + String bucketName = getBucketName(host); + if (bucketName != null) { + return new BucketName(bucketName); + } + return null; + } + + private String getBucketName(String hostName) { + if (hostName.contains(".")) { + String[] hostNameComponents = hostName.split("\\."); + return hostNameComponents[0]; + } + return null; + } +} diff --git a/server/src/main/java/com/adobe/testing/s3mock/S3MockConfiguration.java b/server/src/main/java/com/adobe/testing/s3mock/S3MockConfiguration.java index 5f4a37f0d..b8949d380 100644 --- a/server/src/main/java/com/adobe/testing/s3mock/S3MockConfiguration.java +++ b/server/src/main/java/com/adobe/testing/s3mock/S3MockConfiguration.java @@ -85,6 +85,11 @@ Filter kmsFilter(final KmsKeyStore kmsKeyStore, return new KmsValidationFilter(kmsKeyStore, messageConverter); } + @Bean + Filter bucketNameFilter(S3MockProperties properties) { + return new BucketNameFilter(properties.getContextPath()); + } + @Override public void configureContentNegotiation(final ContentNegotiationConfigurer configurer) { configurer diff --git a/server/src/main/java/com/adobe/testing/s3mock/dto/BucketName.java b/server/src/main/java/com/adobe/testing/s3mock/dto/BucketName.java new file mode 100644 index 000000000..c6fda7325 --- /dev/null +++ b/server/src/main/java/com/adobe/testing/s3mock/dto/BucketName.java @@ -0,0 +1,55 @@ +/* + * Copyright 2017-2022 Adobe. + * + * 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 com.adobe.testing.s3mock.dto; + +import java.util.Objects; + +public class BucketName { + private final String name; + + public BucketName(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BucketName that = (BucketName) o; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return "BucketName{" + + "name='" + name + '\'' + + '}'; + } +} diff --git a/server/src/test/java/com/adobe/testing/s3mock/BucketNameFilterTest.java b/server/src/test/java/com/adobe/testing/s3mock/BucketNameFilterTest.java new file mode 100644 index 000000000..06aacaa2e --- /dev/null +++ b/server/src/test/java/com/adobe/testing/s3mock/BucketNameFilterTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2017-2022 Adobe. + * + * 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 com.adobe.testing.s3mock; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.http.HttpHeaders.HOST; + +import com.adobe.testing.s3mock.dto.BucketName; +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class BucketNameFilterTest { + private final MockHttpServletResponse response = new MockHttpServletResponse(); + private final FilterChain filterChain = (request, response) -> { + }; + private MockHttpServletRequest request; + + @Test + void testGetBucketNameFromPath_awsV1() throws ServletException, IOException { + request = new MockHttpServletRequest("PUT", "/bucket-name/"); + BucketNameFilter iut = new BucketNameFilter(null); + + iut.doFilterInternal(request, response, filterChain); + + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNotNull(); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isEqualTo( + new BucketName("bucket-name")); + } + + @Test + void testGetBucketNameFromPath_awsV2() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/bucket-name"); + BucketNameFilter iut = new BucketNameFilter(null); + + iut.doFilterInternal(request, response, filterChain); + + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNotNull(); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isEqualTo( + new BucketName("bucket-name")); + } + + @Test + void testGetBucketNameFromPath_withKey() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/bucket-name/key-name"); + BucketNameFilter iut = new BucketNameFilter(null); + + iut.doFilterInternal(request, response, filterChain); + + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNotNull(); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isEqualTo( + new BucketName("bucket-name")); + } + + @Test + void testGetBucketNameFromPath_withContextPath() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/context/bucket-name/key-name"); + BucketNameFilter iut = new BucketNameFilter("context"); + + iut.doFilterInternal(request, response, filterChain); + + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNotNull(); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isEqualTo( + new BucketName("bucket-name")); + } + + @Test + void testGetBucketNameFromHost_OK() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/"); + request.addHeader(HOST, "bucket-name.localhost"); + BucketNameFilter iut = new BucketNameFilter(null); + + iut.doFilterInternal(request, response, filterChain); + + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNotNull(); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isEqualTo( + new BucketName("bucket-name")); + } + + @Test + void testGetBucketNameFromHost_noBucket() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/"); + request.addHeader(HOST, "some-host-name"); + BucketNameFilter iut = new BucketNameFilter(null); + + iut.doFilterInternal(request, response, filterChain); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNull(); + } + + @Test + void testGetBucketNameFromHost_withBucketInPath() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/bucket-name/key-name"); + request.addHeader(HOST, "some-host-name"); + BucketNameFilter iut = new BucketNameFilter(null); + + iut.doFilterInternal(request, response, filterChain); + + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNotNull(); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isEqualTo( + new BucketName("bucket-name")); + } + + @Test + void testGetBucketNameFromIP_noBucket() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/"); + request.addHeader(HOST, "127.0.0.1"); + BucketNameFilter iut = new BucketNameFilter(null); + + iut.doFilterInternal(request, response, filterChain); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNull(); + } + + @Test + void testGetBucketNameFromIP_withBucketInPath() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/bucket-name/key-name"); + request.addHeader(HOST, "127.0.0.1"); + BucketNameFilter iut = new BucketNameFilter(null); + + iut.doFilterInternal(request, response, filterChain); + + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isNotNull(); + assertThat(request.getAttribute(BucketNameFilter.BUCKET_ATTRIBUTE)).isEqualTo( + new BucketName("bucket-name")); + } +} diff --git a/server/src/test/java/com/adobe/testing/s3mock/FaviconControllerTest.java b/server/src/test/java/com/adobe/testing/s3mock/FaviconControllerTest.java index 914e066e6..4ee098a1d 100644 --- a/server/src/test/java/com/adobe/testing/s3mock/FaviconControllerTest.java +++ b/server/src/test/java/com/adobe/testing/s3mock/FaviconControllerTest.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import com.adobe.testing.s3mock.service.BucketService; import com.adobe.testing.s3mock.store.BucketStore; import com.adobe.testing.s3mock.store.KmsKeyStore; import com.adobe.testing.s3mock.store.ObjectStore; @@ -37,6 +38,7 @@ ObjectController.class, BucketStore.class, BucketController.class, + BucketService.class, MultipartController.class }) @SpringBootTest(classes = {S3MockConfiguration.class})