Deployment Templates of AWS Services via AWS CDK (in TypeScript)
WAFv2
Considering the comparison table shown below (from https://blog.dream11engineering.com/enhancing-security-and-trust-with-aws-wafv2-8b050b1cba37)
The WCU requirements for a rule group are determined by the rules that you define inside the rule group. The maximum capacity for a rule group is 1,500 WCUs. The basic price for a web ACL includes up to 1,500 WCUs The maximum capacity for a web ACL is 5,000 WCUs.
and AWS doc shown above (from https://docs.aws.amazon.com/waf/latest/developerguide/aws-waf-capacity-units.html) use WAFv2 instead of WAF Classic(v1)
Tutorial for WAFv2 CDK deployment https://aws.amazon.com/blogs/devops/easily-protect-your-aws-cdk-defined-infrastructure-with-aws-wafv2/
import { Stack, aws_wafv2 } from 'aws-cdk-lib';
class TemplateStack extends Stack {
constructor(scope: App, id: string, props: StackProps) {
super(scope, id, props);
new aws_wafv2.CfnWebACL(this, 'apiWebAcl', {
defaultAction: {
block: {},
},
/* Specifies whether this is for an Amazon CloudFront distribution or for a regional application.
* A regional application can be an Application Load Balancer (ALB), an Amazon API Gateway REST API,
* an AWS AppSync GraphQL API, an Amazon Cognito user pool, or an AWS App Runner service. Valid Values are `CLOUDFRONT` and `REGIONAL`
* @link http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-webacl.html#cfn-wafv2-webacl-scope
*/
scope: 'CLOUDFRONT',
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'MetricForWebACLCDK',
sampledRequestsEnabled: true,
},
name: 'ApiWebAclRules', // optional
rules: aclRules, // optional, sample shown below in Sample ACL Rules
});
}
}
ACL (Access Control Lists) Config
- defaultAction:
block - AWS default rules
- AWSManagedRules -
CommonRuleSet - AWSManagedRules -
AdminProtectionRuleSet(Attention: it would filter routes containing substringadmin) - AWSManagedRules -
KnownBadInputsRuleSet
- AWSManagedRules -
- Customized rules
RateLimitByIP: Block requests from IP which exceeded 50k requests per 5 minutesByteMatchForHeaders: Only allow requests with Authorization header
AWS AdminProtectionRuleSet will block the API call if there is admin string inside URI path, which is treated by AWS as Admin reserved URI path
- Alternative solutions:
- Allow the API call just for listing Rate, added Regex
^\\/sample-admin\\/v[0-9]+\\/rates$to skip the inspection - Avoid using string
admininside SampleAdmin URI path prefix - Remove
AdminProtectionRuleSetfrom ACL Rules (our solution)
- Allow the API call just for listing Rate, added Regex
import { aws_wafv2 } from 'aws-cdk-lib';
export const aclRules: aws_wafv2.CfnWebACLProps['rules'] = [
// AWS default rules
// Details in <https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-baseline.html>
{
name: 'CRSRule',
priority: 0,
statement: {
managedRuleGroupStatement: {
name: 'AWSManagedRulesCommonRuleSet',
vendorName: 'AWS',
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'MetricForWebACLCDK-CRS',
sampledRequestsEnabled: true,
},
overrideAction: {
none: {},
},
},
{
name: 'KnownBadInputsRule',
priority: 2,
statement: {
managedRuleGroupStatement: {
name: 'AWSManagedRulesKnownBadInputsRuleSet',
vendorName: 'AWS',
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'MetricForWebACLCDK-KnownBadInputs',
sampledRequestsEnabled: true,
},
overrideAction: {
none: {},
},
},
// Customized Rules
// Block requests from IP which exceeded 50k requests per 5 minutes
{
name: 'RateLimitByIP',
priority: 4,
statement: {
rateBasedStatement: {
limit: 50000,
aggregateKeyType: 'IP',
},
},
action: {
block: {},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'MetricForWebACLCDK-RateLimit',
sampledRequestsEnabled: true,
},
},
// Only allow requests with specific prefix
{
name: 'ByteMatchForHeaders',
priority: 6,
statement: {
byteMatchStatement: {
fieldToMatch: {
headers: {
matchPattern: { includedHeaders: ['Authorization'] },
matchScope: 'VALUE',
oversizeHandling: 'CONTINUE',
},
},
searchString: 'TARGET_PREFIX_STRING',
positionalConstraint: 'STARTS_WITH',
textTransformations: [{ priority: 0, type: 'COMPRESS_WHITE_SPACE' }],
},
},
action: {
allow: {},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'MetricForWebACLCDK-geoMatchForLabels',
sampledRequestsEnabled: true,
},
},
];
CloudFront with Lambda@Edge + ApiGatewayV2
Using recommended config for api gateway
- originRequestPolicy:
ALL_VIEWER_EXCEPT_HOST_HEADER - cachePolicy:
CACHING_DISABLED
Alternative implementations
using aws_cloudfront.CloudFrontWebDistribution vs. aws_cloudfront.Distribution (recommended by AWS Doc)
import { App, Stack, aws_cloudfront, aws_lambda as lambda } from 'aws-cdk-lib';
import { HttpOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
import { HttpApi } from '@aws-cdk/aws-apigatewayv2-alpha';
import { join } from 'path';
class TemplateStack extends Stack {
constructor(scope: App, id: string, props: StackProps) {
super(scope, id, props);
/* Lambda@Edge function to remove the service prefix from the path
* before forwarding the request to the origin API Gateway
*
* ONLY solution to customize the request after received by the CloudFront distribution
* and before forwarding the request to the origin API Gateway
*
* Comparision: Base path mapping vs. CloudFront Distribution
* 1) Base path mapping:
* Ex. https://custom-api-domain/some_route/v1/some_subroute -> (mapping) https://custom-api-domain/some_subroute
*
* 2) CloudFront Distribution:
* Ex. https://custom-api-domain/some_route/v1/some_subroute -> (forward to origin API Gateway) https://CLOUD_FRONT_DISTRIBUTION_DOMAIN/some_subroute
*/
const originRequestHandlerFn = new cloudfront.experimental.EdgeFunction(
this,
'originRequestHandler',
{
runtime: lambda.Runtime.NODEJS_16_X,
handler: 'cloudfrontRequestHandler.handler', // format: [FILENAME].[FUNCTION_NAME], which has to be in JavaScript, not TypeSCript
code: lambda.Code.fromAsset(join(__dirname, 'LAMBDA_EDGE_RELATIVE_PATH')),
}
);
/* default behavior options for CloudFront Distribution */
const addBeheviorDefaultOptions: aws_cloudfront.AddBehaviorOptions = {
allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_ALL,
viewerProtocolPolicy: aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
// recommended config for api gateway by AWS
originRequestPolicy: aws_cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
cachePolicy: aws_cloudfront.CachePolicy.CACHING_DISABLED,
};
/* CloudFront Distribution for API Gateways */
this.cloudfrontDistribution = new aws_cloudfront.Distribution(this, 'GlobalApiDistribution', {
comment: 'Global API Distribution',
defaultBehavior: {
...addBeheviorDefaultOptions,
origin: new HttpOrigin(`execute-api.${this.region}.amazonaws.com`),
},
domainNames: domainName ? [domainName] : [], // custom api domain name
certificate: SSLCertificate, // certifiacte for SSL
sslSupportMethod: aws_cloudfront.SSLMethod.SNI,
webAclId: SOME_WAF_ACL.attrArn, // optional
});
/* Sample API */
const sampleApi: HttpApi = props.sampleApi;
const sampleApiOrigin = new HttpOrigin(
`${sampleApi.httpApiId}.execute-api.${this.region}.amazonaws.com`
);
this.cloudfrontDistribution.addBehavior('/sampleRoute/v1*', sampleApiOrigin, {
...this.addBeheviorDefaultOptions,
edgeLambdas: [
{
eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
functionVersion: originRequestHandlerFn.currentVersion,
includeBody: false,
},
],
});
}
}
Lambda@Edge (in JavaScript)
By default, max cache behaviors per (CloudFront) distribution: 25 If addBehavior() for each route, the behavior amount would exceed the max limit. Or request a higher quota from AWS. https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html
Consequently put all routes under their service prefix (ex. sample-api-route behaviors under path /sample-api-route/v1) via Lambda@Edge, which allows us to modify the request details before getting forwarded to origin from CloudFront distribution.
exports.handler = async function (event) {
const request = event.Records[0].cf.request;
/* Remove the API Gateway prefix from the request path before forwarding the request to the origin API Gateway
* Ex. (original uri) /sample_route/v1/sample_subroute -> (after replacement) /sample_subroute
*/
request.uri = request.uri.replace('/sample_route/v1/', '/');
console.log('EVENT: \n' + JSON.stringify(event, null, 2));
return request;
};
Custom Construct Modifying S3 Bucket
import { aws_s3 as S3, aws_s3_deployment as S3_Deployment } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import path from 'path';
import * as fs from 'fs';
class SampleCustomConstruct extends Constuct {
public sampleContentInS3Bucket = [];
private _s3Bucket: S3.Bucket;
private _s3Key: string;
private s3KeyPrefix: string;
private outputDir: string;
// CONSTRUCTORS
constructor(
scope: Construct,
id: string,
props: { s3Bucket: S3.Bucket; s3KeyPrefix: string; outputDir: string }
) {
super(scope, id);
this._s3Bucket = props.s3Bucket;
this._s3Key = `${props.s3KeyPrefix}/sampleContentInS3Bucket.json`;
this.s3KeyPrefix = props.s3KeyPrefix;
this.outputDir = props.outputDir;
}
// ACCESSORS
public get s3Bucket(): S3.Bucket {
return this._s3Bucket;
}
public get s3Key(): string {
return this._s3Key;
}
// MODIFERS
public addContent(sampleContentInS3Bucket: any) {
this.sampleContentInS3Bucket.push(sampleContentInS3Bucket);
}
public deployBucket() {
fs.mkdirSync(this.outputDir, { recursive: true });
fs.writeFileSync(path.join(this.outputDir, 'sample.json'), JSON.stringify(this.sampleContentInS3Bucket));
new S3_Deployment.BucketDeployment(this, 'SampleBucketDeployment', {
sources: [S3_Deployment.Source.asset(this.outputDir)], // The local dir/zip-file to be uploaded to AWS S3 Bucket
destinationBucket: this._s3Bucket, // The S3 Bucket in AWS
destinationKeyPrefix: this.s3KeyPrefix, // The specific path (file/dir) inside S3 Bucket
});
}
}
S3 Bucket
const sampleBucket = new aws_s3.Bucket(this, 'SampleBucket', {
/* The S3 Bucket will automatically get destroyed when stack destroyed */
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
Lambda Authorizer
Work as AWS JWT Authorizer, checking scopes & route/path
The API request will be checked by the lambda authorizer, assuring it is not from unknown sources, but from existing CloudFront Distributions.
The verification is implemented via preset secret, which will be added as header X-Origin-Verify when CloudFront Distribution redirects requests to origin API Gateway。
Lambda Authorizer - Payload Format
https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html
Lambda Authorizer Checklist for event of APIGatewayRequestAuthorizerEvent
X-Origin-Verifyexists inevent.headers, and matched with the preset secretevent.typeisREQUESTevent.authorizationTokenexists, passes the Auth0 verification, and has access to the required scope(s)
import jwksClient from 'jwks-rsa';
import jwt from 'jsonwebtoken';
import { APIGatewayAuthorizerResult } from 'aws-lambda/trigger/api-gateway-authorizer';
import S3Client from 'SOME_WHERE';
export type ApiPermission = {
arn?: string;
stage?: string;
httpMethod: string;
path: string;
scopes: string[];
};
let apiPermissions: ApiPermission[] | undefined;
const client = jwksClient({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10, // Default value
jwksUri: process.env.JWKS_URI ?? 'https://sample_jwks_uri/.well-known/jwks.json',
});
const defaultDenyAllPolicy = {
principalId: 'user',
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: 'Deny',
Resource: '*',
},
],
},
};
function getToken(params: any) {
if (!params.type || params.type !== 'REQUEST') {
throw new Error('Expected "event.type" parameter to have value "TOKEN"');
}
const tokenString = params.authorizationToken;
if (!tokenString) {
throw new Error('Expected "event.authorizationToken" parameter to be set');
}
const match = tokenString.match(/^Bearer (.*)$/);
if (!match || match.length < 2) {
throw new Error(`Invalid Authorization token - ${tokenString} does not match "Bearer .*"`);
}
return match[1];
}
async function generatePolicy(
scopeClaims: string[],
httpMethod: string,
path: string,
methodArn: string,
principalId: string,
): Promise<APIGatewayAuthorizerResult> {
// Retrieve API permissions from S3 if not already retrieved
if (!apiPermissions && process.env.S3_BUCKET_NAME && process.env.S3_KEY) {
console.log(' == RETRIEVING S3 BUCKET == ', process.env.S3_BUCKET_NAME, process.env.S3_KEY);
apiPermissions = JSON.parse(await S3Client.get(process.env.S3_BUCKET_NAME, process.env.S3_KEY));
console.log('== RETRIEVED API PERMISSIONS ==\n', apiPermissions);
}
// If no API permissions are defined, return default deny all policy
if (apiPermissions === undefined) {
return defaultDenyAllPolicy;
}
const policyStatements = [];
for (let i = 0; i < apiPermissions.length; i++) {
let isPermissionMatched = true;
// Check scopes
for (let j = 0; j < apiPermissions[i].scopes.length; j++) {
if (scopeClaims.includes(apiPermissions[i].scopes[j]) === false) {
isPermissionMatched = false;
break;
}
}
// Check route & method
if (httpMethod !== apiPermissions[i].httpMethod || path !== apiPermissions[i].path) {
isPermissionMatched = false;
}
if (!isPermissionMatched) continue;
policyStatements.push({ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: methodArn });
}
// Check if no policy statements are generated, if so, create default deny all policy statement
if (policyStatements.length === 0) {
return defaultDenyAllPolicy;
} else {
return {
principalId,
policyDocument: {
Version: '2012-10-17',
Statement: policyStatements,
},
context: {},
};
}
}
export const handler = async (
params: AWSLambda.APIGatewayRequestAuthorizerEvent
): Promise<APIGatewayAuthorizerResult> => {
console.log('== params ==\n', params);
const xOriginVerify = params.headers ? params.headers['X-Origin-Verify'] : null;
if (xOriginVerify !== process.env.X_ORIGIN_VERIFY) {
throw new Error('invalid origin');
}
const token = getToken(params);
console.log('== token ==\n', token);
const decoded: any = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header || !decoded.header.kid) {
throw new Error('invalid token');
}
console.log('== decoded ==\n', decoded);
try {
console.log('JWKS_URI', process.env.JWKS_URI);
const key = await client.getSigningKey(decoded.header.kid);
console.log('== key ==\n', key);
const decodedToken = jwt.verify(token, key.getPublicKey(), {
algorithms: ['RS256'],
audience: process.env.AUTH0_AUDIENCE,
issuer: process.env.AUTH0_ISSUER,
});
console.log('== decodedToken ==\n', decodedToken);
if (typeof decodedToken === 'string') {
console.log('ERROR: decodedToken is a string');
return defaultDenyAllPolicy;
}
const scopeClaims = decodedToken.scope.split(' ');
// Generate IAM Policy
const iamPolicy = await generatePolicy(
scopeClaims,
params.requestContext.httpMethod,
params.requestContext.resourcePath,
params.methodArn,
decodedToken.sub as string
);
console.log('== policy ==\n', iamPolicy);
console.log('== policy statements ==\n', iamPolicy.policyDocument.Statement);
return iamPolicy;
} catch (error) {
console.error(error);
return defaultDenyAllPolicy;
}
};
S3 Client
import { S3Client as S3, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const DEFAULT_EXPIRES_TWO_WEEK = 60 * 60 * 24 * 7;
export default class S3Client {
private static s3Client = new S3({
region: 'us-east-1',
});
/**
* @param dataBody input data as an object format
* @returns return the pre-signed URL which would provide the permission for users to access the S3 data
*/
public static async upload(
bucketName: string,
s3Key: string,
objectData: string,
expires: number = DEFAULT_EXPIRES_TWO_WEEK
): Promise<string> {
const s3Params = new PutObjectCommand({
Bucket: bucketName,
Key: s3Key,
Body: objectData,
});
await this.s3Client.send(s3Params);
const urlParams = new GetObjectCommand({
Bucket: bucketName,
Key: s3Key,
});
// It's encrypted and whoever gets the url will get the full access of its own bucket data.
return await getSignedUrl(this.s3Client, urlParams, { expiresIn: expires });
}
public static async get(bucketName: string, s3Key: string): Promise<string> {
const s3Params = new GetObjectCommand({
Bucket: bucketName,
Key: s3Key,
});
try {
const response = await this.s3Client.send(s3Params);
const str = await response.Body?.transformToString();
return str ?? '';
} catch (error) {
console.error(error);
return 'Failed to get data from S3 Bucket';
}
}
}
Deploy Lambda Authorizer in ApiGatewayV2
import {
HttpLambdaAuthorizer,
HttpLambdaResponseType,
} from '@aws-cdk/aws-apigatewayv2-authorizers-alpha';
import {
NodejsFunction as AWSNodeJsFunction,
NodejsFunctionProps as AWSNodejsFunctionProps,
} from 'aws-cdk-lib/aws-lambda-nodejs';
// Init Lambda Authorizer
const lambdaAuthorizerFn = new AWSNodeJsFunction(this, 'LambdaAuthorizerFn', {
stage: 'SAMPLE_STAGE',
entry: join(__dirname, 'SAMPLE_PATH'),
handler: 'SAMPLE_MAIN_FUNCTION_NAME',
environment: {
JWKS_URI: `${jwtIssuer}.well-known/jwks.json`,
AUTH0_ISSUER: 'SAMPLE_JWT_Issuer',
AUTH0_AUDIENCE: 'SAMPLE_JWT_Audience',
S3_BUCKET_NAME: 'SAMPLE_S3_BUCKET_NAME',
S3_KEY: 'SAMPLE_S3_KEY',
X_ORIGIN_VERIFY: 'SAMPLE_X_ORIGIN_VERIFY',
},
timeout: Duration.seconds(5),
memorySize: 1024,
});
// Allow lambda authorizer to retrieve all api permissions
sampleS3Bucket.grantRead(lambdaAuthorizerFn, 'SAMPLE_FOLDER_PATH' + '/*');
const lambdaAuthorizer = new HttpLambdaAuthorizer('LambdaAuthorizer', lambdaAuthorizerFn, {
authorizerName: 'LambdaAuthorizer',
identitySource: ['$request.header.Authorization'],
resultsCacheTtl: Duration.seconds(0),
responseTypes: [HttpLambdaResponseType.IAM],
});
Attach new functions into ApiGatewayV2
import { HttpApi, HttpMethod, AddRoutesOptions } from '@aws-cdk/aws-apigatewayv2-alpha';
const sampleApi: HttpApi; // Need to assign some HttpApi (ApiGatewayV2)
const method = sampleApi.addRoutes({
...addRoutesOptions,
/* AWS doesnt support `authorizationScopes` field when using lambda authorizer */
authorizationScopes: undefined });
// add scope & route/path info into S3 Bucket
const sampleCustomConstruct: SampleCustomConstruct // Need to assign some SampleCustomConstruct
sampleCustomConstruct.addPermission({
httpMethod: addRoutesOptions.methods ? addRoutesOptions.methods[0] : HttpMethod.ANY,
path: addRoutesOptions.path,
scopes: addRoutesOptions.authorizationScopes?.map((scope) => scope.trim()) || [],
});
Workflow Example
WAFv2 + CloudFront with Lambda@Edge + APIGatewayV2 + LambdaAuthorizer
