[AWS] DEV Environment with WAF for API Gateway using CDK

Nagarjun Nagesh
System Weakness
Published in
7 min readFeb 9, 2024

--

DEV WAF

Designing a secure API Gateway using the CDK typescript is the motive to create this blog. Let us dive deeper into the architecture.

  1. Create an API Gateway.
  2. Create an API Domain Name
  3. Connect the API Domain Name to Route 53 with Base Mapping.
  4. Create a WAF for API gateway and link it with API Gateway
  5. Create a Resource and a Lambda function and link the Lambda function to API Gateway.
  6. Create Lambda Permissions to enable API gateway to trigger the Lambda Function
  7. Create a Dead Letter Notification for Lambda Function.

Create an API Gateway using CDK

private addAPIResources(props: PropsAPIResources, lambdaFunction: cdk.aws_lambda.Function) {
const api = new apigateway.RestApi(this, props.apiDomainName, {
restApiName: props.apiDomainName,
description: props.apiDomainName + " API Gateway for the " + props.environment + " environment",
});

// Create an API Gateway resource "/save"
const saveResource = api.root.addResource('save');
// Create an API Gateway method "POST" and link it with the Lambda function
saveResource.addMethod('POST', new apigateway.LambdaIntegration(lambdaFunction), {
apiKeyRequired: true,
});
// Add OPTIONS method to the API Gateway resource
saveResource.addMethod('OPTIONS', this.mockOptionsIntegration(props), {
methodResponses: [
{
statusCode: '200',
responseParameters: {
'method.response.header.Access-Control-Allow-Headers': true,
'method.response.header.Access-Control-Allow-Methods': true,
'method.response.header.Access-Control-Allow-Origin': true,
},
},
],
});
// Create a usage plan
const usagePlan = api.addUsagePlan('MyUsagePlan', {
name: props.domainName + 'UsagePlan',
description: props.domainName + ' Usage plan for My API',
throttle: {
rateLimit: 2000,
burstLimit: 1000,
},
quota: {
limit: 100000,
period: apigateway.Period.MONTH,
},
});
// Create an API key
const apiKey = api.addApiKey('MyApiKey', {
apiKeyName: props.domainName + 'ApiKey',
description: props.domainName + ' API Key for My API',
});
// Associate the API key with the usage plan
usagePlan.addApiKey(apiKey);
// Grant the usage plan access to the API
usagePlan.addApiStage({
api: api,
stage: api.deploymentStage,
});
// Attach the WAF ARN to the API Gateway
const certificate = acm.Certificate.fromCertificateArn(this, props.apiDomainName + 'Certificate', props.certificateArn);
const apiGatewayDomainName = new apigateway.DomainName(this, props.apiDomainName + 'ApiGatewayDomainName', {
domainName: props.apiDomainName,
certificate,
endpointType: apigateway.EndpointType.EDGE,
});
// Add the base mapping
apiGatewayDomainName.addBasePathMapping(api);
return {
api, apiGatewayDomainName
};
}

Explaining the code above,

  1. Create a resource called /save.
  2. Create a POST HTTP Method that is linked to the lambda function.
  3. If you are using a DEV environment, then consider using Usage Plan, With an API key, Link the API Key with Usage plan and the usage plan to the deployment stage.
  4. Create an API Domain Name and assign it to the API gateway, Choose the endpoint type and choose the HTTPS Certificate to attach to the API Gateway.

Create a Lambda Function

// Create a Lambda function
const lambdaFunction = new lambda.Function(this, domainName + 'Function', {
runtime: lambda.Runtime.GO_1_X,
handler: 'main',
code: lambda.Code.fromAsset(golangCodeAsset),
memorySize: 512,
timeout: cdk.Duration.seconds(10),
environment: {
S3_BUCKET_NAME: "bucketName"
},
role: lambdaRole,
retryAttempts: 0,
description: props.environment + " Lambda Function to Save the Resources",
functionName: props.environment + "-lambda-save-resources",
deadLetterTopic: deadLetterTopic
});

This code can be used to create a lambda function.

  1. “runtime:” — Choose the run time — in this case Golang / the programming language of your choice.
  2. “handler:” — Choose the handler for the function.
  3. “code:” — The code in this case is fetched from the bucket where sample code is stored.
  4. “memorySize:” — Choose the memory size.
  5. “timeout:” — The timeout of the lambda function.
  6. “role:” — Add the role that has to be attached to the lambda function.
  7. “deadLetterTopic:” — Add the SNS topic for your dead letter topic.

Create the Lambda Role

private createLambdaRole(bucket: s3.Bucket, dynamoTable: dynamodb.Table, groupNotificationTopic: sns.Topic, deadLetterTopic: sns.Topic, props: PropsAPIResources): iam.Role {
// Create an IAM role for the Lambda function
const lambdaRole = new iam.Role(this, props.domainName + 'Role', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
description: props.environment + props.domainName + "Lambda Function Role",
roleName: props.environment + Config.project + "LambdaFunctionRole",
});
// Add additional permissions
dynamoTable.grantWriteData(lambdaRole);
groupNotificationTopic.grantPublish(lambdaRole);
deadLetterTopic.grantPublish(lambdaRole);
// Allow the Lambda function to put objects in the S3 bucket
bucket.grantReadWrite(lambdaRole);
return lambdaRole
}

Create the lambda role with name and description. Ensure to add the relevant permissions for your lambda function role. A sample is provided above.

Create LogGroup Permissions for Lambda Function

// Add CloudWatch logs permission to the Lambda function's role
const logGroupArn = `arn:aws:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:log-group:/aws/lambda/${lambdaFunction.functionName}:*`;
const createLogGroupStatement = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
resources: [logGroupArn],
});

lambdaFunction.role?.attachInlinePolicy(
new iam.Policy(this, 'createLogGroupPolicy', {
statements: [createLogGroupStatement],
})
);

In the above code, we have added the log group permissions for the lambda function. Specifically:

’logs:CreateLogGroup’, ‘logs:CreateLogStream’, ‘logs:PutLogEvents’

They are use to create and put the loggroups and along with the createLogStream.

Create Lambda Permission and Attach it to API Gateway

// Create a Lambda permission for API Gateway to invoke the Lambda function
const lambdaPermission = new lambda.CfnPermission(this, domainName + 'Permission', {
action: 'lambda:InvokeFunction',
functionName: lambdaFunction.functionArn,
principal: 'apigateway.amazonaws.com',
sourceArn: apiObject.api.arnForExecuteApi(),
});

The above code shows the right way to add permissions using CDK typescript to create permission for the API gateway to invoke the lambda function.

We have provided the function name as Lambda Function ARN and we have provided the source as the API’s ARN.

Create IP Sets and Rule Groups

// Create IP sets for allowed IPs
const ipSet1 = new waf.CfnIPSet(this, 'IPSet1', {
addresses: ['192.0.2.0/24', '198.51.100.0/24'], // Example IPs, replace with your allowed IPs
ipAddressVersion: 'IPV4',
scope: 'REGIONAL',
name: "office IP"
});

const ipSet2 = new waf.CfnIPSet(this, 'IPSet2', {
addresses: ['203.0.113.0/24', '2001:0db8:85a3:0000:0000:8a2e:0370:7334'], // Example IPs, replace with your allowed IPs
ipAddressVersion: 'IPV6',
scope: 'REGIONAL',
name: "remote Consultant Home IP"
});

// Create a rule group containing the IP sets
const ruleGroup = new waf.CfnRuleGroup(this, 'IPRuleGroup', {
capacity: 100,
scope: 'REGIONAL',
name: "Allow these IPs from office and remote",
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'IPRuleGroupMetrics',
sampledRequestsEnabled: true,
},
rules: [
{
name: 'AllowFromIPSet1',
priority: 1,
statement: {
ipSetReferenceStatement: {
arn: ipSet1.attrArn,
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AllowFromIPSet1',
},
},
{
name: 'AllowFromIPSet2',
priority: 2,
statement: {
ipSetReferenceStatement: {
arn: ipSet2.attrArn,
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AllowFromIPSet2',
},
},
],
});

We have created two IP Sets as an example, Let us say one is the Office IP address and the other is the remote consultant home IP address to enable them to access the DEV Environment for the API gateway that we are creating.

Then we have bundled the IP Sets within the rule group. This rule group would then be used to attach itself to the WAF.

What is the benefit of this? Why not simply add multiple IP’s to the IP Set and attach the IP Set to the WAF?

If we created only IP Sets the first problem is that there will only be a bunch of IP’s both necessary and redundant without knowing who added what and for what purpose.

If we curated everything into a rule group then even if the IP changes we can just go the corresponding IP Set where we have named it office for example and change only the office IP Set and remove the old one.

This way we can be 100% sure that we are not removing access for others even unknowingly.

As such, I prefer to do it this way.

Create WAF to associate it with API Gateway


const webACL = new waf.CfnWebACL(this, domainName + 'MyWebACL', {
description: "API ACL for the " + domainName + " API Gateway",
defaultAction: { block: {} }, // blocks all requests by default
scope: 'REGIONAL',
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'MyWebACLMetrics',
sampledRequestsEnabled: true,
},
rules: [
{
name: 'IPRuleGroupRule',
priority: 1,
statement: {
ruleGroupReferenceStatement: {
arn: ruleGroup.attrArn,
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'IPRuleGroupRule',
},
},
],
});
// Enable WAF for the API Gateway
api.node.addDependency(webACL);
// Associate the WebACL with the stage
const wafAssociation = new waf.CfnWebACLAssociation(this, 'WebACLAssociation', {
webAclArn: webACL.attrArn,
resourceArn: api.deploymentStage.stageArn,
});

The below code will generate a WAF with the following properties:

  1. Blocks all request by default.
  2. A WAF with a Regional scope.
  3. Cloudwatch metrics enabled for the WAF.
  4. We have added one rule which is to include the Rule Group that we created above to the WAF.
  5. Add Web ACL to the API Gateway with the below code.
// Enable WAF for the API Gateway
api.node.addDependency(webACL);

// Associate the WebACL with the stage
const wafAssociation = new waf.CfnWebACLAssociation(this, 'WebACLAssociation', {
webAclArn: webACL.attrArn,
resourceArn: api.deploymentStage.stageArn,
});

Associate the WAF with API Stage ARN in order for the association to complete.

Create a Route 53 ARecord

private createRecordSetsInRoute53(props: PropsAPIResources, domainName: string, apiObject: { api: cdk.aws_apigateway.RestApi; apiGatewayDomainName: cdk.aws_apigateway.DomainName; }) {
// Create a hosted zone object using the hosted zone ID
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: props.hostedZoneId,
zoneName: props.apiDomainName,
});

// Create a A record in Route 53 for the custom domain name
new route53.ARecord(this, 'ApiARecord', {
zone: hostedZone,
recordName: props.apiDomainName,
target: route53.RecordTarget.fromAlias(new route53Targets.ApiGatewayDomain(apiObject.apiGatewayDomainName)),
comment: 'API Gateway ARecord for ' + domainName,
deleteExisting: true
});
}

Fetch the hosted zone and use it to create Arecord and attache it to the API Gateway.

Github Code

https://github.com/SkillSageAcademy/CDK-templates/blob/main/secure-api-gateway.ts

Conclusion

The above is the CDK Typescript for creating a Secure API Gateway which is protected by WAF and HTTPS Certificate.

We could also potentially add Authorizers to the API Gateway and secure it with Cognito or Custom Lambda Authorizer so that, every request that comes in needs to contain an “authorizer” JWT token to be authorized. As such the system is more secure which is discussed here in this article

In our scenario we created an API Key and Usage Plan for our Dev environment and attached it to the Deployment Stage.

More importantly we have secured the API gateway to ensure that only the allowed IP’s can access the environment and others are blocked by default.

--

--

I write blogs in blog.blitzbudget.com. Please feel free to check it out! Most of the blogs are posted in the original website