From 989307d0e73b06056ecb571958fbab39b38bfea2 Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Thu, 14 Oct 2021 16:56:39 -0700 Subject: [PATCH] feat: add support for request headers & cookies (#373) * feat(runtime-handler): handle incoming headers and cookies (#293) * feat(handler): update header and cookie support for Response (#296) * feat(runtime-types): add cookie/header support for types (#297) * build: adjust dependency imports for successful build (#314) * chore(runtime-handler): add artifical rc-version bump * chore(release): publish %s - create-twilio-function@3.1.2-rc.0 - @twilio-labs/plugin-serverless@2.1.2-rc.0 - @twilio/runtime-handler@1.2.0-rc.1 - @twilio-labs/serverless-runtime-types@2.2.0-rc.0 - twilio-run@3.1.2-rc.0 * fix(runtime-handler): using set-cookie now sets cookie header (#332) * chore(release): publish %s - @twilio/runtime-handler@1.2.0-rc.2 * chore(release): publish %s - @twilio/runtime-handler@1.2.0-rc.3 * Update package.json * Update package.json Co-authored-by: Phil Nash --- packages/create-twilio-function/CHANGELOG.md | 7 +- packages/plugin-serverless/CHANGELOG.md | 7 +- packages/plugin-serverless/README.md | 2 + packages/runtime-handler/CHANGELOG.md | 24 +++ .../dev-runtime/internal/response.test.ts | 155 +++++++++++++- .../__tests__/dev-runtime/route.test.ts | 194 ++++++++++++++++-- packages/runtime-handler/package.json | 4 +- .../dev-runtime/checks/restricted-headers.ts | 22 ++ .../dev-runtime/internal/functionRunner.ts | 4 +- .../src/dev-runtime/internal/response.ts | 71 ++++++- .../runtime-handler/src/dev-runtime/route.ts | 54 ++++- .../runtime-handler/src/dev-runtime/server.ts | 2 + .../runtime-handler/src/dev-runtime/types.ts | 5 + .../serverless-runtime-types/CHANGELOG.md | 8 + .../example/functions/demo.js | 5 +- .../serverless-runtime-types/package.json | 2 +- packages/serverless-runtime-types/types.d.ts | 23 ++- packages/twilio-run/CHANGELOG.md | 7 +- 18 files changed, 547 insertions(+), 49 deletions(-) create mode 100644 packages/runtime-handler/src/dev-runtime/checks/restricted-headers.ts diff --git a/packages/create-twilio-function/CHANGELOG.md b/packages/create-twilio-function/CHANGELOG.md index aa95507a..366c7295 100644 --- a/packages/create-twilio-function/CHANGELOG.md +++ b/packages/create-twilio-function/CHANGELOG.md @@ -3,11 +3,11 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## [3.2.2-beta.0](https://github.com/twilio-labs/serverless-toolkit/compare/create-twilio-function@3.2.1...create-twilio-function@3.2.2-beta.0) (2021-09-25) -**Note:** Version bump only for package create-twilio-function +## [3.2.2-beta.0](https://github.com/twilio-labs/serverless-toolkit/compare/create-twilio-function@3.2.1...create-twilio-function@3.2.2-beta.0) (2021-09-25) +**Note:** Version bump only for package create-twilio-function @@ -36,10 +36,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ## [3.1.2](https://github.com/twilio-labs/serverless-toolkit/compare/create-twilio-function@3.1.1...create-twilio-function@3.1.2) (2021-07-14) -**Note:** Version bump only for package create-twilio-function +**Note:** Version bump only for package create-twilio-function +## [3.1.2-rc.0](https://github.com/twilio-labs/serverless-toolkit/compare/create-twilio-function@3.1.1...create-twilio-function@3.1.2-rc.0) (2021-07-14) ## [3.1.1](https://github.com/twilio-labs/serverless-toolkit/compare/create-twilio-function@3.1.0...create-twilio-function@3.1.1) (2021-06-30) diff --git a/packages/plugin-serverless/CHANGELOG.md b/packages/plugin-serverless/CHANGELOG.md index 7eb6def2..55629d8f 100644 --- a/packages/plugin-serverless/CHANGELOG.md +++ b/packages/plugin-serverless/CHANGELOG.md @@ -7,10 +7,6 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @twilio-labs/plugin-serverless - - - - ## [2.2.2](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio-labs/plugin-serverless@2.2.1...@twilio-labs/plugin-serverless@2.2.2) (2021-07-28) **Note:** Version bump only for package @twilio-labs/plugin-serverless @@ -35,8 +31,9 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline * add new env commands ([#290](https://github.com/twilio-labs/serverless-toolkit/issues/290)) ([7d11a03](https://github.com/twilio-labs/serverless-toolkit/commit/7d11a03aa5f02c6ac06147c2796f7e8c9964396e)) +## [2.1.2-rc.0](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio-labs/plugin-serverless@2.1.1...@twilio-labs/plugin-serverless@2.1.2-rc.0) (2021-07-14) - +**Note:** Version bump only for package @twilio-labs/plugin-serverless ## [2.1.1](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio-labs/plugin-serverless@2.1.0...@twilio-labs/plugin-serverless@2.1.1) (2021-06-30) diff --git a/packages/plugin-serverless/README.md b/packages/plugin-serverless/README.md index cad8ec05..cd47cf10 100644 --- a/packages/plugin-serverless/README.md +++ b/packages/plugin-serverless/README.md @@ -357,6 +357,7 @@ OPTIONS --typescript Initialize your Serverless project with TypeScript ``` + _See code: [src/commands/serverless/init.js](https://github.com/twilio-labs/serverless-toolkit/blob/v2.2.3-beta.0/src/commands/serverless/init.js)_ ## `twilio serverless:list [TYPES]` @@ -407,6 +408,7 @@ OPTIONS --to=to [Alias for "environment"] ``` + _See code: [src/commands/serverless/list.js](https://github.com/twilio-labs/serverless-toolkit/blob/v2.2.3-beta.0/src/commands/serverless/list.js)_ ## `twilio serverless:list-templates` diff --git a/packages/runtime-handler/CHANGELOG.md b/packages/runtime-handler/CHANGELOG.md index 7c1e1ec9..a2c1eee9 100644 --- a/packages/runtime-handler/CHANGELOG.md +++ b/packages/runtime-handler/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [1.2.0-rc.3](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio/runtime-handler@1.2.0-rc.2...@twilio/runtime-handler@1.2.0-rc.3) (2021-08-03) + +**Note:** Version bump only for package @twilio/runtime-handler + + + + + +# [1.2.0-rc.2](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio/runtime-handler@1.2.0-rc.1...@twilio/runtime-handler@1.2.0-rc.2) (2021-07-28) + + +### Bug Fixes + +* **runtime-handler:** using set-cookie now sets cookie header ([#332](https://github.com/twilio-labs/serverless-toolkit/issues/332)) ([6f65bc3](https://github.com/twilio-labs/serverless-toolkit/commit/6f65bc3bb692b8bd0b21d932f66ae394000e51a9)) + + # [1.2.0-beta.0](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio/runtime-handler@1.1.3...@twilio/runtime-handler@1.2.0-beta.0) (2021-09-25) @@ -21,6 +37,14 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline +# [1.2.0-rc.1](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio/runtime-handler@1.1.1...@twilio/runtime-handler@1.2.0-rc.1) (2021-07-14) + + +### Features + +* **handler:** update header and cookie support for Response ([#296](https://github.com/twilio-labs/serverless-toolkit/issues/296)) ([e9ef02e](https://github.com/twilio-labs/serverless-toolkit/commit/e9ef02ed9e10635623f462db6f53de3669ffaf0b)) +* **runtime-handler:** handle incoming headers and cookies ([#293](https://github.com/twilio-labs/serverless-toolkit/issues/293)) ([62ff180](https://github.com/twilio-labs/serverless-toolkit/commit/62ff1801db6a121122fcd944a855ad7f038cafe4)) + ## [1.1.2](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio/runtime-handler@1.1.1...@twilio/runtime-handler@1.1.2) (2021-07-19) diff --git a/packages/runtime-handler/__tests__/dev-runtime/internal/response.test.ts b/packages/runtime-handler/__tests__/dev-runtime/internal/response.test.ts index 2d44bf4f..8a54e45a 100644 --- a/packages/runtime-handler/__tests__/dev-runtime/internal/response.test.ts +++ b/packages/runtime-handler/__tests__/dev-runtime/internal/response.test.ts @@ -5,7 +5,9 @@ test('has correct defaults', () => { const response = new Response(); expect(response['body']).toBeNull(); expect(response['statusCode']).toBe(200); - expect(response['headers']).toEqual({}); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); }); test('sets status code, body and headers from constructor', () => { @@ -24,6 +26,7 @@ test('sets status code, body and headers from constructor', () => { 'Access-Control-Allow-Origin': 'example.com', 'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type', + 'Set-Cookie': [], }); }); @@ -45,7 +48,9 @@ test('sets body correctly', () => { test('sets headers correctly', () => { const response = new Response(); - expect(response['headers']).toEqual({}); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); response.setHeaders({ 'Access-Control-Allow-Origin': 'example.com', 'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE', @@ -55,6 +60,7 @@ test('sets headers correctly', () => { 'Access-Control-Allow-Origin': 'example.com', 'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type', + 'Set-Cookie': [], }; expect(response['headers']).toEqual(expected); // @ts-ignore @@ -62,28 +68,144 @@ test('sets headers correctly', () => { expect(response['headers']).toEqual(expected); }); +test('sets headers with string cookies', () => { + const response = new Response(); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); + response.setHeaders({ + 'Access-Control-Allow-Origin': 'example.com', + 'Set-Cookie': 'Hi=Bye', + }); + const expected = { + 'Access-Control-Allow-Origin': 'example.com', + 'Set-Cookie': ['Hi=Bye'], + }; + expect(response['headers']).toEqual(expected); +}); + +test('sets headers with an array of cookies', () => { + const response = new Response(); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); + response.setHeaders({ + 'Access-Control-Allow-Origin': 'example.com', + 'Set-Cookie': ['Hi=Bye', 'Hello=World'], + }); + const expected = { + 'Access-Control-Allow-Origin': 'example.com', + 'Set-Cookie': ['Hi=Bye', 'Hello=World'], + }; + expect(response['headers']).toEqual(expected); +}); + +test('sets cookies with lower case set-cookie', () => { + const response = new Response(); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); + response.setHeaders({ + 'Access-Control-Allow-Origin': 'example.com', + 'set-cookie': ['Hi=Bye', 'Hello=World'], + }); + const expected = { + 'Access-Control-Allow-Origin': 'example.com', + 'Set-Cookie': ['Hi=Bye', 'Hello=World'], + }; + expect(response['headers']).toEqual(expected); +}); + test('appends a new header correctly', () => { const response = new Response(); - expect(response['headers']).toEqual({}); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); response.appendHeader('Access-Control-Allow-Origin', 'dkundel.com'); expect(response['headers']).toEqual({ 'Access-Control-Allow-Origin': 'dkundel.com', + 'Set-Cookie': [], }); response.appendHeader('Content-Type', 'application/json'); expect(response['headers']).toEqual({ 'Access-Control-Allow-Origin': 'dkundel.com', 'Content-Type': 'application/json', + 'Set-Cookie': [], }); }); test('appends a header correctly with no existing one', () => { const response = new Response(); - expect(response['headers']).toEqual({}); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); // @ts-ignore response['headers'] = undefined; response.appendHeader('Access-Control-Allow-Origin', 'dkundel.com'); expect(response['headers']).toEqual({ 'Access-Control-Allow-Origin': 'dkundel.com', + 'Set-Cookie': [], + }); +}); + +test('appends multi value headers', () => { + const response = new Response(); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); + response.appendHeader('Access-Control-Allow-Origin', 'dkundel.com'); + response.appendHeader('Access-Control-Allow-Origin', 'philna.sh'); + response.appendHeader('Access-Control-Allow-Methods', 'GET'); + response.appendHeader('Access-Control-Allow-Methods', 'DELETE'); + response.appendHeader('Access-Control-Allow-Methods', ['PUT', 'POST']); + expect(response['headers']).toEqual({ + 'Access-Control-Allow-Origin': ['dkundel.com', 'philna.sh'], + 'Access-Control-Allow-Methods': ['GET', 'DELETE', 'PUT', 'POST'], + 'Set-Cookie': [], + }); +}); + +test('sets a single cookie correctly', () => { + const response = new Response(); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); + response.setCookie('name', 'value'); + expect(response['headers']).toEqual({ + 'Set-Cookie': ['name=value'], + }); +}); + +test('sets a cookie with attributes', () => { + const response = new Response(); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); + response.setCookie('Hello', 'World', [ + 'HttpOnly', + 'Secure', + 'SameSite=Strict', + 'Max-Age=86400', + ]); + expect(response['headers']).toEqual({ + 'Set-Cookie': ['Hello=World;HttpOnly;Secure;SameSite=Strict;Max-Age=86400'], + }); +}); + +test('removes a cookie', () => { + const response = new Response(); + expect(response['headers']).toEqual({ + 'Set-Cookie': [], + }); + response.setCookie('Hello', 'World', [ + 'HttpOnly', + 'Secure', + 'SameSite=Strict', + 'Max-Age=86400', + ]); + response.removeCookie('Hello'); + expect(response['headers']).toEqual({ + 'Set-Cookie': ['Hello=;Max-Age=0'], }); }); @@ -107,6 +229,16 @@ test('appendHeader returns the response', () => { expect(response.appendHeader('X-Test', 'Hello')).toBe(response); }); +test('setCookie returns the response', () => { + const response = new Response(); + expect(response.setCookie('name', 'value')).toBe(response); +}); + +test('removeCookie returns the response', () => { + const response = new Response(); + expect(response.removeCookie('name')).toBe(response); +}); + test('calls express response correctly', () => { const mockRes = { status: jest.fn(), @@ -121,7 +253,10 @@ test('calls express response correctly', () => { expect(mockRes.send).toHaveBeenCalledWith(`I'm a teapot!`); expect(mockRes.status).toHaveBeenCalledWith(418); - expect(mockRes.set).toHaveBeenCalledWith({ 'Content-Type': 'text/plain' }); + expect(mockRes.set).toHaveBeenCalledWith({ + 'Content-Type': 'text/plain', + 'Set-Cookie': [], + }); }); test('serializes a response', () => { @@ -134,7 +269,10 @@ test('serializes a response', () => { expect(serialized.body).toEqual("I'm a teapot!"); expect(serialized.statusCode).toEqual(418); - expect(serialized.headers).toEqual({ 'Content-Type': 'text/plain' }); + expect(serialized.headers).toEqual({ + 'Content-Type': 'text/plain', + 'Set-Cookie': [], + }); }); test('serializes a response with content type set to application/json', () => { @@ -149,5 +287,8 @@ test('serializes a response with content type set to application/json', () => { JSON.stringify({ url: 'https://dkundel.com' }) ); expect(serialized.statusCode).toEqual(200); - expect(serialized.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(serialized.headers).toEqual({ + 'Content-Type': 'application/json', + 'Set-Cookie': [], + }); }); diff --git a/packages/runtime-handler/__tests__/dev-runtime/route.test.ts b/packages/runtime-handler/__tests__/dev-runtime/route.test.ts index 45fb2e78..472ccb53 100644 --- a/packages/runtime-handler/__tests__/dev-runtime/route.test.ts +++ b/packages/runtime-handler/__tests__/dev-runtime/route.test.ts @@ -22,6 +22,7 @@ import { Response } from '../../src/dev-runtime/internal/response'; import { constructContext, constructEvent, + constructHeaders, constructGlobalScope, handleError, handleSuccess, @@ -37,16 +38,21 @@ import { cleanUpStackTrace } from '../../src/dev-runtime/utils/stack-trace/clean const { VoiceResponse, MessagingResponse, FaxResponse } = twiml; -const mockResponse = (new MockResponse() as unknown) as ExpressResponse; +const mockResponse = new MockResponse() as unknown as ExpressResponse; mockResponse.type = jest.fn(() => mockResponse); -function asExpressRequest(req: { query?: {}; body?: {} }): ExpressRequest { - return (req as unknown) as ExpressRequest; +function asExpressRequest(req: { + query?: {}; + body?: {}; + rawHeaders?: string[]; + cookies?: {}; +}): ExpressRequest { + return req as unknown as ExpressRequest; } describe('handleError function', () => { test('returns string error', () => { - const mockRequest = (new MockRequest() as unknown) as ExpressRequest; + const mockRequest = new MockRequest() as unknown as ExpressRequest; mockRequest['useragent'] = { isDesktop: true, isMobile: false, @@ -59,7 +65,7 @@ describe('handleError function', () => { }); test('handles objects as error argument', () => { - const mockRequest = (new MockRequest() as unknown) as ExpressRequest; + const mockRequest = new MockRequest() as unknown as ExpressRequest; mockRequest['useragent'] = { isDesktop: true, isMobile: false, @@ -72,7 +78,7 @@ describe('handleError function', () => { }); test('wraps error object for desktop requests', () => { - const mockRequest = (new MockRequest() as unknown) as ExpressRequest; + const mockRequest = new MockRequest() as unknown as ExpressRequest; mockRequest['useragent'] = { isDesktop: true, isMobile: false, @@ -85,7 +91,7 @@ describe('handleError function', () => { }); test('wraps error object for mobile requests', () => { - const mockRequest = (new MockRequest() as unknown) as ExpressRequest; + const mockRequest = new MockRequest() as unknown as ExpressRequest; mockRequest['useragent'] = { isDesktop: false, isMobile: true, @@ -98,7 +104,7 @@ describe('handleError function', () => { }); test('returns string version of error for other requests', () => { - const mockRequest = (new MockRequest() as unknown) as ExpressRequest; + const mockRequest = new MockRequest() as unknown as ExpressRequest; mockRequest['useragent'] = { isDesktop: false, isMobile: false, @@ -128,7 +134,11 @@ describe('constructEvent function', () => { }, }) ); - expect(event).toEqual({ Body: 'Hello', index: 5 }); + expect(event).toEqual({ + Body: 'Hello', + index: 5, + request: { headers: {}, cookies: {} }, + }); }); test('overrides query with body', () => { @@ -143,7 +153,28 @@ describe('constructEvent function', () => { }, }) ); - expect(event).toEqual({ Body: 'Bye', From: '+123456789' }); + expect(event).toEqual({ + Body: 'Bye', + From: '+123456789', + request: { headers: {}, cookies: {} }, + }); + }); + + test('does not override request', () => { + const event = constructEvent( + asExpressRequest({ + body: { + Body: 'Bye', + }, + query: { + request: 'Hello', + }, + }) + ); + expect(event).toEqual({ + Body: 'Bye', + request: 'Hello', + }); }); test('handles empty body', () => { @@ -156,7 +187,11 @@ describe('constructEvent function', () => { }, }) ); - expect(event).toEqual({ Body: 'Hello', From: '+123456789' }); + expect(event).toEqual({ + Body: 'Hello', + From: '+123456789', + request: { headers: {}, cookies: {} }, + }); }); test('handles empty query', () => { @@ -169,7 +204,11 @@ describe('constructEvent function', () => { query: {}, }) ); - expect(event).toEqual({ Body: 'Hello', From: '+123456789' }); + expect(event).toEqual({ + Body: 'Hello', + From: '+123456789', + request: { headers: {}, cookies: {} }, + }); }); test('handles both empty', () => { @@ -179,7 +218,135 @@ describe('constructEvent function', () => { query: {}, }) ); - expect(event).toEqual({}); + expect(event).toEqual({ request: { headers: {}, cookies: {} } }); + }); + + test('adds headers to request property', () => { + const event = constructEvent( + asExpressRequest({ + body: {}, + query: {}, + rawHeaders: ['x-test', 'example'], + }) + ); + expect(event).toEqual({ + request: { headers: { 'x-test': 'example' }, cookies: {} }, + }); + }); + + test('adds cookies to request property', () => { + const event = constructEvent( + asExpressRequest({ + body: {}, + query: {}, + rawHeaders: [], + cookies: { flavour: 'choc chip' }, + }) + ); + expect(event).toEqual({ + request: { headers: {}, cookies: { flavour: 'choc chip' } }, + }); + }); +}); + +describe('constructHeaders function', () => { + test('handles undefined', () => { + const headers = constructHeaders(); + expect(headers).toEqual({}); + }); + test('handles an empty array', () => { + const headers = constructHeaders([]); + expect(headers).toEqual({}); + }); + test('it handles a single header value', () => { + const headers = constructHeaders(['x-test', 'hello, world']); + expect(headers).toEqual({ 'x-test': 'hello, world' }); + }); + test('it handles a duplicated header value', () => { + const headers = constructHeaders([ + 'x-test', + 'hello, world', + 'x-test', + 'ahoy', + ]); + expect(headers).toEqual({ 'x-test': ['hello, world', 'ahoy'] }); + }); + test('it handles a duplicated header value multiple times', () => { + const headers = constructHeaders([ + 'x-test', + 'hello, world', + 'x-test', + 'ahoy', + 'x-test', + 'third', + ]); + expect(headers).toEqual({ 'x-test': ['hello, world', 'ahoy', 'third'] }); + }); + test('it strips restricted headers', () => { + const headers = constructHeaders([ + 'x-test', + 'hello, world', + 'I-Twilio-Test', + 'nope', + ]); + expect(headers).toEqual({ 'x-test': 'hello, world' }); + }); + test('it lowercases and combines header names', () => { + const headers = constructHeaders([ + 'X-Test', + 'hello, world', + 'X-test', + 'ahoy', + 'x-test', + 'third', + ]); + expect(headers).toEqual({ + 'x-test': ['hello, world', 'ahoy', 'third'], + }); + }); + + test("it doesn't pass on restricted headers", () => { + const headers = constructHeaders([ + 'I-Twilio-Example', + 'example', + 'I-T-Example', + 'example', + 'OT-Example', + 'example', + 'x-amz-example', + 'example', + 'via', + 'example', + 'Referer', + 'example.com', + 'transfer-encoding', + 'example', + 'proxy-authorization', + 'example', + 'proxy-authenticate', + 'example', + 'x-forwarded-example', + 'example', + 'x-real-ip', + 'example', + 'connection', + 'example', + 'proxy-connection', + 'example', + 'expect', + 'example', + 'trailer', + 'example', + 'upgrade', + 'example', + 'x-accel-example', + 'example', + 'x-actual-header', + 'this works', + ]); + expect(headers).toEqual({ + 'x-actual-header': 'this works', + }); }); }); @@ -358,6 +525,7 @@ describe('handleSuccess function', () => { expect(mockResponse.send).toHaveBeenCalledWith({ data: 'Something' }); expect(mockResponse.set).toHaveBeenCalledWith({ 'Content-Type': 'application/json', + 'Set-Cookie': [], }); expect(mockResponse.type).not.toHaveBeenCalled(); }); diff --git a/packages/runtime-handler/package.json b/packages/runtime-handler/package.json index 78bcd2e3..03cfbeb2 100644 --- a/packages/runtime-handler/package.json +++ b/packages/runtime-handler/package.json @@ -43,6 +43,7 @@ }, "devDependencies": { "@types/common-tags": "^1.8.0", + "@types/cookie-parser": "^1.4.2", "@types/debug": "^4.1.4", "@types/express-useragent": "^0.2.21", "@types/jest": "^26.0.24", @@ -60,10 +61,11 @@ "url": "https://github.com/twilio-labs/serverless-toolkit/issues" }, "dependencies": { - "@twilio-labs/serverless-runtime-types": "^2.1.1", + "@twilio-labs/serverless-runtime-types": "2.2.0-rc.0", "@types/express": "4.17.7", "chalk": "^4.1.1", "common-tags": "^1.8.0", + "cookie-parser": "^1.4.5", "debug": "^3.1.0", "express": "^4.16.3", "express-useragent": "^1.0.13", diff --git a/packages/runtime-handler/src/dev-runtime/checks/restricted-headers.ts b/packages/runtime-handler/src/dev-runtime/checks/restricted-headers.ts new file mode 100644 index 00000000..e3a5fbd4 --- /dev/null +++ b/packages/runtime-handler/src/dev-runtime/checks/restricted-headers.ts @@ -0,0 +1,22 @@ +export const restrictedHeaderPrefixes = [ + 'i-twilio-', + 'i-t-', + 'ot-', + 'x-amz', + 'x-forwarded-', + 'x-accel-', +]; + +export const restrictedHeaderExactMatches = [ + 'via', + 'referer', + 'transfer-encoding', + 'proxy-authorization', + 'proxy-authenticate', + 'x-real-ip', + 'connection', + 'proxy-connection', + 'expect', + 'trailer', + 'upgrade', +]; diff --git a/packages/runtime-handler/src/dev-runtime/internal/functionRunner.ts b/packages/runtime-handler/src/dev-runtime/internal/functionRunner.ts index 59db16b1..10a7d944 100644 --- a/packages/runtime-handler/src/dev-runtime/internal/functionRunner.ts +++ b/packages/runtime-handler/src/dev-runtime/internal/functionRunner.ts @@ -6,7 +6,7 @@ import { constructGlobalScope, isTwiml, } from '../route'; -import { ServerConfig } from '../types'; +import { ServerConfig, Headers } from '../types'; import { Response } from './response'; import { setRoutes } from './route-cache'; @@ -16,7 +16,7 @@ const sendDebugMessage = (debugMessage: string, ...debugArgs: any) => { export type Reply = { body?: string | number | boolean | object; - headers?: { [key: string]: number | string }; + headers?: Headers; statusCode: number; }; diff --git a/packages/runtime-handler/src/dev-runtime/internal/response.ts b/packages/runtime-handler/src/dev-runtime/internal/response.ts index d341d525..20ec8285 100644 --- a/packages/runtime-handler/src/dev-runtime/internal/response.ts +++ b/packages/runtime-handler/src/dev-runtime/internal/response.ts @@ -1,8 +1,10 @@ import { TwilioResponse } from '@twilio-labs/serverless-runtime-types/types'; +import { Headers, HeaderValue } from '../types'; import { Response as ExpressResponse } from 'express'; import debug from '../utils/debug'; const log = debug('twilio-runtime-handler:dev:response'); +const COOKIE_HEADER = 'Set-Cookie'; type ResponseOptions = { headers?: Headers; @@ -10,11 +12,6 @@ type ResponseOptions = { body?: object | string; }; -type HeaderValue = number | string; -type Headers = { - [key: string]: HeaderValue; -}; - export class Response implements TwilioResponse { private body: null | any; private statusCode: number; @@ -34,6 +31,15 @@ export class Response implements TwilioResponse { if (options && options.headers) { this.headers = options.headers; } + + // if Set-Cookie is not already in the headers, then add it as an empty list + const cookieHeader = this.headers[COOKIE_HEADER]; + if (!(COOKIE_HEADER in this.headers)) { + this.headers[COOKIE_HEADER] = []; + } + if (!Array.isArray(cookieHeader) && typeof cookieHeader !== 'undefined') { + this.headers[COOKIE_HEADER] = [cookieHeader]; + } } setStatusCode(statusCode: number): Response { @@ -53,14 +59,65 @@ export class Response implements TwilioResponse { if (typeof headersObject !== 'object') { return this; } - this.headers = headersObject; + this.headers = {}; + for (const header in headersObject) { + this.appendHeader(header, headersObject[header]); + } + return this; } appendHeader(key: string, value: HeaderValue): Response { log('Appending header for %s', key, value); this.headers = this.headers || {}; - this.headers[key] = value; + let newHeaderValue: HeaderValue = []; + if (key.toLowerCase() === COOKIE_HEADER.toLowerCase()) { + const existingValue = this.headers[COOKIE_HEADER]; + if (existingValue) { + newHeaderValue = [existingValue, value].flat(); + if (newHeaderValue) { + this.headers[COOKIE_HEADER] = newHeaderValue; + } + } else { + this.headers[COOKIE_HEADER] = Array.isArray(value) ? value: [value]; + } + } else { + const existingValue = this.headers[key]; + if (existingValue) { + newHeaderValue = [existingValue, value].flat(); + if (newHeaderValue) { + this.headers[key] = newHeaderValue; + } + } else { + this.headers[key] = value; + } + } + if (!(COOKIE_HEADER in this.headers)) { + this.headers[COOKIE_HEADER] = []; + } + return this; + } + + setCookie(key: string, value: string, attributes: string[] = []): Response { + log('Setting cookie %s=%s', key, value); + const cookie = + `${key}=${value}` + + (attributes.length > 0 ? `;${attributes.join(';')}` : ''); + this.appendHeader(COOKIE_HEADER, cookie); + return this; + } + + removeCookie(key: string): Response { + log('Removing cookie %s', key); + let cookieHeader = this.headers[COOKIE_HEADER]; + if (!Array.isArray(cookieHeader)) { + cookieHeader = [cookieHeader]; + } + const newCookies = cookieHeader.filter( + (cookie) => typeof cookie === 'string' && !cookie.startsWith(`${key}=`) + ); + newCookies.push(`${key}=;Max-Age=0`); + this.headers[COOKIE_HEADER] = newCookies; return this; } diff --git a/packages/runtime-handler/src/dev-runtime/route.ts b/packages/runtime-handler/src/dev-runtime/route.ts index 66dccf2c..008329b8 100644 --- a/packages/runtime-handler/src/dev-runtime/route.ts +++ b/packages/runtime-handler/src/dev-runtime/route.ts @@ -1,6 +1,7 @@ import { Context, ServerlessCallback, + ServerlessEventObject, ServerlessFunctionSignature, TwilioClient, TwilioClientOptions, @@ -18,6 +19,10 @@ import { join, resolve } from 'path'; import { deserializeError } from 'serialize-error'; import { checkForValidAccountSid } from './checks/check-account-sid'; import { checkForValidAuthToken } from './checks/check-auth-token'; +import { + restrictedHeaderExactMatches, + restrictedHeaderPrefixes, +} from './checks/restricted-headers'; import { Reply } from './internal/functionRunner'; import { Response } from './internal/response'; import * as Runtime from './internal/runtime'; @@ -37,8 +42,53 @@ const RUNNER_PATH = let twilio: TwilioPackage; -export function constructEvent(req: ExpressRequest): T { - return { ...req.query, ...req.body }; +type Headers = { + [key: string]: string | string[]; +}; +type Cookies = { + [key: string]: string; +}; + +export function constructHeaders(rawHeaders?: string[]): Headers { + if (rawHeaders && rawHeaders.length > 0) { + const headers: Headers = {}; + for (let i = 0, len = rawHeaders.length; i < len; i += 2) { + const headerName = rawHeaders[i].toLowerCase(); + if ( + restrictedHeaderExactMatches.some( + (headerType) => headerName === headerType + ) || + restrictedHeaderPrefixes.some((headerType) => + headerName.startsWith(headerType) + ) + ) { + continue; + } + const currentHeader = headers[headerName]; + if (!currentHeader) { + headers[headerName] = rawHeaders[i + 1]; + } else if (typeof currentHeader === 'string') { + headers[headerName] = [currentHeader, rawHeaders[i + 1]]; + } else { + headers[headerName] = [...currentHeader, rawHeaders[i + 1]]; + } + } + return headers; + } + return {}; +} + +export function constructEvent( + req: ExpressRequest +): T { + return { + request: { + headers: constructHeaders(req.rawHeaders), + cookies: (req.cookies || {}) as Cookies, + }, + ...req.query, + ...req.body, + }; } export function augmentContextWithOptionals( diff --git a/packages/runtime-handler/src/dev-runtime/server.ts b/packages/runtime-handler/src/dev-runtime/server.ts index d4416908..9f847299 100644 --- a/packages/runtime-handler/src/dev-runtime/server.ts +++ b/packages/runtime-handler/src/dev-runtime/server.ts @@ -8,6 +8,7 @@ import express, { Response as ExpressResponse, } from 'express'; import userAgentMiddleware from 'express-useragent'; +import cookieParser from 'cookie-parser'; import nocache from 'nocache'; import { createLogger } from './internal/request-logger'; import { setRoutes } from './internal/route-cache'; @@ -109,6 +110,7 @@ export class LocalDevelopmentServer extends EventEmitter { }) ); app.use(bodyParser.json({ limit: DEFAULT_BODY_SIZE_LAMBDA })); + app.use(cookieParser()); app.get('/favicon.ico', (req, res) => { res.redirect( 'https://www.twilio.com/marketing/bundles/marketing/img/favicons/favicon.ico' diff --git a/packages/runtime-handler/src/dev-runtime/types.ts b/packages/runtime-handler/src/dev-runtime/types.ts index 3907f8b1..0fd9f7e1 100644 --- a/packages/runtime-handler/src/dev-runtime/types.ts +++ b/packages/runtime-handler/src/dev-runtime/types.ts @@ -71,3 +71,8 @@ export type LoggerInstance = { error(msg: string, title?: string): void; log(msg: string, level: number): void; }; + +export type HeaderValue = number | string | (string | number)[]; +export type Headers = { + [key: string]: HeaderValue; +}; diff --git a/packages/serverless-runtime-types/CHANGELOG.md b/packages/serverless-runtime-types/CHANGELOG.md index 864961b5..af88c8cf 100644 --- a/packages/serverless-runtime-types/CHANGELOG.md +++ b/packages/serverless-runtime-types/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.2.0-rc.0](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio-labs/serverless-runtime-types@2.1.0...@twilio-labs/serverless-runtime-types@2.2.0-rc.0) (2021-07-14) + + +### Features + +* **runtime-types:** add cookie/header support for types ([#297](https://github.com/twilio-labs/serverless-toolkit/issues/297)) ([e04fbcb](https://github.com/twilio-labs/serverless-toolkit/commit/e04fbcbd89fdda2fe3c55928df5c630cd9989fa9)) + + ## [2.1.1](https://github.com/twilio-labs/serverless-toolkit/compare/@twilio-labs/serverless-runtime-types@2.1.0...@twilio-labs/serverless-runtime-types@2.1.1) (2021-07-19) diff --git a/packages/serverless-runtime-types/example/functions/demo.js b/packages/serverless-runtime-types/example/functions/demo.js index cb2cef1f..4a8b327a 100644 --- a/packages/serverless-runtime-types/example/functions/demo.js +++ b/packages/serverless-runtime-types/example/functions/demo.js @@ -2,11 +2,12 @@ /** * @param {import('@twilio-labs/serverless-runtime-types').Context} context - * @param {{}} event + * @param {import('@twilio-labs/serverless-runtime-types').ServerlessEventObject<{}, {}, { token: string }>} event * @param {import('@twilio-labs/serverless-runtime-types').ServerlessCallback} callback */ -exports.handler = function(context, event, callback) { +exports.handler = function (context, event, callback) { let twiml = new Twilio.twiml.MessagingResponse(); + console.log(event.cookies.token); twiml.message('Hello World'); callback(null, twiml); }; diff --git a/packages/serverless-runtime-types/package.json b/packages/serverless-runtime-types/package.json index a9ddf644..d73a353a 100644 --- a/packages/serverless-runtime-types/package.json +++ b/packages/serverless-runtime-types/package.json @@ -1,6 +1,6 @@ { "name": "@twilio-labs/serverless-runtime-types", - "version": "2.1.2", + "version": "2.2.0-rc.0", "description": "TypeScript definitions to define globals for the Twilio Serverless runtime", "main": "index.js", "types": "index.d.ts", diff --git a/packages/serverless-runtime-types/types.d.ts b/packages/serverless-runtime-types/types.d.ts index 81db91f2..ea13f894 100644 --- a/packages/serverless-runtime-types/types.d.ts +++ b/packages/serverless-runtime-types/types.d.ts @@ -22,10 +22,12 @@ export type AssetResourceMap = { }; export interface TwilioResponse { - setStatusCode(code: number): void; - setBody(body: string | object): void; - appendHeader(key: string, value: string): void; - setHeaders(headers: { [key: string]: string }): void; + setStatusCode(code: number): TwilioResponse; + setBody(body: string | object): TwilioResponse; + appendHeader(key: string, value: string): TwilioResponse; + setHeaders(headers: { [key: string]: string }): TwilioResponse; + setCookie(key: string, value: string, attributes?: string[]): TwilioResponse; + removeCookie(key: string): TwilioResponse; } export type RuntimeSyncClientOptions = TwilioClientOptions & { @@ -56,9 +58,20 @@ export type ServerlessCallback = ( payload?: object | string | number | boolean ) => void; +export type ServerlessEventObject< + RequestBodyAndQuery = {}, + Headers = {}, + Cookies = {} +> = { + request: { + cookies: Cookies; + headers: Headers; + }; +} & RequestBodyAndQuery; + export type ServerlessFunctionSignature< T extends EnvironmentVariables = {}, - U extends {} = {} + U extends ServerlessEventObject = { request: { cookies: {}; headers: {} } } > = ( context: Context, event: U, diff --git a/packages/twilio-run/CHANGELOG.md b/packages/twilio-run/CHANGELOG.md index 5e877a79..587d3e8d 100644 --- a/packages/twilio-run/CHANGELOG.md +++ b/packages/twilio-run/CHANGELOG.md @@ -3,7 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [3.3.0-beta.0](https://github.com/twilio-labs/serverless-toolkit/compare/twilio-run@3.2.2...twilio-run@3.3.0-beta.0) (2021-09-25) +## [3.1.2-rc.0](https://github.com/twilio-labs/serverless-toolkit/compare/twilio-run@3.1.1...twilio-run@3.1.2-rc.0) (2021-07-14) + +**Note:** Version bump only for package twilio-run + + +## [3.3.0-beta.0](https://github.com/twilio-labs/serverless-toolkit/compare/twilio-run@3.2.2...twilio-run@3.3.0-beta.0) (2021-09-25) ### Bug Fixes