@webscale-networks/cloudedge-handlers
Version:
Webscale Networks CloudEDGE Handlers for cloud-agnostic edge function execution
608 lines (547 loc) • 21 kB
JavaScript
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,
}
]
}