Repository: serverless/serverless (JavaScript)
Date: 2017-05-26 13:38:25+00:00
Task type: Bug Fix

Issue:
setting multiple allowed-origins in CORS results in AWS APIG that defies W3C
# This is a Bug Report
- What went wrong?

TL;DR: Access-Control-Allow-Origin header contains multiple origins, which violates the W3C specification and makes Chrome cranky

There's a combination of things here...

The documentation and YAML structure imply that multiple allowed-origins for CORS settings are supported, because it is a list with plural name (origins), and not a string with a singular name (origin):

https://serverless.com/framework/docs/providers/aws/events/apigateway/#enabling-cors-for-your-endpoints

``` yaml
functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: user/create
          method: get
          cors:
            origins:
              - '*'
```

Serverless seems to configure a static HTTP response header for Access-Control-Allow-Origin, with a string value that is a comma-delimited version of the list that we specify in the YAML.

This is against the W3C CORS specification, and Chrome complains (in the console) when it sees `Access-Control-Allow-Origin: https://origin1.com,https://origin2.com`

https://www.w3.org/TR/cors/#resource-requests

> add a single Access-Control-Allow-Origin header, with either the value of the Origin header or the string "*" as value

The Access-Control-Allow-Origin header has to be a single origin that is the same as the incoming Origin header, or the wildcard "*". It is not allowed to be multiple origins. I would think that this design is to avoid information disclosure, to prevent a failed pre-flight check from telling malicious parties all about all the other origins that can try.

Unfortunately, AWS API Gateway itself only supports a single allowed-origin, probably to simplify implementation and improve the performance of the service:

https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-cors.html

> Use the console-provided value of '_*' as the Access-Control-Allow-Origin header value to allow access requests from all domains, or specify a named domain to all access requests from the specified domain

"a named domain" - singular
- What did you expect should have happened?

For example, say I allow 2 origins: https://origin1.com and https://origin2.com
- request from https://origin1.com = Access-Control-Allow-Origin: https://origin1.com
- request from https://origin2.com = Access-Control-Allow-Origin: https://origin2.com
- request from https://origin3.com = no CORS headers

Server-side web server frameworks like Hapi.js support configuring multiple allowed-origins, but only ever actually respond with the incoming request Origin header if there is a match:

https://github.com/hapijs/hapi/blob/v15.1.1/lib/cors.js#L85

Ideally, AWS APIG would support multiple headers to be configured, and perform this request-time match for us. But that doesn't seem available just now.

Alternatively, is it possible to achieve this sort of matching with an APIG response header template that Serverless defines?
- What was the config you used?

``` yaml
functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: user/create
          method: get
          cors:
            origins:
              - https://origin1.com
              - https://origin2.com
```
- What stacktrace or error message from your provider did you see?

n/a
## Additional Data
- **_Serverless Framework Version you're using**_: 1.0.0-rc.2
- **_Operating System**_: macOS Sierra



Issue discussion:
@mymattcarroll

Thank you very much for reporting @jokeyrhyme 👍 

That makes sense! I'm only concerned that this might break existing deployments when renaming `origins` to `origin` and only supporting a string config variable.

@brianneisler @eahefnawy do you guys have any idea how we can make this change in a non-breaking way?

Should we add the `origin` config parameter and keep `origins` until v2.0 is released? We can add a validation so that the user can only use `origin` xor `origins`. Any other ideas?
@pmuens that seems to be the simplest way to fix this in a backwards compatible way. 

> Alternatively, is it possible to achieve this sort of matching with an APIG response header template that Serverless defines?

This might be worth taking two minutes to look at... would be a great feature if we could get that to work. 
@pmuens my preference would be to:
-   short term: document the current behaviour with a warning
-   short term: detect when `origins` has more than 1 entry, and display a warning to the user
-   long term: keep `origins` and make it work

Even if we can't get this to work the way we want it to, I would discourage renaming the setting to the singular `origin`, for the compatibility reasons you point out.

Solution patch:
diff --git a/docs/providers/aws/events/apigateway.md b/docs/providers/aws/events/apigateway.md
index e4e5385b1aa..da85ecf369f 100644
--- a/docs/providers/aws/events/apigateway.md
+++ b/docs/providers/aws/events/apigateway.md
@@ -115,8 +115,7 @@ functions:
           path: hello
           method: get
           cors:
-            origins:
-              - '*'
+            origin: '*'
             headers:
               - Content-Type
               - X-Amz-Date
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js
index 3ea3f00f3f8..cb8a5fd91e3 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js
@@ -12,8 +12,14 @@ module.exports = {
       const corsMethodLogicalId = this.provider.naming
         .getMethodLogicalId(resourceName, 'options');
 
+      // TODO remove once "origins" config is deprecated
+      let origin = config.origin;
+      if (config.origins && config.origins.length) {
+        origin = config.origins.join(',');
+      }
+
       const preflightHeaders = {
-        'Access-Control-Allow-Origin': `'${config.origins.join(',')}'`,
+        'Access-Control-Allow-Origin': `'${origin}'`,
         'Access-Control-Allow-Headers': `'${config.headers.join(',')}'`,
         'Access-Control-Allow-Methods': `'${config.methods.join(',')}'`,
         'Access-Control-Allow-Credentials': `'${config.allowCredentials}'`,
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/integration.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/integration.js
index fa67bb8ba1a..28aa0d7a717 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/integration.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/integration.js
@@ -102,8 +102,14 @@ module.exports = {
       const integrationResponseHeaders = [];
 
       if (http.cors) {
+        // TODO remove once "origins" config is deprecated
+        let origin = http.cors.origin;
+        if (http.cors.origins && http.cors.origins.length) {
+          origin = http.cors.origins.join(',');
+        }
+
         _.merge(integrationResponseHeaders, {
-          'Access-Control-Allow-Origin': `'${http.cors.origins.join(',')}'`,
+          'Access-Control-Allow-Origin': `'${origin}'`,
         });
       }
 
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/responses.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/responses.js
index c039b46fcf4..5d57455e1da 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/responses.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/responses.js
@@ -12,8 +12,14 @@ module.exports = {
         const methodResponseHeaders = [];
 
         if (http.cors) {
+          // TODO remove once "origins" config is deprecated
+          let origin = http.cors.origin;
+          if (http.cors.origins && http.cors.origins.length) {
+            origin = http.cors.origins.join(',');
+          }
+
           _.merge(methodResponseHeaders, {
-            'Access-Control-Allow-Origin': `'${http.cors.origins.join(',')}'`,
+            'Access-Control-Allow-Origin': `'${origin}'`,
           });
         }
 
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
index 682502c1e57..2cbbfe924b8 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
@@ -58,6 +58,7 @@ module.exports = {
             cors.headers = _.union(http.cors.headers, cors.headers);
             cors.methods = _.union(http.cors.methods, cors.methods);
             cors.origins = _.union(http.cors.origins, cors.origins);
+            cors.origin = http.cors.origin || '*';
             cors.allowCredentials = cors.allowCredentials || http.cors.allowCredentials;
 
             corsPreflight[http.path] = cors;
@@ -274,6 +275,7 @@ module.exports = {
 
     let cors = {
       origins: ['*'],
+      origin: '*',
       methods: ['OPTIONS'],
       headers,
       allowCredentials: false,
@@ -284,6 +286,15 @@ module.exports = {
       cors.methods = cors.methods || [];
       cors.allowCredentials = Boolean(cors.allowCredentials);
 
+      if (cors.origins && cors.origin) {
+        const errorMessage = [
+          'You can only use "origin" or "origins",',
+          ' but not both at the same time to configure CORS.',
+          ' Please check the docs for more info.',
+        ].join('');
+        throw new this.serverless.classes.Error(errorMessage);
+      }
+
       if (cors.headers) {
         if (!Array.isArray(cors.headers)) {
           const errorMessage = [


Test patch:
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js
index 6f06b721860..1b77b46a936 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js
@@ -54,13 +54,13 @@ describe('#compileCors()', () => {
   it('should create preflight method for CORS enabled resource', () => {
     awsCompileApigEvents.validated.corsPreflight = {
       'users/update': {
-        origins: ['*'],
+        origin: 'http://example.com',
         headers: ['*'],
         methods: ['OPTIONS', 'PUT'],
         allowCredentials: false,
       },
       'users/create': {
-        origins: ['*'],
+        origins: ['*', 'http://example.com'],
         headers: ['*'],
         methods: ['OPTIONS', 'POST'],
         allowCredentials: true,
@@ -72,19 +72,20 @@ describe('#compileCors()', () => {
         allowCredentials: false,
       },
       'users/any': {
-        origins: ['*'],
+        origins: ['http://example.com'],
         headers: ['*'],
         methods: ['OPTIONS', 'ANY'],
         allowCredentials: false,
       },
     };
     return awsCompileApigEvents.compileCors().then(() => {
+      // users/create
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
           .Resources.ApiGatewayMethodUsersCreateOptions
           .Properties.Integration.IntegrationResponses[0]
           .ResponseParameters['method.response.header.Access-Control-Allow-Origin']
-      ).to.equal('\'*\'');
+      ).to.equal('\'*,http://example.com\'');
 
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
@@ -107,12 +108,13 @@ describe('#compileCors()', () => {
           .ResponseParameters['method.response.header.Access-Control-Allow-Credentials']
       ).to.equal('\'true\'');
 
+      // users/update
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
           .Resources.ApiGatewayMethodUsersUpdateOptions
           .Properties.Integration.IntegrationResponses[0]
           .ResponseParameters['method.response.header.Access-Control-Allow-Origin']
-      ).to.equal('\'*\'');
+      ).to.equal('\'http://example.com\'');
 
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
@@ -128,6 +130,7 @@ describe('#compileCors()', () => {
           .ResponseParameters['method.response.header.Access-Control-Allow-Credentials']
       ).to.equal('\'false\'');
 
+      // users/delete
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
           .Resources.ApiGatewayMethodUsersDeleteOptions
@@ -156,12 +159,13 @@ describe('#compileCors()', () => {
           .ResponseParameters['method.response.header.Access-Control-Allow-Credentials']
       ).to.equal('\'false\'');
 
+      // users/any
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
           .Resources.ApiGatewayMethodUsersAnyOptions
           .Properties.Integration.IntegrationResponses[0]
           .ResponseParameters['method.response.header.Access-Control-Allow-Origin']
-      ).to.equal('\'*\'');
+      ).to.equal('\'http://example.com\'');
 
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.test.js
index da991486d0e..c81ed7edb3a 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.test.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.test.js
@@ -592,7 +592,7 @@ describe('#compileMethods()', () => {
           method: 'post',
           integration: 'AWS',
           cors: {
-            origins: ['*'],
+            origin: 'http://example.com',
           },
           response: {
             statusCodes: {
@@ -638,13 +638,12 @@ describe('#compileMethods()', () => {
       },
     ];
     return awsCompileApigEvents.compileMethods().then(() => {
-      // Check origin.
       expect(
         awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
           .Resources.ApiGatewayMethodUsersCreatePost.Properties
           .Integration.IntegrationResponses[0]
           .ResponseParameters['method.response.header.Access-Control-Allow-Origin']
-      ).to.equal('\'*\'');
+      ).to.equal('\'http://example.com\'');
 
       // CORS not enabled!
       expect(
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
index fcbbb1e4de8..7ead5365f74 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
@@ -415,6 +415,28 @@ describe('#validate()', () => {
     expect(authorizer.identityValidationExpression).to.equal('foo');
   });
 
+  it('should throw an error if "origin" and "origins" CORS config is used', () => {
+    awsCompileApigEvents.serverless.service.functions = {
+      first: {
+        events: [
+          {
+            http: {
+              method: 'POST',
+              path: '/foo/bar',
+              cors: {
+                origin: '*',
+                origins: ['*'],
+              },
+            },
+          },
+        ],
+      },
+    };
+
+    expect(() => awsCompileApigEvents.validate())
+      .to.throw(Error, 'can only use');
+  });
+
   it('should process cors defaults', () => {
     awsCompileApigEvents.serverless.service.functions = {
       first: {
@@ -436,6 +458,7 @@ describe('#validate()', () => {
       headers: ['Content-Type', 'X-Amz-Date', 'Authorization', 'X-Api-Key',
         'X-Amz-Security-Token', 'X-Amz-User-Agent'],
       methods: ['OPTIONS', 'POST'],
+      origin: '*',
       origins: ['*'],
       allowCredentials: false,
     });