DEV Community

k.goto for AWS Community Builders

Posted on

Flexible IP restrictions with AWS CDK and AWS WAF

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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",
  },
};

Enter fullscreen mode Exit fullscreen mode

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],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

iplist.txt

0.0.0.1/32
0.0.0.2/32
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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",
  },
};
Enter fullscreen mode Exit fullscreen mode

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();
  }

Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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,
    });
Enter fullscreen mode Exit fullscreen mode

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,
      },
    };
Enter fullscreen mode Exit fullscreen mode

And Web ACLs in WAF v2 (the WAF itself).

The following settings will cause WAF's default behavior to be blocked (inaccessible).

defaultAction: { block: {} },
Enter fullscreen mode Exit fullscreen mode

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],
    });
  }
Enter fullscreen mode Exit fullscreen mode

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) {
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`);
    }
Enter fullscreen mode Exit fullscreen mode

The remaining string, i.e., the legitimate IP address, is then stored in an array.

    ipList.push(trimmedLine);
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode
  • Invalid IP address
500.0.0.0/32 # <- It starts at 500 (>255)
10.0.0.0/100 # <- CIDR range is 100 (>32)
Enter fullscreen mode Exit fullscreen mode

Deployment

Synthesize

npx cdk synth
Enter fullscreen mode Exit fullscreen mode

Deployment

npx cdk deploy
Enter fullscreen mode Exit fullscreen mode

Stack Deletion

npx cdk destroy
Enter fullscreen mode Exit fullscreen mode

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)