From f6447c438fd672f22ca424b512c56d2d87266740 Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Thu, 31 Dec 2020 11:28:25 -0700 Subject: [PATCH 1/2] Add support for HttpApi cors configuration --- README.md | 5 +- src/config/commandOptions.js | 2 +- src/events/http/HttpServer.js | 111 ++++++++++++++---- src/utils/getHttpApiCorsConfig.js | 27 +++++ src/utils/index.js | 1 + .../httpApi-cors-default/handler.js | 10 ++ .../httpApi-cors-default.test.js | 45 +++++++ .../httpApi-cors-default/serverless.yml | 26 ++++ tests/integration/httpApi-cors/handler.js | 10 ++ .../httpApi-cors/httpApi-cors.test.js | 68 +++++++++++ tests/integration/httpApi-cors/serverless.yml | 37 ++++++ 11 files changed, 318 insertions(+), 24 deletions(-) create mode 100644 src/utils/getHttpApiCorsConfig.js create mode 100644 tests/integration/httpApi-cors-default/handler.js create mode 100644 tests/integration/httpApi-cors-default/httpApi-cors-default.test.js create mode 100644 tests/integration/httpApi-cors-default/serverless.yml create mode 100644 tests/integration/httpApi-cors/handler.js create mode 100644 tests/integration/httpApi-cors/httpApi-cors.test.js create mode 100644 tests/integration/httpApi-cors/serverless.yml diff --git a/README.md b/README.md index da4d4df00..e29e9f35c 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,10 @@ your response template should be in file: `helloworld.res.vm` and your request t [Serverless doc](https://serverless.com/framework/docs/providers/aws/events/apigateway#enabling-cors) -If the endpoint config has CORS set to true, the plugin will use the CLI CORS options for the associated route. +For HTTP APIs, the CORS configuration will work out of the box. Any CLI arguments +passed in will be ignored. + +For REST APIs, if the endpoint config has CORS set to true, the plugin will use the CLI CORS options for the associated route. Otherwise, no CORS headers will be added. ### Catch-all Path Variables diff --git a/src/config/commandOptions.js b/src/config/commandOptions.js index 604cc9c69..635702e82 100644 --- a/src/config/commandOptions.js +++ b/src/config/commandOptions.js @@ -17,7 +17,7 @@ export default { }, corsExposedHeaders: { usage: - 'USed to build the Access-Control-Exposed-Headers response header for CORS support', + 'Used to build the Access-Control-Exposed-Headers response header for CORS support', }, disableCookieValidation: { usage: 'Used to disable cookie-validation on hapi.js-server', diff --git a/src/events/http/HttpServer.js b/src/events/http/HttpServer.js index 0c77db282..61fc0a39c 100644 --- a/src/events/http/HttpServer.js +++ b/src/events/http/HttpServer.js @@ -20,6 +20,7 @@ import debugLog from '../../debugLog.js' import serverlessLog, { logRoutes } from '../../serverlessLog.js' import { detectEncoding, + getHttpApiCorsConfig, jsonPath, splitHandlerPathAndName, generateHapiPath, @@ -89,35 +90,87 @@ export default class HttpServer { const explicitlySetHeaders = { ...response.headers } - response.headers['access-control-allow-origin'] = request.headers.origin - response.headers['access-control-allow-credentials'] = 'true' - - if (request.method === 'options') { - response.statusCode = 200 - response.headers['access-control-expose-headers'] = - 'content-type, content-length, etag' - response.headers['access-control-max-age'] = 60 * 10 + if ( + this.#serverless.service.provider.httpApi && + this.#serverless.service.provider.httpApi.cors + ) { + const httpApiCors = getHttpApiCorsConfig( + this.#serverless.service.provider.httpApi.cors, + ) - if (request.headers['access-control-request-headers']) { - response.headers['access-control-allow-headers'] = - request.headers['access-control-request-headers'] + if (request.method === 'options') { + response.statusCode = 204 + const allowAllOrigins = + httpApiCors.allowedOrigins.length === 1 && + httpApiCors.allowedOrigins[0] === '*' + if ( + !allowAllOrigins && + !httpApiCors.allowedOrigins.includes(request.headers.origin) + ) { + return h.continue + } } - if (request.headers['access-control-request-method']) { - response.headers['access-control-allow-methods'] = - request.headers['access-control-request-method'] + response.headers['access-control-allow-origin'] = + request.headers.origin + if (httpApiCors.allowCredentials) { + response.headers['access-control-allow-credentials'] = 'true' } - } + if (httpApiCors.maxAge) { + response.headers['access-control-max-age'] = httpApiCors.maxAge + } + if (httpApiCors.exposedResponseHeaders) { + response.headers[ + 'access-control-expose-headers' + ] = httpApiCors.exposedResponseHeaders.join(',') + } + if (httpApiCors.allowedMethods) { + response.headers[ + 'access-control-allow-methods' + ] = httpApiCors.allowedMethods.join(',') + } + if (httpApiCors.allowedHeaders) { + response.headers[ + 'access-control-allow-headers' + ] = httpApiCors.allowedHeaders.join(',') + } + } else { + response.headers['access-control-allow-origin'] = + request.headers.origin + response.headers['access-control-allow-credentials'] = 'true' + + if (request.method === 'options') { + response.statusCode = 200 + + if (request.headers['access-control-expose-headers']) { + response.headers['access-control-expose-headers'] = + request.headers['access-control-expose-headers'] + } else { + response.headers['access-control-expose-headers'] = + 'content-type, content-length, etag' + } + response.headers['access-control-max-age'] = 60 * 10 + + if (request.headers['access-control-request-headers']) { + response.headers['access-control-allow-headers'] = + request.headers['access-control-request-headers'] + } - // Override default headers with headers that have been explicitly set - Object.keys(explicitlySetHeaders).forEach((key) => { - const value = explicitlySetHeaders[key] - if (value) { - response.headers[key] = value + if (request.headers['access-control-request-method']) { + response.headers['access-control-allow-methods'] = + request.headers['access-control-request-method'] + } } - }) - } + // Override default headers with headers that have been explicitly set + Object.keys(explicitlySetHeaders).forEach((key) => { + const value = explicitlySetHeaders[key] + if (value) { + response.headers[key] = value + } + }) + } + } return h.continue }) } @@ -343,6 +396,20 @@ export default class HttpServer { headers: endpoint.cors.headers || this.#options.corsConfig.headers, origin: endpoint.cors.origins || this.#options.corsConfig.origin, } + } else if ( + this.#serverless.service.provider.httpApi && + this.#serverless.service.provider.httpApi.cors + ) { + const httpApiCors = getHttpApiCorsConfig( + this.#serverless.service.provider.httpApi.cors, + ) + cors = { + origin: httpApiCors.allowedOrigins || [], + credentials: httpApiCors.allowCredentials, + exposedHeaders: httpApiCors.exposedResponseHeaders || [], + maxAge: httpApiCors.maxAge, + headers: httpApiCors.allowedHeaders || [], + } } const hapiMethod = method === 'ANY' ? '*' : method diff --git a/src/utils/getHttpApiCorsConfig.js b/src/utils/getHttpApiCorsConfig.js new file mode 100644 index 000000000..5585efc81 --- /dev/null +++ b/src/utils/getHttpApiCorsConfig.js @@ -0,0 +1,27 @@ +import debugLog from '../debugLog.js' +import { logWarning } from '../serverlessLog.js' + +export default function getHttpApiCorsConfig(httpApiCors) { + if (httpApiCors === true) { + // default values that should be set by serverless + // https://www.serverless.com/framework/docs/providers/aws/events/http-api/ + const c = { + allowedOrigins: ['*'], + allowedHeaders: [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + 'X-Amz-User-Agent', + ], + allowedMethods: ['OPTIONS', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'], + } + debugLog(c) + logWarning(c) + return c + } + debugLog(httpApiCors) + logWarning(httpApiCors) + return httpApiCors +} diff --git a/src/utils/index.js b/src/utils/index.js index 9e7397a3e..8ac844a0a 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -5,6 +5,7 @@ export { default as createApiKey } from './createApiKey.js' export { default as createUniqueId } from './createUniqueId.js' export { default as detectExecutable } from './detectExecutable.js' export { default as formatToClfTime } from './formatToClfTime.js' +export { default as getHttpApiCorsConfig } from './getHttpApiCorsConfig.js' export { default as jsonPath } from './jsonPath.js' export { default as parseHeaders } from './parseHeaders.js' export { default as parseMultiValueHeaders } from './parseMultiValueHeaders.js' diff --git a/tests/integration/httpApi-cors-default/handler.js b/tests/integration/httpApi-cors-default/handler.js new file mode 100644 index 000000000..29cca0173 --- /dev/null +++ b/tests/integration/httpApi-cors-default/handler.js @@ -0,0 +1,10 @@ +'use strict' + +exports.user = async function get() { + return { + body: JSON.stringify({ + something: true, + }), + statusCode: 200, + } +} diff --git a/tests/integration/httpApi-cors-default/httpApi-cors-default.test.js b/tests/integration/httpApi-cors-default/httpApi-cors-default.test.js new file mode 100644 index 000000000..a26ba0dbd --- /dev/null +++ b/tests/integration/httpApi-cors-default/httpApi-cors-default.test.js @@ -0,0 +1,45 @@ +// tests based on: +// https://dev.to/piczmar_0/serverless-authorizers---custom-rest-authorizer-16 + +import { resolve } from 'path' +import fetch from 'node-fetch' +import { joinUrl, setup, teardown } from '../_testHelpers/index.js' + +jest.setTimeout(30000) + +describe('HttpApi Cors Default Tests', () => { + // init + beforeAll(() => + setup({ + servicePath: resolve(__dirname), + }), + ) + + // cleanup + afterAll(() => teardown()) + + test('Fetch OPTIONS with any origin', async () => { + const url = joinUrl(TEST_BASE_URL, '/dev/user') + const options = { + method: 'OPTIONS', + headers: { + origin: 'http://www.mytestapp.com', + 'access-control-request-headers': 'authorization,content-type', + 'access-control-request-method': 'GET', + }, + } + + const response = await fetch(url, options) + expect(response.status).toEqual(204) + + expect(response.headers.get('access-control-allow-origin')).toEqual( + 'http://www.mytestapp.com', + ) + expect(response.headers.get('access-control-allow-methods')).toEqual( + 'OPTIONS,GET,POST,PUT,DELETE,PATCH', + ) + expect(response.headers.get('access-control-allow-headers')).toEqual( + 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent', + ) + }) +}) diff --git a/tests/integration/httpApi-cors-default/serverless.yml b/tests/integration/httpApi-cors-default/serverless.yml new file mode 100644 index 000000000..260dddcd8 --- /dev/null +++ b/tests/integration/httpApi-cors-default/serverless.yml @@ -0,0 +1,26 @@ +service: httpapi-cors-default + +plugins: + - ../../../src/main.js + +provider: + memorySize: 128 + name: aws + region: us-east-1 # default + runtime: nodejs12.x + stage: dev + versionFunctions: false + httpApi: + payload: '1.0' + cors: true + +functions: + user: + events: + - httpApi: + method: get + path: user + - httpApi: + method: put + path: user + handler: handler.user diff --git a/tests/integration/httpApi-cors/handler.js b/tests/integration/httpApi-cors/handler.js new file mode 100644 index 000000000..29cca0173 --- /dev/null +++ b/tests/integration/httpApi-cors/handler.js @@ -0,0 +1,10 @@ +'use strict' + +exports.user = async function get() { + return { + body: JSON.stringify({ + something: true, + }), + statusCode: 200, + } +} diff --git a/tests/integration/httpApi-cors/httpApi-cors.test.js b/tests/integration/httpApi-cors/httpApi-cors.test.js new file mode 100644 index 000000000..9b6f3fe10 --- /dev/null +++ b/tests/integration/httpApi-cors/httpApi-cors.test.js @@ -0,0 +1,68 @@ +// tests based on: +// https://dev.to/piczmar_0/serverless-authorizers---custom-rest-authorizer-16 + +import { resolve } from 'path' +import fetch from 'node-fetch' +import { joinUrl, setup, teardown } from '../_testHelpers/index.js' + +jest.setTimeout(30000) + +describe('HttpApi Cors Tests', () => { + // init + beforeAll(() => + setup({ + servicePath: resolve(__dirname), + }), + ) + + // cleanup + afterAll(() => teardown()) + + test('Fetch OPTIONS with valid origin', async () => { + const url = joinUrl(TEST_BASE_URL, '/dev/user') + const options = { + method: 'OPTIONS', + headers: { + origin: 'http://www.mytestapp.com', + 'access-control-request-headers': 'authorization,content-type', + 'access-control-request-method': 'GET', + }, + } + + const response = await fetch(url, options) + expect(response.status).toEqual(204) + + expect(response.headers.get('access-control-allow-origin')).toEqual( + 'http://www.mytestapp.com', + ) + expect(response.headers.get('access-control-allow-credentials')).toEqual( + 'true', + ) + expect(response.headers.get('access-control-max-age')).toEqual('60') + expect(response.headers.get('access-control-expose-headers')).toEqual( + 'status,origin', + ) + expect(response.headers.get('access-control-allow-methods')).toEqual( + 'GET,POST', + ) + expect(response.headers.get('access-control-allow-headers')).toEqual( + 'authorization,content-type', + ) + }) + + test('Fetch OPTIONS with invalid origin', async () => { + const url = joinUrl(TEST_BASE_URL, '/dev/user') + const options = { + method: 'OPTIONS', + headers: { + origin: 'http://www.wrongapp.com', + 'access-control-request-headers': 'authorization,content-type', + 'access-control-request-method': 'GET', + }, + } + + const response = await fetch(url, options) + expect(response.status).toEqual(204) + expect(response.headers.get('access-control-allow-origin')).toEqual(null) + }) +}) diff --git a/tests/integration/httpApi-cors/serverless.yml b/tests/integration/httpApi-cors/serverless.yml new file mode 100644 index 000000000..2e2b78c09 --- /dev/null +++ b/tests/integration/httpApi-cors/serverless.yml @@ -0,0 +1,37 @@ +service: httpapi-cors + +plugins: + - ../../../src/main.js + +provider: + memorySize: 128 + name: aws + region: us-east-1 # default + runtime: nodejs12.x + stage: dev + versionFunctions: false + httpApi: + payload: '1.0' + cors: + allowedOrigins: + - http://www.mytestapp.com + - http://www.myothertestapp.com + allowCredentials: true + maxAge: 60 # In seconds + exposedResponseHeaders: + - status + - origin + allowedMethods: + - GET + - POST + allowedHeaders: + - authorization + - content-type + +functions: + user: + events: + - httpApi: + method: get + path: user + handler: handler.user From bdd41d33335db0454306b5f2855a0fd8b8410abe Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Wed, 31 Mar 2021 10:45:10 -0500 Subject: [PATCH 2/2] fix plugin path --- tests/integration/httpApi-cors-default/serverless.yml | 2 +- tests/integration/httpApi-cors/serverless.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/httpApi-cors-default/serverless.yml b/tests/integration/httpApi-cors-default/serverless.yml index 260dddcd8..08731614f 100644 --- a/tests/integration/httpApi-cors-default/serverless.yml +++ b/tests/integration/httpApi-cors-default/serverless.yml @@ -1,7 +1,7 @@ service: httpapi-cors-default plugins: - - ../../../src/main.js + - ../../../ provider: memorySize: 128 diff --git a/tests/integration/httpApi-cors/serverless.yml b/tests/integration/httpApi-cors/serverless.yml index 2e2b78c09..c332b076b 100644 --- a/tests/integration/httpApi-cors/serverless.yml +++ b/tests/integration/httpApi-cors/serverless.yml @@ -1,7 +1,7 @@ service: httpapi-cors plugins: - - ../../../src/main.js + - ../../../ provider: memorySize: 128