@mapbox/cloudfriend
Version:
Helper functions for assembling CloudFormation templates in JavaScript
474 lines (432 loc) • 16.4 kB
JavaScript
;
const crypto = require('crypto');
const redent = require('redent');
const Lambda = require('./lambda');
const merge = require('../merge');
const random = crypto.randomBytes(4).toString('hex');
function assertNodeVersion(runtime) {
if (runtime.match(/^nodejs[\d+]+.x$/)) {
const version = runtime.match(/\d+/)[0];
if (Number(version) < 18)
throw new Error(`Only nodejs runtimes >= 18 are supported for hookshot lambdas, received: '${runtime}'`);
} else {
throw new Error(`Only valid nodejs runtimes are supported for hookshot lambdas, received: '${runtime}'`);
}
}
/**
* The hookshot.Passthrough class defines resources that set up a single API Gateway
* endpoint that responds to `POST` and `OPTIONS` requests. You are expected to
* provide a Lambda function that will receive the request, and return some
* response to the caller.
*
* Note that in this case, your Lambda function will receive every HTTP `POST`
* request that arrives at the API Gateway URL that hookshot helped you create.
* You are responsible for any authentication that should be performed against
* incoming requests.
*
* Your Lambda function will receive an event object which includes the request
* method, headers, and body, as well as other data specific to the API Gateway
* endpoint created by hookshot. See [AWS documentation here](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format)
* for a full description of the incoming data.
*
* To work properly, **your lambda function must return a data object
* matching in a specific JSON format**. Again, see [AWS documentation for a full description](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format).
*
* Your API Gateway endpoint will allow cross-origin resource
* sharing (CORS) required by requests from any webpage. Preflight `OPTIONS`
* requests will receive a `200` response with CORS headers. And the response
* you return from your Lambda function will be modified to include CORS headers.
*
* The generated template's `Outputs` will include the URL for the API Gateway endpoint,
* and a random string that can be used as a shared secret.
*
* @name hookshot.Passthrough
*
* @param {String} Prefix - This will be used to prefix the set of CloudFormation
* resources created by this shortcut.
* @param {String} PassthroughTo - The logical name of the Lambda function that you
* have written which will receive a request and generate a response to provide
* to the caller.
* @param {String} [LoggingLevel='OFF'] - One of `OFF`, `INFO`, or `ERROR`. Logs are delivered
* to a CloudWatch Log Group named `API-Gateway-Execution-Logs_{rest-api-id}/hookshot`.
* @param {Boolean} [DataTraceEnabled=false] - Set to `true` to enable full request/response
* logging in the API's logs.
* @param {Boolean} [MetricsEnabled=false] - Set to `true` to enable additional metrics in CloudWatch.
* @param {String} [AccessLogFormat] - A single line format of the access logs of
* data, as specified by selected `$context` variables. The format must include at
* least `$context.requestId`. [See AWS documentation for details](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-apigateway-stage-accesslogsetting.html#cfn-apigateway-stage-accesslogsetting-format).
* @param {String|Object} [WebhookSecret] - A secret string to be used to verify
* payload signatures that are delivered to the endpoint. This is optional. If
* not specified, a string will be generated for you. Implementation of
* signature verification is up to the caller.
*
* @example
* const cf = require('@mapbox/cloudfriend');
*
* const myTemplate = {
* ...
* Resources: {
* MyLambdaFunction: {
* Type: 'AWS::Lambda::Function',
* Properties: { ... }
* }
* }
* };
*
* const webhook = new cf.shortcuts.hookshot.Passthrough({
* Prefix: 'Webhook',
* PassthroughTo: 'MyLambdaFunction'
* });
*
* module.exports = cf.merge(myTemplate, webhook);
*/
class Passthrough {
constructor(options) {
if (!options) throw new Error('Options required');
const {
Prefix,
PassthroughTo,
AccessLogFormat,
DataTraceEnabled = false,
MetricsEnabled = false,
WebhookSecret
} = options;
if (options.Runtime) assertNodeVersion(options.Runtime);
let {
LoggingLevel = 'OFF'
} = options;
const required = [Prefix, PassthroughTo];
if (required.some((variable) => !variable))
throw new Error('You must provide a Prefix, and PassthroughTo');
if (!['OFF', 'INFO', 'ERROR'].includes(LoggingLevel))
throw new Error('LoggingLevel must be one of OFF, INFO, or ERROR');
if (DataTraceEnabled)
LoggingLevel = LoggingLevel === 'OFF' ? 'ERROR' : LoggingLevel;
this.Prefix = Prefix;
this.PassthroughTo = PassthroughTo;
this.WebhookSecret = options.WebhookSecret;
const Resources = {
[`${Prefix}Api`]: {
Type: 'AWS::ApiGateway::RestApi',
Properties: {
Name: { 'Fn::Sub': '${AWS::StackName}-webhook' },
FailOnWarnings: true,
EndpointConfiguration: {
Types: ['REGIONAL']
}
}
},
[`${Prefix}Stage`]: {
Type: 'AWS::ApiGateway::Stage',
Properties: {
DeploymentId: { Ref: `${Prefix}Deployment${random}` },
StageName: 'hookshot',
RestApiId: { Ref: `${Prefix}Api` },
MethodSettings: [
{
HttpMethod: '*',
ResourcePath: '/*',
ThrottlingBurstLimit: 20,
ThrottlingRateLimit: 5,
LoggingLevel,
DataTraceEnabled,
MetricsEnabled
}
]
}
},
[`${Prefix}Deployment${random}`]: {
Type: 'AWS::ApiGateway::Deployment',
DependsOn: `${Prefix}Method`,
Properties: {
RestApiId: { Ref: `${Prefix}Api` },
StageName: 'unused'
}
},
[`${Prefix}Resource`]: {
Type: 'AWS::ApiGateway::Resource',
Properties: {
ParentId: { 'Fn::GetAtt': [`${Prefix}Api`, 'RootResourceId'] },
RestApiId: { Ref: `${Prefix}Api` },
PathPart: 'webhook'
}
},
[`${Prefix}OptionsMethod`]: {
Type: 'AWS::ApiGateway::Method',
Properties: {
RestApiId: { Ref: `${Prefix}Api` },
ResourceId: { Ref: `${Prefix}Resource` },
ApiKeyRequired: false,
AuthorizationType: 'NONE',
HttpMethod: 'OPTIONS',
Integration: {
Type: 'AWS_PROXY',
IntegrationHttpMethod: 'POST',
Uri: { 'Fn::Sub': `arn:aws:apigateway:\${AWS::Region}:lambda:path/2015-03-31/functions/\${${Prefix}Function.Arn}/invocations` }
}
}
},
[`${Prefix}Method`]: this.method(),
[`${Prefix}Permission`]: {
Type: 'AWS::Lambda::Permission',
Properties: {
FunctionName: { Ref: `${Prefix}Function` },
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: { 'Fn::Sub': `arn:aws:execute-api:\${AWS::Region}:\${AWS::AccountId}:\${${Prefix}Api}/*` }
}
}
};
if (!WebhookSecret) Resources[`${Prefix}Secret`] = {
Type: 'AWS::ApiGateway::ApiKey',
Properties: {
Enabled: false
}
};
if (AccessLogFormat) {
Resources[`${Prefix}AccessLogs`] = {
Type: 'AWS::Logs::LogGroup',
Properties: {
LogGroupName: { 'Fn::Sub': `\${AWS::StackName}-${Prefix}-access-logs` },
RetentionInDays: 14
}
};
Resources[`${Prefix}Stage`].Properties.AccessLogSetting = {
DestinationArn: { 'Fn::GetAtt': [`${Prefix}AccessLogs`, 'Arn'] },
Format: AccessLogFormat
};
}
const lambda = new Lambda(
Object.assign({}, options, {
LogicalName: `${Prefix}Function`,
FunctionName: { 'Fn::Sub': `\${AWS::StackName}-${Prefix}` },
Code: { ZipFile: this.code() },
Description: { 'Fn::Sub': 'Passthrough function for ${AWS::StackName}' },
Handler: 'index.lambda',
Timeout: 30,
MemorySize: 128,
Statement: [
{
Effect: 'Allow',
Action: 'lambda:InvokeFunction',
Resource: { 'Fn::GetAtt': [PassthroughTo, 'Arn'] }
}
]
})
);
this.Resources = merge({ Resources }, lambda).Resources;
this.Outputs = {
[`${Prefix}EndpointOutput`]: {
Description: 'The HTTPS endpoint used to send github webhooks',
Value: { 'Fn::Sub': `https://\${${Prefix}Api}.execute-api.\${AWS::Region}.amazonaws.com/hookshot/webhook` }
},
[`${Prefix}SecretOutput`]: {
Description: 'A secret key to give Github to use when signing webhook requests',
Value: WebhookSecret ? WebhookSecret : { Ref: `${Prefix}Secret` }
}
};
}
method() {
return {
Type: 'AWS::ApiGateway::Method',
Properties: {
RestApiId: { Ref: `${this.Prefix}Api` },
ResourceId: { Ref: `${this.Prefix}Resource` },
ApiKeyRequired: false,
AuthorizationType: 'NONE',
HttpMethod: 'POST',
Integration: {
Type: 'AWS_PROXY',
IntegrationHttpMethod: 'POST',
Uri: { 'Fn::Sub': `arn:aws:apigateway:\${AWS::Region}:lambda:path/2015-03-31/functions/\${${this.Prefix}Function.Arn}/invocations` }
}
}
};
}
code() {
return {
'Fn::Sub': redent(`
'use strict';
const { InvokeCommand, LambdaClient } = require('@aws-sdk/client-lambda');
const client = new LambdaClient();
module.exports.lambda = (event, context, callback) => {
if (event.httpMethod === 'OPTIONS') {
const requestHeaders = event.headers['Access-Control-Request-Headers'] || event.headers['access-control-request-headers'];
const response = {
statusCode: 200,
body: '',
headers: {
'Access-Control-Allow-Headers': requestHeaders,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Origin': '*'
}
};
return callback(null, response);
}
const command = new InvokeCommand({
FunctionName: '\${${this.PassthroughTo}}',
Payload: JSON.stringify(event)
});
client.send(command)
.then((response) => {
if (!response || !response.Payload)
return callback(new Error('Your Lambda function ${this.PassthroughTo} did not provide a payload'));
var payload = JSON.parse(Buffer.from(response.Payload).toString());
payload.headers = payload.headers || {};
payload.headers['Access-Control-Allow-Origin'] = '*';
callback(null, payload);
})
.catch((err) => callback(err));
};
`).trim()
};
}
}
/**
* The hookshot.Github class defines resources that set up a single API Gateway
* endpoint that is designed responds to POST requests sent from GitHub in
* response to various GitHub events. The hookshot system will use a shared
* secret to validate that the incoming payload did in fact originate from GitHub,
* before sending the event payload to your Lambda function for further
* processing. Any requests that did not come from GitHub or were not properly
* signed using your secret key are rejected, and will never make it to your
* Lambda function.
*
* @name hookshot.Github
*
* @property {Object} Resources - the CloudFormation resources created by this shortcut.
* @property {Object} Outputs - the CloudFormation outputs created by this
* shortcut. This includes the URL for the API Gateway endpoint, and a secret
* string. Use these two values to configure GitHub to send webhooks to your
* API Gateway endpoint.
*
* @param {String} Prefix this will be used to prefix the set of CloudFormation
* resources created by this shortcut.
* @param {String} PassthroughTo the logical name of the Lambda function that you
* have written which will receive a request and generate a response to provide
* to the caller.
* @param {String} LoggingLevel one of `OFF`, `INFO`, or `ERROR`. Logs are delivered
* to a CloudWatch LogGroup named `API-Gateway-Execution-Logs_{rest-api-id}/hookshot`
* @param {String|Object} [WebhookSecret] A secret string to be used to verify
* payload signatures that are delivered to the endpoint. This is optional. If
* not specified, a string will be generated for you. You should provide this
* value to GitHub, and signature verification will be performed before your
* Lambda function being invoked to respond to the event.
*
* @example
* const cf = require('@mapbox/cloudfriend');
*
* const myTemplate = {
* ...
* Resources: {
* MyLambdaFunction: {
* Type: 'AWS::Lambda::Function',
* Properties: { ... }
* }
* }
* };
*
* const webhook = new cf.shortcuts.hookshot.Github({
* Prefix: 'Webhook',
* PassthroughTo: 'MyLambdaFunction'
* });
*
* module.exports = cf.merge(myTemplate, webhook);
*/
class Github extends Passthrough {
constructor(options = {}) {
super(options);
delete this.Resources[`${this.Prefix}OptionsMethod`];
}
method() {
return {
Type: 'AWS::ApiGateway::Method',
Properties: {
RestApiId: { Ref: `${this.Prefix}Api` },
ResourceId: { Ref: `${this.Prefix}Resource` },
ApiKeyRequired: false,
AuthorizationType: 'NONE',
HttpMethod: 'POST',
Integration: {
Type: 'AWS',
IntegrationHttpMethod: 'POST',
IntegrationResponses: [
{
StatusCode: '200'
},
{
StatusCode: '500',
SelectionPattern: '^error.*'
},
{
StatusCode: '403',
SelectionPattern: '^invalid.*'
}
],
Uri: { 'Fn::Sub': `arn:aws:apigateway:\${AWS::Region}:lambda:path/2015-03-31/functions/\${${this.Prefix}Function.Arn}/invocations` },
RequestTemplates: {
'application/json': '{"signature":"$input.params(\'X-Hub-Signature\')","body":$input.json(\'$\')}'
}
},
MethodResponses: [
{
StatusCode: '200',
ResponseModels: {
'application/json': 'Empty'
}
},
{
StatusCode: '500',
ResponseModels: {
'application/json': 'Empty'
}
},
{
StatusCode: '403',
ResponseModels: {
'application/json': 'Empty'
}
}
]
}
};
}
code() {
return {
'Fn::Sub': [
redent(`
'use strict';
const crypto = require('crypto');
const { InvokeCommand, LambdaClient } = require('@aws-sdk/client-lambda');
const client = new LambdaClient();
const secret = '\${WebhookSecret}';
module.exports.lambda = (event, context, callback) => {
const body = event.body;
const hash = 'sha1=' + crypto
.createHmac('sha1', secret)
.update(Buffer.from(JSON.stringify(body)))
.digest('hex');
if (event.signature !== hash)
return callback('invalid: signature does not match');
if (body.zen) return callback(null, 'ignored ping request');
const command = new InvokeCommand({
FunctionName: '\${${this.PassthroughTo}}',
Payload: JSON.stringify(event.body),
InvocationType: 'Event'
});
client.send(command)
.then(() => callback(null, 'success'))
.catch((err) => callback(err));
};
`).trim(),
{
WebhookSecret: this.WebhookSecret || { Ref: `${this.Prefix}Secret` }
}
]
};
}
}
module.exports = {
Passthrough,
Github
};