I built a flexible IP address restriction mechanism using WAF with AWS CDK.
What is
-
Create a WAF (WebACL) with flexible IP restrictions with CDK.
- -> Whitelist IP addresses in a configuration file like the following, so that it can dynamically apply when deploying increasing or decreasing IP addresses
- iplist.txt
0.0.0.1/32 0.0.0.2/32
Assumptions
I am using CDK with TypeScript.
Also, I am using v2 CDK.
❯ cdk --version
2.31.0 (build b67950d)
Code
See GitHub.
Composition
Some files and codes other than the main one in this article are omitted.
.
├── bin
│ └── waf-cdk-ip-restrictions.ts
├── lib
│ ├── config.ts
│ ├── resource
│ │ └── waf-cdk-ip-restrictions-stack.ts
│ ├── util
│ │ └── get-ip-list.ts
│ └── validator
│ └── waf-region-validator.ts
└── iplist.txt
waf-cdk-ip-restrictions.ts
#!/usr/bin/env node
import "source-map-support/register";
import { App } from "aws-cdk-lib";
import { WafCdkIpRestrictionsStack } from "../lib/resource/waf-cdk-ip-restrictions-stack";
import { configStackProps } from "../lib/config";
const app = new App();
new WafCdkIpRestrictionsStack(app, "WafCdkIpRestrictionsStack", configStackProps);
config.ts
import { StackProps } from "aws-cdk-lib";
export interface Config {
scopeType: string;
}
export interface ConfigStackProps extends StackProps {
config: Config;
}
export const configStackProps: ConfigStackProps = {
env: {
region: "us-east-1",
},
config: {
scopeType: "CLOUDFRONT",
},
};
waf-cdk-ip-restrictions-stack.ts
import { Stack } from "aws-cdk-lib";
import { Construct } from "constructs";
import { getIPList } from "../util/get-ip-list";
import { WafRegionValidator } from "../validator/waf-region-validator";
import { CfnIPSet, CfnWebACL } from "aws-cdk-lib/aws-wafv2";
import { ConfigStackProps } from "../config";
const ipListFilePath = "./iplist.txt";
export class WafCdkIpRestrictionsStack extends Stack {
private scopeType: string;
private ipList: string[];
constructor(scope: Construct, id: string, props: ConfigStackProps) {
super(scope, id, props);
this.init(props);
this.create();
}
private init(props: ConfigStackProps): void {
this.scopeType = props.config.scopeType;
this.ipList = getIPList(ipListFilePath);
const wafRegionValidator = new WafRegionValidator(this.scopeType, this.region);
this.node.addValidation(wafRegionValidator);
}
private create(): void {
const whiteListIPSet = new CfnIPSet(this, "WhiteListIPSet", {
name: "WhiteListIPSet",
addresses: this.ipList,
ipAddressVersion: "IPV4",
scope: this.scopeType,
});
const whiteListIPSetRuleProperty: CfnWebACL.RuleProperty = {
priority: 0,
name: "WhiteListIPSet-Rule",
action: {
allow: {},
},
statement: {
ipSetReferenceStatement: {
arn: whiteListIPSet.attrArn,
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "WhiteListIPSet-Rule",
sampledRequestsEnabled: true,
},
};
new CfnWebACL(this, "WebAcl", {
name: "WebAcl",
defaultAction: { block: {} },
scope: this.scopeType,
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "WebAcl",
sampledRequestsEnabled: true,
},
rules: [whiteListIPSetRuleProperty],
});
}
}
waf-region-validator.ts
import { IValidation } from "constructs";
export class WafRegionValidator implements IValidation {
private scopeType: string;
private region: string;
constructor(scopeType: string, region: string) {
this.scopeType = scopeType;
this.region = region;
}
public validate(): string[] {
const errors: string[] = [];
if (this.scopeType !== "CLOUDFRONT" && this.scopeType !== "REGIONAL") {
errors.push("Scope must be CLOUDFRONT or REGIONAL.");
}
if (this.scopeType === "CLOUDFRONT" && this.region !== "us-east-1") {
errors.push("Region must be us-east-1 when CLOUDFRONT.");
}
return errors;
}
}
get-ip-list.ts
import * as fs from "fs";
const ipListFilePath = "./iplist.txt";
export const getIPList = (): string[] => {
const ipList: string[] = [];
const ipListFile = fs.readFileSync(ipListFilePath, "utf8");
const lines = ipListFile.toString().split("\n");
for (const line of lines) {
const trimmedLine = line
.replace(/ /g, "")
.replace(/\t/g, "")
.replace(/^([^#]+)#.*$/g, "$1");
const commentOutPattern = /^#/g;
const commentOutResult = trimmedLine.match(commentOutPattern);
if (!trimmedLine.length || commentOutResult) continue;
const cidrFormatPattern =
/^(([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\/([1-2]?[0-9]|3[0-2])$/g;
const cidrFormatResult = trimmedLine.match(cidrFormatPattern);
if (!cidrFormatResult) {
throw new Error(`IP CIDR Format is invalid: ${trimmedLine}`);
}
ipList.push(trimmedLine);
}
return ipList;
};
iplist.txt
0.0.0.1/32
0.0.0.2/32
Description
waf-cdk-ip-restrictions.ts
The env.region is contained in a stack parameter object called configStackProps defined in config.ts, which is explained in the next section.
Therefore, by passing this as the stack props (3rd argument), you can deploy in the region you specify.
new WafCdkIpRestrictionsStack(app, "WafCdkIpRestrictionsStack", configStackProps);
config.ts
This file defines the parameters to be passed to the constructor of the stack class.
It defines an interface called ConfigStackProps
that inherits from StackProps
(interface) to be passed to the constructor of Stack, and an interface called Config
that defines its own parameters.
export interface Config {
scopeType: string;
}
export interface ConfigStackProps extends StackProps {
config: Config;
}
It will store parameters of its own Config
type as well as env
and other parameters including region
that you originally wanted to pass in ConfigStackProps
with StackProps
.
export const configStackProps: ConfigStackProps = {
env: {
region: "us-east-1",
},
config: {
scopeType: "CLOUDFRONT",
},
};
waf-cdk-ip-restrictions-stack.ts
constructor
First, to avoid constructor bloat, I call them separately in two private methods, init
and create
.
export class WafCdkIpRestrictionsStack extends Stack {
private scopeType: string;
private ipList: string[];
constructor(scope: Construct, id: string, props: ConfigStackProps) {
super(scope, id, props);
this.init(props);
this.create();
}
init
The init is mainly for initialization of member variables and validation process.
private init(props: ConfigStackProps): void {
this.scopeType = props.config.scopeType;
this.ipList = getIPList();
const wafRegionValidator = new WafRegionValidator(this.scopeType, this.region);
this.node.addValidation(wafRegionValidator);
}
The following initialization process calls getIPList()
, a method to retrieve IPs from an IP file, which will be explained in the next section, and stores white list IPs in a string array.
this.ipList = getIPList();
The two lines after initialization are the validation part, which is used to validate the constructor using the method addValidation
in Class Node.
Specifically, "If SCOPE is CLOUDFRONT, an error will occur if you try to deploy in a region other than us-east-1" validation process is performed.
create
Then comes the create()
method.
This is where you create the WebACL for WAF v2 and the IPSet to be specified as the whitelist IP.
However, since Currently WAV v2 does not provide L2 constructs for CDK, I will use L1 constructs.
First of all, there is IPSet. As the name suggests, this is a resource for WAF that manages IPs.
Based on the IP array obtained by getIPList()
, an IPSet for IPv4 is created.
By attaching this as a permission rule to the WAF's WebACL, it is possible to allow access only from this IP address.
const whiteListIPSet = new wafv2.CfnIPSet(this, "WhiteListIPSet", {
name: "WhiteListIPSet",
addresses: this.ipList,
ipAddressVersion: "IPV4",
scope: this.scopeType,
});
The next step is to create a Rule Property based on the above IP set. The previous one was a class, so I instantiated it with new, but this one is an interface, not a class.
This can be regarded as a WAF rule. Specify the ARN of the IP Set you just created in statement.ipSetReferenceStatement.arn
.
You can specify the ARN of the IP Set you just created by writing whiteListIPSet.attrArn
.
const whiteListIPSetRuleProperty: wafv2.CfnWebACL.RuleProperty = {
priority: 0,
name: "WhiteListIPSet-Rule",
action: {
allow: {},
},
statement: {
ipSetReferenceStatement: {
arn: whiteListIPSet.attrArn,
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "WhiteListIPSet-Rule",
sampledRequestsEnabled: true,
},
};
And Web ACLs in WAF v2 (the WAF itself).
The following settings will cause WAF's default behavior to be blocked (inaccessible).
defaultAction: { block: {} },
Then, by specifying the rules
rule property as described earlier, it is possible to apply this rule, i.e., allow access only from the corresponding IP address.
const webAcl = new wafv2.CfnWebACL(this, "WebAcl", {
name: "WebAcl",
defaultAction: { block: {} },
scope: this.scopeType,
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "WebAcl",
sampledRequestsEnabled: true,
},
rules: [whiteListIPSetRuleProperty],
});
}
waf-region-validator.ts
Here are the details of the validator class used in the WafCdkIpRestrictionsStack class earlier.
As shown above, "If SCOPE is CLOUDFRONT, error if you try to deploy in a region other than us-east-1" validation process is used.
get-ip-list.ts
This is a function that reads from a file filled with whitelist IPs and returns them as an array of strings.
It reads the file and processes one line at a time with a for statement.
const ipListFile = fs.readFileSync(ipListFilePath, "utf8");
const lines = ipListFile.toString().split("\n");
for (const line of lines) {
This one extracts "blanks, tabs, and comment-outs in the middle of a line " from a line and goes to the next loop with zero characters or "characters beginning with #," i.e., commented-out lines are not read.
const trimmedLine = line
.replace(/ /g, "")
.replace(/\t/g, "")
.replace(/^([^#]+)#.*$/g, "$1");
const commentOutPattern = /^#/g;
const commentOutResult = trimmedLine.match(commentOutPattern);
if (!trimmedLine.length || commentOutResult) continue;
Then, after narrowing it down to pure string lines, the CIDR format of the IP address is checked and an error is made if it is in an incorrect format.
const cidrFormatPattern =
/^(([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\/([1-2]?[0-9]|3[0-2])$/g;
const cidrFormatResult = trimmedLine.match(cidrFormatPattern);
if (!cidrFormatResult) {
throw new Error(`IP CIDR Format is invalid: ${trimmedLine}`);
}
The remaining string, i.e., the legitimate IP address, is then stored in an array.
ipList.push(trimmedLine);
iplist.txt
This is where you maintain a whitelist of IPs to which you want to grant access.
The above get-ip-list.ts will do the following: Error out any invalid IP address, remove comment-outs, and so on.
- Valid IP address
0.0.0.1/8
0.0.0.2/16
0.0.0.3/32 # Only the IP part is extracted even if comment is included.
## comment # <-Not an error.
- Invalid IP address
500.0.0.0/32 # <- It starts at 500 (>255)
10.0.0.0/100 # <- CIDR range is 100 (>32)
Deployment
Synthesize
npx cdk synth
Deployment
npx cdk deploy
Stack Deletion
npx cdk destroy
Finally
The crux of this project was not so much building a WAF, but rather realizing dynamic IP restrictions.
The fact that the resources are dynamically created to respond to increasing and decreasing factors is a great match with the strengths of CDK.
Top comments (0)