From b3fa2b9bb1d53f7c62ef839c01b4c62ecf62aa1c Mon Sep 17 00:00:00 2001 From: Giurgiu Razvan Date: Thu, 20 Jun 2024 18:05:59 +0300 Subject: [PATCH] feat: Enable OPTIONS preflight and a method to enable cros origins (#359) ### Reasoning At the moment, no support for OPTIONS request is implemented. This is the preflight request for CORS checks, thus API calls to a serverless function from a web browser is failing. ### Changes Enable preflight response [OPTIONS] with generic cors allow. Export a function "cors" where you can specify the allowed origins. --- README.md | 4 ++++ index.js | 5 ++++- lib/invocation-handler.js | 12 +++++++++++- lib/invoker.js | 18 +++++++++++++----- lib/types.d.ts | 6 +++++- test/test.js | 39 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5d4f7df..8009971 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ export interface Function { // This function is optional and should be synchronous. shutdown?: () => any; + // Function that returns an array of CORS origins + // This function is optional. + cors?: () => string[]; + // The liveness function, called to check if the server is alive // This function is optional and should return 200/OK if the server is alive. liveness?: HealthCheck; diff --git a/index.js b/index.js index 36f32ba..d061c36 100644 --- a/index.js +++ b/index.js @@ -23,7 +23,7 @@ const INCLUDE_RAW = false; /** * Starts the provided Function. If the function is a module, it will be - * inspected for init, shutdown, liveness, and readiness functions and those + * inspected for init, shutdown, cors, liveness, and readiness functions and those * will be used to configure the server. If it's a function, it will be used * directly. * @@ -56,6 +56,9 @@ async function start(func, options) { if (typeof func.readiness === 'function') { options.readiness = func.readiness; } + if (typeof func.cors === 'function') { + options.cors = func.cors; + } return __start(func.handle, options); } diff --git a/lib/invocation-handler.js b/lib/invocation-handler.js index 676f8f9..709c4e7 100644 --- a/lib/invocation-handler.js +++ b/lib/invocation-handler.js @@ -4,7 +4,8 @@ const invoker = require('./invoker'); module.exports = function use(fastify, opts, done) { fastify.get('/', doGet); fastify.post('/', doPost); - const invokeFunction = invoker(opts.func); + fastify.options('/', doOptions); + const invokeFunction = invoker(opts); // TODO: if we know this is a CloudEvent function, should // we allow GET requests? @@ -15,6 +16,15 @@ module.exports = function use(fastify, opts, done) { async function doPost(request, reply) { sendReply(reply, await invokeFunction(request.fcontext, reply.log)); } + + async function doOptions(_request, reply) { + reply.code(204).headers({ + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-methods': 'OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH', + 'access-control-allow-origin': '*', + 'access-control-allow-headers': '*' + }).send(); + } done(); }; diff --git a/lib/invoker.js b/lib/invoker.js index e8ab08c..991ce0a 100644 --- a/lib/invoker.js +++ b/lib/invoker.js @@ -3,7 +3,8 @@ */ const { CloudEvent, HTTP } = require('cloudevents'); -module.exports = function invoker(func) { +module.exports = function invoker(opts) { + const func = opts.func; return async function invokeFunction(context, log) { // Default payload values const payload = { @@ -11,12 +12,19 @@ module.exports = function invoker(func) { response: undefined, headers: { 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-methods': - 'OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH', - 'access-control-allow-origin': '*' + 'access-control-allow-methods': 'OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH', } }; + if (opts.funcConfig.cors) { + const allowedOrigins = opts.funcConfig.cors(); + const requestOrigin = context.headers.origin; + const originIncluded = allowedOrigins.includes(requestOrigin) ? requestOrigin : null; + payload.headers['access-control-allow-origin'] = originIncluded; + } else { + payload.headers['access-control-allow-origin'] = '*'; + } + let fnReturn; const scope = Object.freeze({}); try { @@ -29,7 +37,7 @@ module.exports = function invoker(func) { if (fnReturn instanceof CloudEvent || fnReturn.constructor?.name === 'CloudEvent') { try { const message = HTTP.binary(fnReturn); - payload.headers = {...payload.headers, ...message.headers}; + payload.headers = { ...payload.headers, ...message.headers }; payload.response = message.body; // In this case, where the function is invoked with a CloudEvent // and returns a CloudEvent we don't need to continue processing the diff --git a/lib/types.d.ts b/lib/types.d.ts index 42b7529..b553de7 100644 --- a/lib/types.d.ts +++ b/lib/types.d.ts @@ -1,6 +1,6 @@ /* eslint-disable no-unused-vars */ -import { IncomingHttpHeaders, IncomingMessage } from 'http'; import { CloudEvent } from 'cloudevents'; +import { IncomingHttpHeaders, IncomingMessage } from 'http'; import { Http2ServerRequest, Http2ServerResponse } from 'http2'; /** @@ -16,6 +16,10 @@ export interface Function { // This function is optional. shutdown?: () => (any | Promise); + // Function that returns an array of CORS origins + // This function is optional. + cors?: () => string[]; + // The liveness function, called to check if the server is alive // This function is optional and should return 200/OK if the server is alive. liveness?: HealthCheck; diff --git a/test/test.js b/test/test.js index d79776f..1a69454 100644 --- a/test/test.js +++ b/test/test.js @@ -378,6 +378,45 @@ test('Sends CORS headers in HTTP response', t => { }, errHandler(t)); }); +test('Responds to OPTIONS with CORS headers in HTTP response', t => { + start(_ => '') + .then(server => { + t.plan(2); + request(server) + .options('/') + .expect(204) + .expect('Access-Control-Allow-Origin', '*') + .expect('Access-Control-Allow-Methods', + 'OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH') + .end((err, res) => { + t.error(err, 'No error'); + t.equal(res.text, ''); + t.end(); + server.close(); + }); + }, errHandler(t)); +}); + +test('Responds to GET with CORS headers in HTTP response', t => { + start(_ => '', { cors: () => ['http://example.com', 'http://example2.com'] } ) + .then(server => { + t.plan(2); + request(server) + .get('/') + .expect(204) + .set('Origin', 'http://example.com') + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect('Access-Control-Allow-Methods', + 'OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH') + .end((err, res) => { + t.error(err, 'No error'); + t.equal(res.text, ''); + t.end(); + server.close(); + }); + }, errHandler(t)); +}); + test('Respects headers set by the function', t => { const func = require(`${__dirname}/fixtures/response-header/`); start(func)