UNPKG

@webscale-networks/cloudedge-handlers

Version:

Webscale Networks CloudEDGE Handlers for cloud-agnostic edge function execution

608 lines (547 loc) 21 kB
const _ = require('lodash'); const fs = require('fs'); const events = require('./events.js').events; const execution_context = require('../../../execution_context/lambda/src/index.js'); const serverless_context = require('../../../execution_context/lambda/src/serverless.js'); const handlers = require('../../../execution_context/utils/src/handlers.js'); // JSON paths to the headers in a request. const headerPaths = [ 'headers', 'origin.custom.customHeaders', ] // All headers AWS disallows a handler from adding, updating or deleting. const disallowedHeaders = [ 'connection', 'expect', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'trailer', 'upgrade', 'x-accel-buffering', 'x-accel-charset', 'x-accel-limit-rate', 'x-accel-redirect', 'x-cache', 'x-forwarded-proto', 'x-real-ip', 'wce-orq-handlers', 'wce-orp-handlers', ]; // All header name prefixes AWS disallows a handler from adding, updating or // deleting. const disallowedPatterns = [ 'x-amz-cf-', 'x-edge-', ]; // All request headers that are read-only to handlers. const readOnlyRequestHeaders = [ 'accept-encoding', 'content-length', 'if-modified-since', 'if-none-match', 'if-range', 'if-unmodified-since', 'transfer-encoding', 'via', ]; // All response headers that are read-only to handlers. const readOnlyResponseHeaders = [ 'transfer-encoding', 'via', ]; // Runs a handler function from within Webscale's handler execution context. async function call(module, funcName, type, underTest) { const run = jest.spyOn(handlers, 'run'); const read = jest.spyOn(fs, 'readFileSync'); let result = {}; if (type == 'handleRequest') { run.mockImplementation((wReq, _wResp, _handles) => { return underTest(wReq); }); read.mockImplementation(() => { return `{"requestHandlers": ["${module}.${funcName}"]}`; }); result = execution_context.handler( events['handleRequest'], null, ); } else if (type == 'handleResponse') { run.mockImplementation((wReq2, wResp, _handles) => { return underTest(wReq2, wResp); }); read.mockImplementation(() => { return `{"responseHandlers": ["${module}.${funcName}"]}`; }); result = execution_context.handler( events['handleResponse'], null, ); } else if (type == 'compute') { run.mockImplementation((wReq3, _handles) => { return underTest(wReq3); }); read.mockImplementation(() => { return `{"serverlessHandlers": ["${module}.${funcName}"]}`; }); result = serverless_context.handler( events['compute']['request'], null, ); } run.mockRestore(); read.mockRestore(); return result; }; function toLowerCase(headersObj) { if (!headersObj) { return headersObj; } return Object.keys(headersObj).reduce((accumulator, key) => { const normalizedValue = headersObj[key].map((headerValue) => { return { [headerValue['key'].toLowerCase()]: headerValue['value'] }; }); accumulator[key.toLowerCase()] = normalizedValue; return accumulator; }, {}); }; // Additional Jest expectation methods to improve readability of tests. expect.extend({ // Passes if all the given headers and header name prefixes are excluded // from 'received'. toExclude(received, headers, patterns) { for (const header of headers) { if (received[header]) { return { message: () => `${header} was included`, pass: false, }; } } for (const pattern of patterns) { for (const header in received) { if (header.startsWith(pattern)) { return { message: () => `headers cannot start with ${pattern}`, pass: false, }; } } } return { message: () => 'no disallowed headers were included', pass: true, }; }, // Passes if all the header values in 'headers' match the header value in // 'received' and 'expected'. toBeUnmodified(received, expected, headers) { const normalizedReceived = toLowerCase(received); const normalizedExpected = toLowerCase(expected); for (const header of headers) { const receivedHeader = _.get(normalizedReceived, `[${header.toLowerCase()}]`); const expectedHeader = _.get(normalizedExpected, `[${header.toLowerCase()}]`); if (JSON.stringify(receivedHeader) != JSON.stringify(expectedHeader)) { return { message: () => `header ${header} cannot be modified, ` + `expected ${JSON.stringify(expectedHeader)}, ` + `got ${JSON.stringify(receivedHeader)}`, pass: false, }; } } return { message: () => 'headers were unmodified', pass: true, }; } }); // Convenience function for converting AWS header object into a generic object. headersToObject = function(headers) { const result = {}; for (const header in headers) { result[header.toLowerCase()] = headers[header][0]['value']; } return result; }; /* These tests assert that all known AWS Lambda@Edge restrictions are obeyed by every handler function in the manifest.json. They were obtained from the following documentation: - https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/edge-functions-restrictions.html - https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-generating-http-responses-in-requests.html */ // AWS does not allow certain headers to be added. cannotAddDisallowedHeaders = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); if (result.status) { return; } for (headerPath of headerPaths) { const headers = _.get(result, headerPath); if (headers) { expect(headersToObject(headers)).toExclude(disallowedHeaders, disallowedPatterns); } } }; // AWS does not allow specific headers to be updated. cannotModifyReadonlyRequestHeaders = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); const request = events['handleRequest'].Records[0].cf.request; if (result.status) { return; } for (headerPath of headerPaths) { const headers = _.get(result, headerPath); const expected = _.get(request, headerPath); expect(headers).toBeUnmodified(expected, readOnlyRequestHeaders); } }; // A response generated by an origin request handler cannot set the // Content-Length header. cannotSetContentLengthOnResponse = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); if (result.status) { expect(_.get(result, "headers['content-length']")).toBeUndefined(); } }; // AWS specifies query strings cannot include whitespace, the fragment character // or control characters. malformedQueryString = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); if (result.querystring) { const actualQuery = result.querystring; expect( actualQuery.includes(' ') && actualQuery.includes('#') ).toBe(false); } }; // AWS specifies query strings must be smaller than 8192 characters. tooLargeQueryString = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); const maxQueryLength = 8192; if (result.querystring) { const actualQuery = result.querystring; expect(actualQuery.length).toBeLessThan(maxQueryLength); } }; // AWS specifies URIs (including query strings) must be smaller than 8192 // characters. tooLargeUri = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); const maxLength = 8192; if (result.uri && result.querystring) { expect( (result.uri.length + result.querystring.length) ).toBeLessThan(maxLength); } }; // AWS specifies base64 encoded response bodies can be, at most, 1.33 MB. // Plain text bodies can be, at most, 1 MB. tooLargeResponseBody = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); if (result.body.data && result.body.encoding == 'base64') { expect(result.body.data.length).toBeLessThanOrEqual(1330000); } else if (result.body.data && result.body.encoding == 'text') { expect(result.body.data.length).toBeLessThanOrEqual(1000000); } }; // AWS does not allow specific headers from being updated. cannotModifyReadonlyResponseHeaders = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); const response = events['handleResponse'].Records[0].cf.response; for (headerPath of headerPaths) { const headers = _.get(result, headerPath); const expected = _.get(response, headerPath); expect(headers).toBeUnmodified(expected, readOnlyResponseHeaders); } }; // AWS requires a status code is set on all responses. missingStatusCode = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); const converted = Number(result.status); expect(converted).toEqual(expect.any(Number)); }; // AWS returns an error if a status code is outside of 200 - 599. outOfRangeStatusCode = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); expect(Number(result.status)).toBeGreaterThanOrEqual(200); expect(Number(result.status)).toBeLessThanOrEqual(599); }; // AWS returns an error if the status code is 204 but a response body is // specified. includedBodyForNoContentResponse = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); if (result.status == "204") { expect(result.body.data.length).toEqual(0); } }; // AWS returns an error if the request or response body encoding is incorrect. invalidBodyEncoding = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); if (type == 'handleRequest') { if (result.body && result.body.encoding == 'base64') { expect( Buffer.from(result.body.data, 'base64').toString('base64'), ).toEqual(result.body.data); } } else if (type == 'handleResponse') { if (result.body && result.bodyEncoding == 'base64') { expect( Buffer.from(result.body, 'base64').toString('base64'), ).toEqual(result.body); } } }; // The only valid body encodings are 'base64' and 'text'. unknownBodyEncoding = async function(module, funcName, type, underTest) { const result = await call(module, funcName, type, underTest); if (type == 'handleRequest' && _.get(result, 'body.encoding')) { expect(['base64', 'text']).toContain(result.body.encoding); } else if (type == 'handleResponse' && result.bodyEncoding) { expect(['base64', 'text']).toContain(result.bodyEncoding); } else if (type == 'compute' && result.bodyEncoding) { expect(['base64', 'text']).toContain(result.bodyEncoding); } }; // Uri must start with a '/'. uriStartsWith = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (result && result.uri) { expect(result.uri.startsWith('/')).toBe(true); } } // Origin domain names must not be empty, longer than 253 chars, include a ':', // or be an IP address. validateOriginDomainName = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (_.get(result, 'origin.custom')){ expect(result.origin.custom.domainName.length).toBeGreaterThan(0); expect(result.origin.custom.domainName.length).toBeLessThan(254); expect(result.origin.custom.domainName.includes(':')).toBe(false); expect(result.origin.custom.domainName).not .toMatch(/(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/); } } // Origin path must start with a '/', cannot end with a '/', and must have a // maximum length of 255 characters. validateOriginPath = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (_.get(result, 'origin.custom.path')){ expect(result.origin.custom.path.startsWith('/')).toBe(true); expect(result.origin.custom.path.endsWith('/')).toBe(false); expect(result.origin.custom.path.length).toBeLessThan(256); } } // Keepalive timeout must be a number between 1-60, inclusive. validateKeepaliveTimeout = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (_.get(result, 'origin.custom')){ const keepaliveTimeout = result.origin.custom.keepaliveTimeout; expect(keepaliveTimeout).toBeGreaterThanOrEqual(1); expect(keepaliveTimeout).toBeLessThanOrEqual(60); } } // Port must be between 1024 and 655535 validatePort = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (_.get(result, 'origin.custom')){ const port = result.origin.custom.port; expect((port >= 1024 && port <= 65535) || port == 80 || port == 443) .toBe(true); } } // Protocol must be 'http' or 'https'. validateProtocol = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (_.get(result, 'origin.custom.protocol')){ const VALID_PROTOCOLS = ['http', 'https']; expect(VALID_PROTOCOLS).toContain(result.origin.custom.protocol); } } // ReadTimeout must be a number from 4-60, inclusive. validateReadTimeout = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (_.get(result, 'origin.custom.readTimeout')){ expect(result.origin.custom.readTimeout).toBeGreaterThanOrEqual(4); expect(result.origin.custom.readTimeout).toBeLessThanOrEqual(60); } } // SSLProtocols must be one of TLSv1.2, TLSv1.1, TLSv1, or SSLv3. validateSSLProtocols = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (_.get(result, 'origin.custom.sslProtocols')){ const VALID_SSL_PROTOCOLS = ['TLSv1.2', 'TLSv1.1', 'TLSv1', 'SSLv3']; result.origin.custom.sslProtocols.forEach((protocol) => { expect(VALID_SSL_PROTOCOLS).toContain(protocol); }); } } // Request method cannot be changed. methodImmutable = async function(module, funcName, type, underTest){ const request = events['handleRequest'].Records[0].cf.request; const result = await call(module, funcName, type, underTest); if (result && result.method) { expect(result.method).toBe(request.method); } } // Peer address cannot be changed. peerAddressImmutable = async function(module, funcName, type, underTest){ const request = events['handleRequest'].Records[0].cf.request; const result = await call(module, funcName, type, underTest); if (result && result.clientIp) { expect(result.clientIp).toBe(request.clientIp); } } // Body truncated flag cannot be changed. bodyTruncatedImmutable = async function(module, funcName, type, underTest){ const request = events['handleRequest'].Records[0].cf.request; const result = await call(module, funcName, type, underTest); if (_.get(result, 'body.inputTruncated')) { expect(result.body.inputTruncated).toBe(request.body.inputTruncated); } } // AuthMethod must be set to 'origin-access-identity' or 'none'. Region is // required when set to 'origin-access-identity'. validateAuthMethod = async function(module, funcName, type, underTest){ const result = await call(module, funcName, type, underTest); if (_.get(result, 'origin.custom.authMethod')){ const authMethod = result.origin.custom.authMethod; VALID_AUTH_METHODS = ['none', 'origin-access-identity'] expect(VALID_AUTH_METHODS).toContain(authMethod); if (authMethod === 'origin-access-identity'){ expect(result.origin.custom.region).toBeTruthy(); } } } exports.tests = { 'handleRequest': [ { name: 'Cannot add disallowed headers', expectFunction: cannotAddDisallowedHeaders, }, { name: 'Cannot modify readonly headers', expectFunction: cannotModifyReadonlyRequestHeaders, }, { name: 'Cannot set Content-Length on responses generated by origin requests', expectFunction: cannotSetContentLengthOnResponse, }, { name: 'Malformed query string', expectFunction: malformedQueryString, }, { name: 'Query string is too large', expectFunction: tooLargeQueryString, }, { name: 'URI is too large', expectFunction: tooLargeUri, }, { name: 'Response body is too large', expectFunction: tooLargeResponseBody, }, { name: 'Invalid response body encoding', expectFunction: invalidBodyEncoding, }, { name: 'Body encoding is base64 or text', expectFunction: unknownBodyEncoding, }, { name: 'Keepalive timeout must be an number from 1-60, inclusive', expectFunction: validateKeepaliveTimeout, }, { name: 'Uri must begin with a /', expectFunction: uriStartsWith, }, { name: 'Origin domain name must not be empty, include a colon, be longer than 253 characters, or be an IP address', expectFunction: validateOriginDomainName, }, { name: 'Origin path must start with a /, cannot end with a /, and cannot be longer than 255 characters', expectFunction: validateOriginPath, }, { name: 'Port must be 80, 443, or a number in the range 1024-65535, inclusive', expectFunction: validatePort, }, { name: 'Protocol must be http or https', expectFunction: validateProtocol, }, { name: 'ReadTimeout must be a number from 4-60, inclusive', expectFunction: validateReadTimeout, }, { name: 'Valid SSL protocols include: TLSv1.2, TLSv1.1, TLSv1, or SSLv3', expectFunction: validateSSLProtocols, }, { name: 'Request method cannot be changed', expectFunction: methodImmutable, }, { name: 'Peer address cannot be changed', expectFunction: peerAddressImmutable, }, { name: 'Body truncated flag cannot be changed', expectFunction: bodyTruncatedImmutable, }, { name: 'Auth method must be either origin-access-identity or none. Region is required if set to origin-access-identity.', expectFunction: validateAuthMethod, } ], 'handleResponse': [ { name: 'Cannot add disallowed headers', expectFunction: cannotAddDisallowedHeaders, }, { name: 'Cannot modify readonly headers', expectFunction: cannotModifyReadonlyResponseHeaders, }, { name: 'Missing status code', expectFunction: missingStatusCode, }, { name: 'Status code out of range', expectFunction: outOfRangeStatusCode, }, { name: 'Body included in 204 response', expectFunction: includedBodyForNoContentResponse, }, { name: 'Invalid response body encoding', expectFunction: invalidBodyEncoding, }, { name: 'Body encoding is base64 or text', expectFunction: unknownBodyEncoding, } ], 'compute': [ { name: 'Missing status code', expectFunction: missingStatusCode, }, { name: 'Status code out of range', expectFunction: outOfRangeStatusCode, } ] }