AWS CDKとWAFで柔軟なIP制限をする

概要

WAFを用いた柔軟なIPアドレス制限の仕組みを、AWS CDKによって構築してみました。


目次

目次


背景

以前、「AWS WAFとCloudFormationで柔軟なIP制限をする」という記事を書きました。

go-to-k.hatenablog.com


当時はタイトルの通りCloudFormationで構築していたのですが、「柔軟なIP制限をする」というところがCDKの方がいい感じに作れそうだったので、今回CDKで作ってみました。


やりたいこと

  • CDKで柔軟なIP制限が可能なWAF(WebACL)を作成する


CloudFormation、つまりymlだとスタック作成後にIPの増減に対応するのが中々大変だったんですよね・・・(Conditions + Ifで頑張りました。)

今回はCDKを用いたことで、かなりいい感じに構築できました!!(リソースの動的な増減はCDKの強みです)


前提

TypeScriptでCDKを使用しています。

また、CDKはv2を使用しております。

❯ cdk --version
2.31.0 (build b67950d)


コード

GitHubに全コードを載せております。

github.com


構成

※本記事のメイン以外の一部のファイルやコードを省略しています。

.
├── 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


解説

waf-cdk-ip-restrictions.ts

次項でご説明する、config.tsで定義したconfigStackPropsというスタックのパラメータオブジェクトの中にenv.regionが入っているため、これをスタックのprops(第3引数)として渡すことで、指定するリージョンでのデプロイが可能になります。

new WafCdkIpRestrictionsStack(app, "WafCdkIpRestrictionsStack", configStackProps);


config.ts

スタッククラスのコンストラクタに渡すパラメータを定義するためのファイルです。

Stackのコンストラクタで渡せるようStackProps(interface)を継承するConfigStackPropsというinterfaceと、自前のパラメータを定義するConfigというinterfaceを定義しています。

export interface Config {
  scopeType: string;
}

export interface ConfigStackProps extends StackProps {
  config: Config;
}


ConfigStackPropsにもともとStackPropsで渡したかったregionを含むenvなどの他に、自前のConfig型のパラメータも格納します。

export const configStackProps: ConfigStackProps = {
  env: {
    region: "us-east-1",
  },
  config: {
    scopeType: "CLOUDFRONT",
  },
};


waf-cdk-ip-restrictions-stack.ts

constructor

まず、コンストラクタの肥大化を防ぐために、initcreateという2つのprivateメソッドに分けて呼んでいます。

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

initの方は主にメンバー変数の初期化と、バリデーション処理になります。

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


初期化処理の以下ですが、getIPList()という、次項目で解説する「IPファイルからIPを取得するメソッド」を呼んで、ホワイトリストIPをstring配列で格納しています。

    this.ipList = getIPList();


初期化後の2行がバリデーション部分で、Class NodeのaddValidationというメソッドを用いてコンストラクタのバリデーションを行うためのものになります。

具体的には、「SCOPEがCLOUDFRONTのとき、us-east-1以外のリージョンでデプロイしようとしたらエラーにする」バリデーション処理を行っています。

この仕組みの詳細は、以下の記事AWS CDKでバリデーションを行う(addValidation)」で解説しておりますので、良かったらご覧下さい。

go-to-k.hatenablog.com


create

そして、create()メソッドになります。

ここでWAF v2のWebACLと、ホワイトリストIPに指定するIPSetを作成します。

しかし、現状WAV v2にCDKのL2コンストラクトが提供されていないため、L1コンストラクトでの記述になります。


まず最初に、IPSetです。名前の通り、IPを管理するWAF用のリソースです。

getIPList()で取得したIPの配列をもとに、IPv4用のIPSetを作成します。

これをWAFのWebACLに許可ルールとしてアタッチすることで、このIPアドレスからのアクセスのみを許可することが可能になります。

    const whiteListIPSet = new wafv2.CfnIPSet(this, "WhiteListIPSet", {
      name: "WhiteListIPSet",
      addresses: this.ipList,
      ipAddressVersion: "IPV4",
      scope: this.scopeType,
    });


次が、上記のIPセットをもとにルールプロパティというものを作成します。先程はクラスだったのでnewでインスタンス生成していましたが、こちらはクラスではなくinterfaceになります。

これはWAFのルールと捉えてしまって構いません。先程作成したIP SetのARNをstatement.ipSetReferenceStatement.arnに指定します。

whiteListIPSet.attrArnという書き方で先程のIP SetのARNを指定できます。

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


そして、WAF v2におけるWeb ACL(WAFそのもの)です。

以下のような設定によって、 WAFのデフォルトの挙動がブロック(アクセスできない)になります。

defaultAction: { block: {} },


そして先程のルールプロパティをrulesに指定することで、こちらのルールに当てはまる、つまり該当のIPアドレスからのみのアクセスを許可することが可能になります。

    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

先程WafCdkIpRestrictionsStackクラスで使用したvalidatorクラスの詳細です。

上記の通り、「SCOPEがCLOUDFRONTのとき、us-east-1以外のリージョンでデプロイしようとしたらエラーにする」バリデーション処理を行っています。

こちらも上記記事にて詳細を解説しているため本記事では省略します。


get-ip-list.ts

こちらは、ホワイトリストIPを記入したファイルから読み込んで、stringの配列で返す関数です。


ファイル読み込み・1行ずつfor文で処理します。

  const ipListFile = fs.readFileSync(ipListFilePath, "utf8");
  const lines = ipListFile.toString().split("\n");

  for (const line of lines) {


こちらは、1行の中から「空白・タブ・行途中のコメントアウトを抜き取って、0文字になった・または「#で始まる文字」つまりコメントアウトの行は読み取らないで次のループへ行くようにしています。

    const trimmedLine = line
      .replace(/ /g, "")
      .replace(/\t/g, "")
      .replace(/^([^#]+)#.*$/g, "$1");

    const commentOutPattern = /^#/g;
    const commentOutResult = trimmedLine.match(commentOutPattern);
    if (!trimmedLine.length || commentOutResult) continue;


その後、純粋な文字列の行に絞ったあと、IPアドレスのCIDR形式のチェックを行い、おかしい形式の場合はエラーにしています。

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


そして、残った文字列、つまり正当なIPアドレスを配列に格納します。

    ipList.push(trimmedLine);


iplist.txt

こちらで、アクセス許可をしたいIPのホワイトリストを管理します。

以下のように、IPアドレスとして不正な形式のものはエラーにする、コメントアウトは削る、といったような処理が上記のget-ip-list.tsにて行われます。

0.0.0.1/8
0.0.0.2/16
0.0.0.3/32 # commentが入っていてもIP部分のみ抽出
# comment # <-エラーにはならずスルー
500.0.0.0/32 # <- 500から始まっている(>255)
10.0.0.0/100 # <- CIDRのレンジが100(>32)


デプロイ

synthesize

npx cdk synth


デプロイ

npx cdk deploy


スタック削除

npx cdk destroy


最後に

今回の肝はWAFの構築というより、動的なIP制限を実現するということでした。

増減する要素に対して動的に対応してリソースを作るというところがCDKの強みと相性抜群です。

IPファイルから読み取る部分もこれはこれでいい内容だったかなと思います。