Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for HttpApi cors configuration #1153

Merged
merged 5 commits into from Apr 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Expand Up @@ -389,7 +389,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
Expand Down
111 changes: 89 additions & 22 deletions src/events/http/HttpServer.js
Expand Up @@ -20,6 +20,7 @@ import debugLog from '../../debugLog.js'
import serverlessLog, { logRoutes } from '../../serverlessLog.js'
import {
detectEncoding,
getHttpApiCorsConfig,
jsonPath,
splitHandlerPathAndName,
generateHapiPath,
Expand Down Expand Up @@ -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
})
}
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions 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
}
1 change: 1 addition & 0 deletions src/utils/index.js
Expand Up @@ -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'
Expand Down
10 changes: 10 additions & 0 deletions 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,
}
}
@@ -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',
)
})
})
26 changes: 26 additions & 0 deletions tests/integration/httpApi-cors-default/serverless.yml
@@ -0,0 +1,26 @@
service: httpapi-cors-default

plugins:
- ../../../

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
10 changes: 10 additions & 0 deletions 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,
}
}
68 changes: 68 additions & 0 deletions 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)
})
})
37 changes: 37 additions & 0 deletions tests/integration/httpApi-cors/serverless.yml
@@ -0,0 +1,37 @@
service: httpapi-cors

plugins:
- ../../../

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