diff --git a/docs/utilities/cors.md b/docs/utilities/cors.md new file mode 100644 index 000000000..0a1061fea --- /dev/null +++ b/docs/utilities/cors.md @@ -0,0 +1,199 @@ +--- +title: Cors +description: Utility +--- + +The Cors utility helps configuring [CORS (Cross-Origin Resource Sharing)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) for your Lambda function when used with API Gateway and Lambda proxy integration. +When configured as Lambda proxy integration, the function must set CORS HTTP headers in the response ([doc](https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-cors.html#:~:text=Enabling%20CORS%20support%20for%20Lambda%20or%20HTTP%20proxy%20integrations)). + +**Key features** + +* Automatically set the CORS HTTP headers in the response. +* Multi-origins is supported, only the origin matching the request origin will be returned. +* Support environment variables or programmatic configuration + +## Install + +To install this utility, add the following dependency to your project. + +=== "Maven" + + ```xml + + software.amazon.lambda + powertools-cors + {{ powertools.version }} + + + + + + ... + + org.codehaus.mojo + aspectj-maven-plugin + 1.14.0 + + 1.8 + 1.8 + 1.8 + + + software.amazon.lambda + powertools-cors + + ... + + + + + + compile + + + + + ... + + + ``` +=== "Gradle" + + ```groovy + plugins{ + id 'java' + id 'io.freefair.aspectj.post-compile-weaving' version '6.3.0' + } + + dependencies { + ... + aspect 'software.amazon.lambda:powertools-cors:{{ powertools.version }}' + } + ``` + +## Cors Annotation + +You can use the `@CrossOrigin` annotation on the `handleRequest` method of a class that implements `RequestHandler` + +=== "Cross Origin Annotation" + +```java hl_lines="3" +public class FunctionProxy implements RequestHandler { + + @CrossOrigin + public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { + // ... + return new APIGatewayProxyResponseEvent() + .withStatusCode(200) + .withHeaders(headers) + .withBody(body); + } +} +``` + +## Configuration + +### Using the annotation + +=== "FunctionProxy.java" + +The annotation provides all the parameters required to set up the different CORS headers: + +| parameter | Default | Description | +|----------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| **allowedHeaders** | `Authorization, *` ([*](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers#directives)) | [`Access-Control-Allow-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) header | +| **exposedHeaders** | `*` | [`Access-Control-Expose-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers) header | +| **origins** | `*` | [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) header | +| **methods** | `*` | [`Access-Control-Allow-Methods`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods) header | +| **allowCredentials** | `true` | [`Access-Control-Allow-Credentials`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials) header | +| **maxAge** | `29` | [`Access-Control-Max-Age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age) header | + +**Example:** + +=== "FunctionProxy.java" + +```java hl_lines="3-7" +public class FunctionProxy implements RequestHandler { + + @CrossOrigin( + origins = "http://origin.com, https://other.origin.com", + allowedHeaders = "Authorization, Content-Type, X-API-Key", + methods = "POST, OPTIONS" + ) + public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { + // ... + return new APIGatewayProxyResponseEvent() + .withStatusCode(200) + .withHeaders(headers) + .withBody(body); + } +} +``` + + +### Using Environment variables + +You can configure the CORS header values in the environment variables of your Lambda function. They will have precedence over the annotation configuration. +This way you can externalize the configuration and change it without changing the code. + +| Environment variable | Default | Description | +|--------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| **ACCESS_CONTROL_ALLOW_HEADERS** | `Authorization, *` ([*](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers#directives)) | [`Access-Control-Allow-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) header | +| **ACCESS_CONTROL_EXPOSE_HEADERS** | `*` | [`Access-Control-Expose-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers) header | +| **ACCESS_CONTROL_ALLOW_ORIGIN** | `*` | [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) header | +| **ACCESS_CONTROL_ALLOW_METHODS** | `*` | [`Access-Control-Allow-Methods`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods) header | +| **ACCESS_CONTROL_ALLOW_CREDENTIALS** | `true` | [`Access-Control-Allow-Credentials`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials) header | +| **ACCESS_CONTROL_MAX_AGE** | `29` | [`Access-Control-Max-Age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age) header | + +**Example:** + +=== "SAM template" + +```yaml hl_lines="12-17" + CorsFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: Function + Handler: CorsFunction::handleRequest + Runtime: java11 + Architectures: + - x86_64 + MemorySize: 512 + Environment: + Variables: + ACCESS_CONTROL_ALLOW_HEADERS: 'Authorization, Content-Type, X-API-Key' + ACCESS_CONTROL_EXPOSE_HEADERS: '*' + ACCESS_CONTROL_ALLOW_ORIGIN: 'https://mydomain.com' + ACCESS_CONTROL_ALLOW_METHODS: 'OPTIONS, POST' + ACCESS_CONTROL_MAX_AGE: '300' + ACCESS_CONTROL_ALLOW_CREDENTIALS: 'true' + Events: + MyApi: + Type: Api + Properties: + Path: /cors + Method: post +``` + +## Advanced information about origin configuration +Browsers do no support multiple origins (comma-separated) in the `Access-Control-Allow-Origin` header. Only one must be provided. +If your backend can be reached by multiple frontend applications (with different origins), +the function must return the `Access-Control-Allow-Origin` header that matches the `origin` header in the request. + +Lambda Powertools handles this for you. You can configure multiple origins, separated by commas: + +=== "Multiple origins" + +```java hl_lines="2" + @CrossOrigin( + origins = "http://origin.com, https://other.origin.com" + ) +``` + +!!! warning "Origins must be well-formed" + Origins must be well-formed URLs: `{protocol}://{host}[:{port}]` where protocol can be `http` or `https` and port is optional. [Mode details](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy#definition_of_an_origin). + + `*`, `http://*` and `https://*` are also valid origins. + +!!! info "`Vary` header" + Note that when returning a specific value (rather than `*`), the `Vary` header must be set to `Origin` ([doc](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin#cors_and_caching)). Lambda Powertools handles this for you. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index e16d01450..db0e7b9d2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,6 +17,7 @@ nav: - utilities/validation.md - utilities/custom_resources.md - utilities/serialization.md + - utilities/cors.md theme: name: material diff --git a/pom.xml b/pom.xml index c38099545..2271a909f 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ powertools-test-suite powertools-cloudformation powertools-idempotency + powertools-cors diff --git a/powertools-cors/pom.xml b/powertools-cors/pom.xml new file mode 100644 index 000000000..7a77e931b --- /dev/null +++ b/powertools-cors/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + powertools-parent + software.amazon.lambda + 1.12.0 + + powertools-cors + AWS Lambda Powertools for Java library CORS Configuration + + https://aws.amazon.com/lambda/ + + GitHub Issues + https://github.com/awslabs/aws-lambda-powertools-java/issues + + + https://github.com/awslabs/aws-lambda-powertools-java.git + + + + AWS Lambda Powertools team + Amazon Web Services + https://aws.amazon.com/ + + + + + + ossrh + https://aws.oss.sonatype.org/content/repositories/snapshots + + + + + + software.amazon.lambda + powertools-core + + + com.amazonaws + aws-lambda-java-events + + + org.aspectj + aspectjrt + + + org.apache.logging.log4j + log4j-slf4j-impl + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-inline + test + + + com.amazonaws + aws-lambda-java-tests + test + + + org.junit-pioneer + junit-pioneer + 1.6.2 + test + + + + + + jdk16 + + [16,) + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + --add-opens java.base/java.util=ALL-UNNAMED + --add-opens java.base/java.lang=ALL-UNNAMED + + + + + + + + \ No newline at end of file diff --git a/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/Constants.java b/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/Constants.java new file mode 100644 index 000000000..6628b4f71 --- /dev/null +++ b/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/Constants.java @@ -0,0 +1,43 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors; + +public interface Constants { + String ACCESS_CONTROL_ALLOW_HEADERS = "Access-Control-Allow-Headers"; + String ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers"; + String ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin"; + String ACCESS_CONTROL_ALLOW_METHODS = "Access-Control-Allow-Methods"; + String ACCESS_CONTROL_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials"; + String ACCESS_CONTROL_MAX_AGE = "Access-Control-Max-Age"; + + String VARY = "Vary"; + String VARY_ORIGIN = "Origin"; + + String ENV_ACCESS_CONTROL_ALLOW_HEADERS = "ACCESS_CONTROL_ALLOW_HEADERS"; + String ENV_ACCESS_CONTROL_EXPOSE_HEADERS = "ACCESS_CONTROL_EXPOSE_HEADERS"; + String ENV_ACCESS_CONTROL_ALLOW_ORIGIN = "ACCESS_CONTROL_ALLOW_ORIGIN"; + String ENV_ACCESS_CONTROL_ALLOW_METHODS = "ACCESS_CONTROL_ALLOW_METHODS"; + String ENV_ACCESS_CONTROL_ALLOW_CREDENTIALS = "ACCESS_CONTROL_ALLOW_CREDENTIALS"; + String ENV_ACCESS_CONTROL_MAX_AGE = "ACCESS_CONTROL_MAX_AGE"; + + String WILDCARD = "*"; + + String DEFAULT_ACCESS_CONTROL_ALLOW_HEADERS = "Authorization, *"; + String DEFAULT_ACCESS_CONTROL_EXPOSE_HEADERS = WILDCARD; + String DEFAULT_ACCESS_CONTROL_ALLOW_METHODS = WILDCARD; + String DEFAULT_ACCESS_CONTROL_ALLOW_ORIGIN = WILDCARD; + boolean DEFAULT_ACCESS_CONTROL_ALLOW_CREDENTIALS = true; + int DEFAULT_ACCESS_CONTROL_MAX_AGE = 29; + +} diff --git a/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/CrossOrigin.java b/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/CrossOrigin.java new file mode 100644 index 000000000..7db320680 --- /dev/null +++ b/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/CrossOrigin.java @@ -0,0 +1,96 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static software.amazon.lambda.powertools.cors.Constants.*; + +/** + * CrossOrigin annotation to be placed on a Lambda function configured with API Gateway as proxy.
+ * Your function must implement
RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>
+ * It will automatically add the Cross-Origins Resource Sharing (CORS) headers in the response sent to API Gateway & the client.
+ * By default, it allows everything (all methods, headers and origins). Make sure to restrict to your need. + *

You can use the annotation alone and keep the default setup (or use environment variables instead, see below):
+ *
+ *    @CrossOrigin
+ *    public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context ctx) {
+ *      // ...
+ *      return response;
+ *    }
+ * 
+ *

+ *

You can use the annotation and customize the parameters:
+ *

+ *    @CrossOrigin(
+ *         origins = "http://origin.com",
+ *         allowedHeaders = "Content-Type",
+ *         methods = "POST, OPTIONS"
+ *     )
+ *    public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context ctx) {
+ *      // ...
+ *      return response;
+ *    }
+ * 
+ *

+ *

You can also use the following environment variables if you wish to externalize the configuration:

    + *
  • ACCESS_CONTROL_ALLOW_HEADERS
  • + *
  • ACCESS_CONTROL_EXPOSE_HEADERS
  • + *
  • ACCESS_CONTROL_ALLOW_ORIGIN
  • + *
  • ACCESS_CONTROL_ALLOW_METHODS
  • + *
  • ACCESS_CONTROL_ALLOW_CREDENTIALS
  • + *
  • ACCESS_CONTROL_MAX_AGE
  • + *

+ * Note that if you configure cross-origin both in environment variables and programmatically with the annotation, + * environment variables take the precedence over the annotation. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface CrossOrigin { + /** + * Allowed methods (Access-Control-Request-Methods). You can set several methods separated by a comma (','). + * Default: * (allow all methods) + */ + String methods() default DEFAULT_ACCESS_CONTROL_ALLOW_METHODS; + /** + * Allowed origins (Access-Control-Allow-Origin). You can set several origins separated by a comma (','). + * If you do so, only the origin matching the client origin will be sent in the response. + * An origin must be a well-formed url: {protocol}://{host}[:{port}] + * Default: * (allow all origins) + */ + String origins() default DEFAULT_ACCESS_CONTROL_ALLOW_ORIGIN; + /** + * Allowed headers (Access-Control-Request-Headers). You can set several headers separated by a comma (','). + * Note that Authorization is not part of the wildcard and must be set explicitly + * Default: Authorization, * (allow all headers) + */ + String allowedHeaders() default DEFAULT_ACCESS_CONTROL_ALLOW_HEADERS; + /** + * Exposed headers (Access-Control-Expose-Headers). You can set several headers separated by a comma (','). + * Default: * (expose all headers) + */ + String exposedHeaders() default DEFAULT_ACCESS_CONTROL_EXPOSE_HEADERS; + /** + * If credential mode is allowed (ACCESS_CONTROL_ALLOW_CREDENTIALS) + * Default: true + */ + boolean allowCredentials() default DEFAULT_ACCESS_CONTROL_ALLOW_CREDENTIALS; + /** + * How long (in seconds) the preflight request is cached (default: 29) + */ + int maxAge() default DEFAULT_ACCESS_CONTROL_MAX_AGE; +} diff --git a/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/internal/CrossOriginAspect.java b/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/internal/CrossOriginAspect.java new file mode 100644 index 000000000..8e1d1257e --- /dev/null +++ b/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/internal/CrossOriginAspect.java @@ -0,0 +1,73 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors.internal; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import software.amazon.lambda.powertools.cors.CrossOrigin; + +import java.lang.reflect.Method; + +import static software.amazon.lambda.powertools.core.internal.LambdaHandlerProcessor.isHandlerMethod; +import static software.amazon.lambda.powertools.core.internal.LambdaHandlerProcessor.placedOnRequestHandler; + +@Aspect +public class CrossOriginAspect { + + private static final Logger LOG = LogManager.getLogger(CrossOriginAspect.class); + + @Pointcut("@annotation(crossOrigin)") + public void callAt(CrossOrigin crossOrigin) { + } + + @Around(value = "callAt(crossOrigin) && execution(@CrossOrigin * *.*(..))", argNames = "pjp,crossOrigin") + public Object around(ProceedingJoinPoint pjp, + CrossOrigin crossOrigin) throws Throwable { + Object result = pjp.proceed(pjp.getArgs()); + + if (!isHandlerMethod(pjp) || !placedOnRequestHandler(pjp) || !isApiGatewayRequest(pjp)) { + LOG.warn("@Cors annotation must be used on a Lambda handler that receives APIGatewayProxyRequestEvent and return APIGatewayProxyResponseEvent"); + return result; + } + + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(crossOrigin); + return proceed(pjp, result, crossOriginHandler); + } + + private Object proceed(ProceedingJoinPoint pjp, Object result, CrossOriginHandler crossOriginHandler) { + try { + APIGatewayProxyRequestEvent request = (APIGatewayProxyRequestEvent) pjp.getArgs()[0]; + APIGatewayProxyResponseEvent response = (APIGatewayProxyResponseEvent) result; + + return crossOriginHandler.process(request, response); + } catch (Exception e) { + // should not happen, but we don't want to fail because of this + LOG.error("Error while setting CORS headers. If you think this is an issue in PowerTools, please open an issue on GitHub.", e); + } + return result; + } + + private boolean isApiGatewayRequest(ProceedingJoinPoint pjp) { + Method method = ((MethodSignature) pjp.getSignature()).getMethod(); + return method.getReturnType().equals(APIGatewayProxyResponseEvent.class) && + pjp.getArgs()[0] instanceof APIGatewayProxyRequestEvent; + } +} diff --git a/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/internal/CrossOriginHandler.java b/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/internal/CrossOriginHandler.java new file mode 100644 index 000000000..dea09f10d --- /dev/null +++ b/powertools-cors/src/main/java/software/amazon/lambda/powertools/cors/internal/CrossOriginHandler.java @@ -0,0 +1,101 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors.internal; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import software.amazon.lambda.powertools.cors.CrossOrigin; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +import static software.amazon.lambda.powertools.cors.Constants.*; +import static software.amazon.lambda.powertools.cors.Constants.ACCESS_CONTROL_MAX_AGE; + +public class CrossOriginHandler { + private static final Logger LOG = LogManager.getLogger(CrossOriginHandler.class); + + private final String allowHeaders; + private final String exposedHeaders; + private final String allowMethods; + private final String allowOrigins; + private final String allowCredentials; + private final String maxAge; + + CrossOriginHandler(CrossOrigin crossOrigin) { + allowHeaders = Optional.ofNullable(System.getenv(ENV_ACCESS_CONTROL_ALLOW_HEADERS)).orElse(crossOrigin.allowedHeaders()); + exposedHeaders = Optional.ofNullable(System.getenv(ENV_ACCESS_CONTROL_EXPOSE_HEADERS)).orElse(crossOrigin.exposedHeaders()); + allowMethods = Optional.ofNullable(System.getenv(ENV_ACCESS_CONTROL_ALLOW_METHODS)).orElse(crossOrigin.methods()); + allowOrigins = Optional.ofNullable(System.getenv(ENV_ACCESS_CONTROL_ALLOW_ORIGIN)).orElse(crossOrigin.origins()); + allowCredentials = Optional.ofNullable(System.getenv(ENV_ACCESS_CONTROL_ALLOW_CREDENTIALS)).orElse(String.valueOf(crossOrigin.allowCredentials())); + maxAge = Optional.ofNullable(System.getenv(ENV_ACCESS_CONTROL_MAX_AGE)).orElse(String.valueOf(crossOrigin.maxAge())); + } + + public APIGatewayProxyResponseEvent process(APIGatewayProxyRequestEvent request, APIGatewayProxyResponseEvent response) { + try { + String origin = request.getHeaders().get("origin"); + LOG.debug("origin=" + origin); + + if (response.getHeaders() == null) { + response.setHeaders(new HashMap<>()); + } + processOrigin(response, origin); + response.getHeaders().put(ACCESS_CONTROL_ALLOW_HEADERS, allowHeaders); + response.getHeaders().put(ACCESS_CONTROL_EXPOSE_HEADERS, exposedHeaders); + response.getHeaders().put(ACCESS_CONTROL_ALLOW_METHODS, allowMethods); + response.getHeaders().put(ACCESS_CONTROL_ALLOW_CREDENTIALS, allowCredentials); + response.getHeaders().put(ACCESS_CONTROL_MAX_AGE, maxAge); + } catch (Exception e) { + // should not happen, but we don't want to fail because of this + LOG.error("Error while setting CORS headers. If you think this is an issue in PowerTools, please open an issue on GitHub", e); + } + if (LOG.isDebugEnabled()) { + LOG.debug("====== CORS Headers ======"); + response.getHeaders().forEach((key, value) -> {if (key.startsWith("Access-Control")) { LOG.debug(key + " -> " + value); }} ); + } + return response; + } + + private void processOrigin(APIGatewayProxyResponseEvent response, String origin) throws MalformedURLException { + if (allowOrigins != null) { + List allowOriginList = Arrays.asList(allowOrigins.split("\\s*,\\s*")); + if (allowOriginList.stream().anyMatch(WILDCARD::equals)) { + response.getHeaders().put(ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.getHeaders().put(VARY, VARY_ORIGIN); + } else { + URL url = new URL(origin); + allowOriginList.stream().filter(o -> { + try { + URL allowUrl = new URL(o); + return url.getProtocol().equals(allowUrl.getProtocol()) && + url.getPort() == allowUrl.getPort() && + (url.getHost().equals(allowUrl.getHost()) || allowUrl.getHost().equals(WILDCARD)); + } catch (MalformedURLException e) { + LOG.warn("Allowed origin '"+o+"' is malformed. It should contain a protocol, a host and eventually a port"); + return false; + } + }).findAny().ifPresent(validOrigin -> { + response.getHeaders().put(ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.getHeaders().put(VARY, VARY_ORIGIN); + }); + } + } + } +} diff --git a/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/CrossOriginTest.java b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/CrossOriginTest.java new file mode 100644 index 000000000..016b6668b --- /dev/null +++ b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/CrossOriginTest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.amazonaws.services.lambda.runtime.events.SQSBatchResponse; +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import com.amazonaws.services.lambda.runtime.tests.EventLoader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.SetEnvironmentVariable; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import software.amazon.lambda.powertools.cors.handlers.CorsFunction; +import software.amazon.lambda.powertools.cors.handlers.CorsNotOnHandlerFunction; +import software.amazon.lambda.powertools.cors.handlers.CorsOnSqsFunction; +import software.amazon.lambda.powertools.cors.handlers.DefaultCorsFunction; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.lambda.powertools.cors.Constants.*; + +public class CrossOriginTest { + @Mock + private Context context; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void functionWithCustomAndDefaultCorsConfig_shouldHaveCorsHeaders() { + CorsFunction function = new CorsFunction(); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json"); + APIGatewayProxyResponseEvent response = function.handleRequest(event, context); + + Map headers = response.getHeaders(); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_ORIGIN, "http://origin.com"); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type"); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_METHODS, "POST, OPTIONS"); + assertThat(headers).containsEntry(ACCESS_CONTROL_MAX_AGE, String.valueOf(DEFAULT_ACCESS_CONTROL_MAX_AGE)); + assertThat(headers).containsEntry(ACCESS_CONTROL_EXPOSE_HEADERS, DEFAULT_ACCESS_CONTROL_EXPOSE_HEADERS); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_CREDENTIALS, String.valueOf(DEFAULT_ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + @SetEnvironmentVariable.SetEnvironmentVariables(value = { + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_HEADERS, value = "Content-Type, X-Amz-Date"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_ORIGIN, value = "https://example.com, http://origin.com"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_METHODS, value = "OPTIONS, POST"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_EXPOSE_HEADERS, value = "Content-Type, X-Amz-Date"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_CREDENTIALS, value = "true"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_MAX_AGE, value = "42"), + }) + public void functionWithCustomCorsEnvVars_shouldHaveCorsHeaders() { + DefaultCorsFunction function = new DefaultCorsFunction(); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json"); + APIGatewayProxyResponseEvent response = function.handleRequest(event, context); + + Map headers = response.getHeaders(); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_ORIGIN, "http://origin.com"); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type, X-Amz-Date"); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_METHODS, "OPTIONS, POST"); + assertThat(headers).containsEntry(ACCESS_CONTROL_MAX_AGE, "42"); + assertThat(headers).containsEntry(ACCESS_CONTROL_EXPOSE_HEADERS, "Content-Type, X-Amz-Date"); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + } + + @Test + public void functionWithCorsOnSqsEvent() { + CorsOnSqsFunction function = new CorsOnSqsFunction(); + SQSEvent sqsEvent = EventLoader.loadSQSEvent("sqs_event.json"); + SQSBatchResponse response = function.handleRequest(sqsEvent, context); + assertThat(response).isNotNull(); + } + + @Test + public void functionWithCorsNotOnHandler_shouldNotContainCorsHeaders() { + CorsNotOnHandlerFunction function = new CorsNotOnHandlerFunction(); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json"); + APIGatewayProxyResponseEvent response = function.handleRequest(event, context); + assertThat(response.getHeaders()).isNull(); + } + +} diff --git a/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsFunction.java b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsFunction.java new file mode 100644 index 000000000..56a4d549a --- /dev/null +++ b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsFunction.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors.handlers; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import software.amazon.lambda.powertools.cors.CrossOrigin; + +public class CorsFunction implements RequestHandler { + + @CrossOrigin( + origins = "http://origin.com", + allowedHeaders = "Content-Type", + methods = "POST, OPTIONS" + ) + public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { + return new APIGatewayProxyResponseEvent().withBody("OK"); + } + +} diff --git a/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsNotOnHandlerFunction.java b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsNotOnHandlerFunction.java new file mode 100644 index 000000000..5a0bbb43b --- /dev/null +++ b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsNotOnHandlerFunction.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors.handlers; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import software.amazon.lambda.powertools.cors.CrossOrigin; + +public class CorsNotOnHandlerFunction implements RequestHandler { + + public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { + return new APIGatewayProxyResponseEvent().withBody(doSomething()); + } + + @CrossOrigin + private String doSomething() { + return "something"; + } + +} diff --git a/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsOnSqsFunction.java b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsOnSqsFunction.java new file mode 100644 index 000000000..af6782711 --- /dev/null +++ b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/CorsOnSqsFunction.java @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors.handlers; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.SQSBatchResponse; +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import software.amazon.lambda.powertools.cors.CrossOrigin; + +public class CorsOnSqsFunction implements RequestHandler { + + @CrossOrigin + public SQSBatchResponse handleRequest(final SQSEvent input, final Context context) { + return new SQSBatchResponse(); + } + +} diff --git a/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/DefaultCorsFunction.java b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/DefaultCorsFunction.java new file mode 100644 index 000000000..8f8778c76 --- /dev/null +++ b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/handlers/DefaultCorsFunction.java @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Amazon.com, Inc. or its affiliates. + * 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 software.amazon.lambda.powertools.cors.handlers; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import software.amazon.lambda.powertools.cors.CrossOrigin; + +public class DefaultCorsFunction implements RequestHandler { + + @CrossOrigin + public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { + return new APIGatewayProxyResponseEvent().withBody("OK"); + } + +} diff --git a/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/internal/CrossOriginHandlerTest.java b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/internal/CrossOriginHandlerTest.java new file mode 100644 index 000000000..5fbe55e38 --- /dev/null +++ b/powertools-cors/src/test/java/software/amazon/lambda/powertools/cors/internal/CrossOriginHandlerTest.java @@ -0,0 +1,229 @@ +package software.amazon.lambda.powertools.cors.internal; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.amazonaws.services.lambda.runtime.tests.EventLoader; +import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.SetEnvironmentVariable; +import software.amazon.lambda.powertools.cors.CrossOrigin; + +import java.lang.annotation.Annotation; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; +import static software.amazon.lambda.powertools.cors.Constants.*; + +public class CrossOriginHandlerTest { + + CrossOrigin defaultCors = new CrossOrigin() { + + @Override + public Class annotationType() { + return CrossOrigin.class; + } + + @Override + public String methods() { + return DEFAULT_ACCESS_CONTROL_ALLOW_METHODS; + } + + @Override + public String origins() { + return DEFAULT_ACCESS_CONTROL_ALLOW_ORIGIN; + } + + @Override + public String allowedHeaders() { + return DEFAULT_ACCESS_CONTROL_ALLOW_HEADERS; + } + + @Override + public String exposedHeaders() { + return DEFAULT_ACCESS_CONTROL_EXPOSE_HEADERS; + } + + @Override + public boolean allowCredentials() { + return DEFAULT_ACCESS_CONTROL_ALLOW_CREDENTIALS; + } + + @Override + public int maxAge() { + return DEFAULT_ACCESS_CONTROL_MAX_AGE; + } + }; + + @Test + public void defaultCorsConfiguration_shouldReturnDefaultHeaders() { + CrossOriginHandler handler = new CrossOriginHandler(defaultCors); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json"); + APIGatewayProxyResponseEvent response = handler.process(event, new APIGatewayProxyResponseEvent().withBody("OK")); + + assertThat(response.getBody()).isEqualTo("OK"); + Map headers = response.getHeaders(); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_ORIGIN, "http://origin.com"); + assertThat(headers).containsEntry(VARY, VARY_ORIGIN); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_HEADERS, DEFAULT_ACCESS_CONTROL_ALLOW_HEADERS); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_METHODS, DEFAULT_ACCESS_CONTROL_ALLOW_METHODS); + assertThat(headers).containsEntry(ACCESS_CONTROL_MAX_AGE, String.valueOf(DEFAULT_ACCESS_CONTROL_MAX_AGE)); + assertThat(headers).containsEntry(ACCESS_CONTROL_EXPOSE_HEADERS, DEFAULT_ACCESS_CONTROL_EXPOSE_HEADERS); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_CREDENTIALS, String.valueOf(DEFAULT_ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void defaultCorsConfiguration_withExistingHeaders_shouldReturnDefaultAndExistingHeaders() { + CrossOriginHandler handler = new CrossOriginHandler(defaultCors); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json"); + Map initialHeaders = new HashMap<>(); + initialHeaders.put("Content-Type", "application/json"); + APIGatewayProxyResponseEvent response = handler.process(event, new APIGatewayProxyResponseEvent().withBody("OK").withHeaders(initialHeaders)); + + assertThat(response.getBody()).isEqualTo("OK"); + Map headers = response.getHeaders(); + assertThat(headers).containsEntry("Content-Type", "application/json"); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_ORIGIN, "http://origin.com"); + assertThat(headers).containsEntry(VARY, VARY_ORIGIN); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_HEADERS, DEFAULT_ACCESS_CONTROL_ALLOW_HEADERS); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_METHODS, DEFAULT_ACCESS_CONTROL_ALLOW_METHODS); + assertThat(headers).containsEntry(ACCESS_CONTROL_MAX_AGE, String.valueOf(DEFAULT_ACCESS_CONTROL_MAX_AGE)); + assertThat(headers).containsEntry(ACCESS_CONTROL_EXPOSE_HEADERS, DEFAULT_ACCESS_CONTROL_EXPOSE_HEADERS); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_CREDENTIALS, String.valueOf(DEFAULT_ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void defaultCorsConfig_withDifferentOrigin_shouldNotReturnAllowOriginHeader() { + CrossOrigin cors = new CrossOrigin() { + + @Override + public Class annotationType() { + return CrossOrigin.class; + } + + @Override + public String methods() { + return DEFAULT_ACCESS_CONTROL_ALLOW_METHODS; + } + + @Override + public String origins() { + return "http://other_origin.com, origin.com"; + } + + @Override + public String allowedHeaders() { + return DEFAULT_ACCESS_CONTROL_ALLOW_HEADERS; + } + + @Override + public String exposedHeaders() { + return DEFAULT_ACCESS_CONTROL_EXPOSE_HEADERS; + } + + @Override + public boolean allowCredentials() { + return DEFAULT_ACCESS_CONTROL_ALLOW_CREDENTIALS; + } + + @Override + public int maxAge() { + return DEFAULT_ACCESS_CONTROL_MAX_AGE; + } + }; + CrossOriginHandler handler = new CrossOriginHandler(cors); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json"); + APIGatewayProxyResponseEvent response = handler.process(event, new APIGatewayProxyResponseEvent().withBody("OK")); + + Map headers = response.getHeaders(); + assertThat(headers).doesNotContainKey(ACCESS_CONTROL_ALLOW_ORIGIN); + assertThat(headers).doesNotContainKey(VARY); + } + + @Test + public void defaultCorsConfig_withMalformedOrigin_shouldNotReturnAllowOriginHeader() { + CrossOrigin cors = new CrossOrigin() { + + @Override + public Class annotationType() { + return CrossOrigin.class; + } + + @Override + public String methods() { + return DEFAULT_ACCESS_CONTROL_ALLOW_METHODS; + } + + @Override + public String origins() { + return "http://origin.com"; + } + + @Override + public String allowedHeaders() { + return DEFAULT_ACCESS_CONTROL_ALLOW_HEADERS; + } + + @Override + public String exposedHeaders() { + return DEFAULT_ACCESS_CONTROL_EXPOSE_HEADERS; + } + + @Override + public boolean allowCredentials() { + return DEFAULT_ACCESS_CONTROL_ALLOW_CREDENTIALS; + } + + @Override + public int maxAge() { + return DEFAULT_ACCESS_CONTROL_MAX_AGE; + } + }; + CrossOriginHandler handler = new CrossOriginHandler(cors); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event_malformed_origin.json"); + APIGatewayProxyResponseEvent response = handler.process(event, new APIGatewayProxyResponseEvent().withBody("OK")); + + Map headers = response.getHeaders(); + assertThat(headers).doesNotContainKey(ACCESS_CONTROL_ALLOW_ORIGIN); + assertThat(headers).doesNotContainKey(VARY); + } + + @Test + @SetEnvironmentVariable.SetEnvironmentVariables(value = { + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_HEADERS, value = "Content-Type, X-Amz-Date"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_ORIGIN, value = "http://origin.com"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_METHODS, value = "OPTIONS, POST"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_EXPOSE_HEADERS, value = "Content-Type, X-Amz-Date"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_CREDENTIALS, value = "true"), + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_MAX_AGE, value = "42"), + }) + public void corsConfigWithEnvVars_shouldReturnCorrectHeaders() { + CrossOriginHandler handler = new CrossOriginHandler(defaultCors); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json"); + APIGatewayProxyResponseEvent response = handler.process(event, new APIGatewayProxyResponseEvent().withBody("OK")); + + assertThat(response.getBody()).isEqualTo("OK"); + Map headers = response.getHeaders(); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_ORIGIN, "http://origin.com"); + assertThat(headers).containsEntry(VARY, VARY_ORIGIN); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type, X-Amz-Date"); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_METHODS, "OPTIONS, POST"); + assertThat(headers).containsEntry(ACCESS_CONTROL_MAX_AGE, "42"); + assertThat(headers).containsEntry(ACCESS_CONTROL_EXPOSE_HEADERS, "Content-Type, X-Amz-Date"); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + } + + @Test + @SetEnvironmentVariable.SetEnvironmentVariables(value = { + @SetEnvironmentVariable(key = ENV_ACCESS_CONTROL_ALLOW_ORIGIN, value = "http://*") + }) + public void corsWithWildcardOrigin_shouldReturnCorrectOrigin() { + CrossOriginHandler handler = new CrossOriginHandler(defaultCors); + APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json"); + APIGatewayProxyResponseEvent response = handler.process(event, new APIGatewayProxyResponseEvent().withBody("OK")); + + assertThat(response.getBody()).isEqualTo("OK"); + Map headers = response.getHeaders(); + assertThat(headers).containsEntry(ACCESS_CONTROL_ALLOW_ORIGIN, "http://origin.com"); + assertThat(headers).containsEntry(VARY, VARY_ORIGIN); + } +} diff --git a/powertools-cors/src/test/resources/apigw_event.json b/powertools-cors/src/test/resources/apigw_event.json new file mode 100644 index 000000000..9c9e69a15 --- /dev/null +++ b/powertools-cors/src/test/resources/apigw_event.json @@ -0,0 +1,62 @@ +{ + "body": "{\"id\":1234, \"name\":\"product\", \"price\":42}", + "resource": "/{proxy+}", + "path": "/path/to/resource", + "httpMethod": "POST", + "isBase64Encoded": false, + "queryStringParameters": { + "foo": "bar" + }, + "pathParameters": { + "proxy": "/path/to/resource" + }, + "stageVariables": { + "baz": "qux" + }, + "headers": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, sdch", + "Accept-Language": "en-US,en;q=0.8", + "Cache-Control": "max-age=0", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "origin": "http://origin.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Custom User Agent String", + "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", + "X-Forwarded-For": "127.0.0.1, 127.0.0.2", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "requestContext": { + "accountId": "123456789012", + "resourceId": "123456", + "stage": "prod", + "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", + "requestTime": "09/Apr/2015:12:34:56 +0000", + "requestTimeEpoch": 1428582896000, + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "accessKey": null, + "sourceIp": "127.0.0.1", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "Custom User Agent String", + "user": null + }, + "path": "/prod/path/to/resource", + "resourcePath": "/{proxy+}", + "httpMethod": "POST", + "apiId": "1234567890", + "protocol": "HTTP/1.1" + } +} diff --git a/powertools-cors/src/test/resources/apigw_event_malformed_origin.json b/powertools-cors/src/test/resources/apigw_event_malformed_origin.json new file mode 100644 index 000000000..602203f17 --- /dev/null +++ b/powertools-cors/src/test/resources/apigw_event_malformed_origin.json @@ -0,0 +1,62 @@ +{ + "body": "{\"id\":1234, \"name\":\"product\", \"price\":42}", + "resource": "/{proxy+}", + "path": "/path/to/resource", + "httpMethod": "POST", + "isBase64Encoded": false, + "queryStringParameters": { + "foo": "bar" + }, + "pathParameters": { + "proxy": "/path/to/resource" + }, + "stageVariables": { + "baz": "qux" + }, + "headers": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, sdch", + "Accept-Language": "en-US,en;q=0.8", + "Cache-Control": "max-age=0", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "origin": "malformed-origin-should-not-happen.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Custom User Agent String", + "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", + "X-Forwarded-For": "127.0.0.1, 127.0.0.2", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "requestContext": { + "accountId": "123456789012", + "resourceId": "123456", + "stage": "prod", + "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", + "requestTime": "09/Apr/2015:12:34:56 +0000", + "requestTimeEpoch": 1428582896000, + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "accessKey": null, + "sourceIp": "127.0.0.1", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "Custom User Agent String", + "user": null + }, + "path": "/prod/path/to/resource", + "resourcePath": "/{proxy+}", + "httpMethod": "POST", + "apiId": "1234567890", + "protocol": "HTTP/1.1" + } +} diff --git a/powertools-cors/src/test/resources/sqs_event.json b/powertools-cors/src/test/resources/sqs_event.json new file mode 100644 index 000000000..d33db4b53 --- /dev/null +++ b/powertools-cors/src/test/resources/sqs_event.json @@ -0,0 +1,40 @@ +{ + "Records": [ + { + "messageId": "d9144555-9a4f-4ec3-99a0-34ce359b4b54", + "receiptHandle": "13e7f7851d2eaa5c01f208ebadbf1e72==", + "body": "{\n \"id\": 1234,\n \"name\": \"product\",\n \"price\": 42\n}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1601975706495", + "SenderId": "AROAIFU437PVZ5L2J53F5", + "ApproximateFirstReceiveTimestamp": "1601975706499" + }, + "messageAttributes": { + + }, + "md5OfBody": "13e7f7851d2eaa5c01f208ebadbf1e72", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:eu-central-1:123456789012:TestLambda", + "awsRegion": "eu-central-1" + }, + { + "messageId": "d9144555-9a4f-4ec3-99a0-34ce359b4b54", + "receiptHandle": "13e7f7851d2eaa5c01f208ebadbf1e72==", + "body": "{\n \"id\": 12345,\n \"name\": \"product5\",\n \"price\": 45\n}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1601975706495", + "SenderId": "AROAIFU437PVZ5L2J53F5", + "ApproximateFirstReceiveTimestamp": "1601975706499" + }, + "messageAttributes": { + + }, + "md5OfBody": "13e7f7851d2eaa5c01f208ebadbf1e72", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:eu-central-1:123456789012:TestLambda", + "awsRegion": "eu-central-1" + } + ] +} \ No newline at end of file diff --git a/powertools-idempotency/pom.xml b/powertools-idempotency/pom.xml index 0332bf8e4..dd5348dde 100644 --- a/powertools-idempotency/pom.xml +++ b/powertools-idempotency/pom.xml @@ -122,6 +122,7 @@ com.amazonaws aws-lambda-java-tests + test com.amazonaws