Flexible IP restrictions with AWS CDK and AWS WAF
Tags: aws, cdk, waf, typescript
Published: 2023-07-03 15:49:26

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.

```sh
❯ cdk --version
2.31.0 (build b67950d)
```

---

## Code

See [GitHub](https://github.com/go-to-k/waf-cdk-ip-restrictions).

### 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

```typescript
#!/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

```typescript
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

```typescript
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

```typescript
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

```typescript
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**.

```typescript
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.

```typescript
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`.

```typescript
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`.

```typescript
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.

```typescript
  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.

```typescript
    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**.

```typescript
    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`.

```typescript
    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**.

```typescript
    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.

```typescript
  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**.

```typescript
    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.

```typescript
    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.

```typescript
    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

```sh
npx cdk synth
```

### Deployment

```sh
npx cdk deploy
```

### Stack Deletion

```sh
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.