概要
WAFを用いた柔軟なIPアドレス制限の仕組みを、AWS CDKによって構築してみました。
目次
背景
以前、「AWS WAFとCloudFormationで柔軟なIP制限をする」という記事を書きました。
当時はタイトルの通り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に全コードを載せております。
構成
※本記事のメイン以外の一部のファイルやコードを省略しています。
. ├── 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
まず、コンストラクタの肥大化を防ぐために、init
とcreate
という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)」で解説しておりますので、良かったらご覧下さい。
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にて行われます。
- エラーにならない・有効なIPアドレス
0.0.0.1/8 0.0.0.2/16 0.0.0.3/32 # commentが入っていてもIP部分のみ抽出 # comment # <-エラーにはならずスルー
- エラーになる・無効なIPアドレス
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ファイルから読み取る部分もこれはこれでいい内容だったかなと思います。