🧇

AWS CDKでAWS WAFのルールを作る試み

2024/06/30に公開

はじめに

いろいろ書いてますが、githubのコードこのあたりを見てもらえればOKです。

AWS CDKにおけるAWS WAF

現在、AWS CDKにはAWS WAFのL2 Constructがないため、以下のような書き方になります。
これはAWSマネージドルールグループ1つと自分で定義したルールをWebACLに追加したものです。

export class CdktempStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const webAcl = new waf.CfnWebACL(this, "WebAcl", {
      defaultAction: { block: {} },
      name: "WebAclName",
      scope: "REGIONAL",
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: "WebAcl",
      },
      rules: [
        {
          priority: 1,
          name: "managedRule",
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "managedRule",
          },
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesCommonRuleSet",
            },
          },
          overrideAction: { none: {} }
        },
        {
          priority: 10,
          name: "myRule",
          action: { block: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "myRule",
          },
          statement: {
            notStatement: {
              statement: {
                geoMatchStatement: {
                  countryCodes: ["JP"],
                },
              },
            },
          },
        },
      ],
    });
  }
}

L1コンストラクタしかないため、という理由が大きいかもしれないですが、やりたいことに対して、ほぼAWS WAFのJSONと同じようなものを書かないといけないのはなかなか手間です。

ないなら作ろうの精神でAWS CDKでAWS WAFを扱うやつを作った!というのが今回の趣旨です。

こう書きたい

このrulesの書き方にある程度習熟しているという前提は必要ですが、単純にルールを有効にするだけの場合、上記のサンプルで見せたAWSマネージドルールグループの部分(managedRule)のようにするのはそんなに難しくはないはずです。
一方で、ORやAND,NOTを駆使して、複数のステートメントで条件にするととたんにめんどくさくなります。上記のサンプルではmyRuleの部分です。

statementを何回書かせる気なんだ
statement: {
    notStatement: {
        statement: {
            geoMatchStatement: {
                countryCodes: ["JP"],
            },
        },
    },
}

完全に個人の意見ですが、例えばルール部を以下のように書いてあるとよさそうに見えます。Cloudflareとかに影響受けています。

block( not( geoip.country = ["JP"] ) )

aws-cdk-examplesのwaf

https://github.com/aws-samples/aws-cdk-examples/tree/main

aws-cdk-examplesにもwafを扱ったサンプルがあります。

これをベースに拡張していたのが最初の一歩でした。
AWSマネージドルールグループについては以下の書き方と同じようにしています。
ちなみにサンプルではexcludedRulesが扱えるような書き方ですが実装されてませんでした。
https://github.com/aws-samples/aws-cdk-examples/blob/main/typescript/waf/waf-cloudfront.ts#L129-L159

一方で、国コードのルールやRateLimitのルールなどもありますが、この辺りを大きく変えています。
先ほど記載したANDやORが書けるようにし、visibilityConfigなどを省略できるようになればいい感じです。

作りたいもの

作りたいものと書いていますが、実際にはルールを作りながら必要なものを実装しています。
その時欲しくなかったから実装されていない、みたいなのも多くあります。

AWSマネージドルールグループに対してほしい機能は以下です。
excludedRulesscopeDownStatementを使えるようにすることで、マネージドルールグループに細かい調整ができるようになります。

  • 特定ルールグループを有効にする
  • excludedRulesを扱う
  • scopeDownStatementを扱う

自分で作るカスタムルールに対してほしい機能は以下です。

  • ANDやOR,NOTを扱いやすくする
  • IPセットやGeoベース、その他のルールを関数で作るようする。(AND,OR,NOTを駆使して自分で実装しやすくする)

また共通してプライオリティ(ルールの優先順位)を自動で付与できるようにしています。

作ったもの

こう書きたいものは

block( not( geoip.country = ["JP"] ) )

こうなった

WafStatement.block(
    "name",
    priority,
    WafStatement.not(
        WafStatement.matchCountryCodes(["JP"})
    )
)

サンプルの方針

今回のサンプルでは以下のファイルに分かれています。
どうわければいいのかは結構迷走してましたし、今も迷ってます。

  • IPアドレスやルール構成などの変更する場所
    • /lib/waf-config.ts
      • IPセットやRateルールに利用する各種値
    • /lib/waf.ts
      • 具体的なルールを設定する場所
        • CfnWebACL.RulePropertyを返す関数として実装する
      • 自分が欲しいルールをrulesに追加する
  • ルールステートメントなど変更しない場所
    • /lib/utils/waf/webacl.ts
      • WebACLのScope
      • (作ったけどいらなかった)
    • /lib/utils/waf/statements.ts
      • ルールのパーツを定義
      • 今回の主役
    • /lib/utils/waf/ipsets.ts
      • IPSetsを扱う
      • ipv4とipv6を同時に扱う

cdk-waf-sample

以下に作成したものを置いています。
https://github.com/raihalea/cdk-waf-sample/tree/main

いくつかファイルに分かれているのでStackから順に説明していきます。

Stack

waf.tsWafコンストラクタを呼び出しています。
どのルールを使うかなどは、StackではなくWafコンストラクタで書くようにしています。
https://github.com/raihalea/cdk-waf-sample/blob/main/lib/cdk-waf-sample-stack.ts#L12

Wafコンストラクタ

waf.ts

rulesmakeRules関数で作ったルールのリストを入れています。
makeRuelsの中で具体的なルールを作っていきます。
https://github.com/raihalea/cdk-waf-sample/blob/main/lib/waf.ts#L89

AWSマネージドルールグループ

createManagedRules関数内でmanagedRulesWafStatements.managedRuleGroup()関数で処理しています。
https://github.com/raihalea/cdk-waf-sample/blob/main/lib/waf.ts#L142-L144

AWSマネージドルールグループ名やプライオリティを入力し、CfnWebACL.RulePropertyを返します。

  static managedRuleGroup(
    r: listOfRules,
    startPriorityNumber: number,
    index: number,
  ): CfnRuleGroup.RuleProperty {
    var stateProp: CfnWebACL.StatementProperty = {
      managedRuleGroupStatement: {
        name: r.name,
        vendorName: 'AWS',
        excludedRules: r.excludedRules.map((ruleName) => ({
          name: ruleName,
        })),
        scopeDownStatement: r.scopeDownStatement,
      },
    };
    var overrideAction: CfnWebACL.OverrideActionProperty = { none: {} };

    var rule: CfnWebACL.RuleProperty = {
      name: r.name,
      priority:
        r.priority !== undefined ? r.priority : startPriorityNumber + index,
      overrideAction: overrideAction,
      statement: stateProp,
      visibilityConfig: {
        sampledRequestsEnabled: true,
        cloudWatchMetricsEnabled: true,
        metricName: r.name,
      },
    };
    return rule;
  }

カスタムルール

今回のサンプルの中で複数の条件を使っているcreateSizeRestrictionExcludedAdminIpsを例にあげて説明します。

createSizeRestrictionExcludedAdminIpsは以下のようなルールです。

  • 条件にマッチしたリクエストをブロック
    • 以下の両方にマッチする
      • 16KB以上のBodyのサイズを持つ
      • 以下の条件に両方にマッチしない
        • 特定パス
          • /api/で始まる
          • /setupに完全一致する
        • 管理者のIPアドレス
          • 管理者のIPアドレスを定義していない場合には特定パスだけで判断

つまり、管理者には一部パスに16KB以上のリクエストを許可しているようなルールです。

  private createSizeRestrictionExcludedAdminIps(
    priority: number,
    adminIpsSetList: CfnIPSet[]
  ): CfnRuleGroup.RuleProperty {
    const urlConditons = WafStatements.or(
      WafStatements.startsWithURL("/api/"),
      WafStatements.exactlyURL("/setup")
    );

    let combinedConditions;
    if (adminIpsSetList.length === 0) {
      combinedConditions = urlConditons;
    } else {
      combinedConditions = WafStatements.and(
        urlConditons,
        WafStatements.ipv4v6Match(adminIpsSetList)
      );
    }

    return WafStatements.block(
      "SizeRestriction",
      priority,
      WafStatements.and(
        WafStatements.oversizedRequestBody(16 * 1024), //16KB
        WafStatements.not(combinedConditions)
      )
    );
  }

おわりに

AWS WAFをCDKで書く試みでした。
WafStatementsは自分が欲しかった部分しか実装していないので、実際に使うと足りない箇所はあるかもしれないが、その都度実装すればいいかな、と

もとのL1 ConstructやAWS WAFで利用できるJSONフォーマットは書きづらい一方で、AWS WAFに詳しくなくてもルールがどういった動きをするのかが読めると考えています。
今回のCDKのサンプルは若干の書きやすさを得る代わりに、部分的に複雑になったために読みづらい部分があるかもなぁ、というのが反省点です。設定する場所が2か所になって嬉しいんだっけ?というところとか。

特にIPセット周りのIPセットが定義されていなかったら…や、IPv4のみ定義された場合には…といった場合の対応が手間がかかった部分ですが、CloudFormationの場合にはもっと時間を消費すると思っているので、よかったということにしておきます。

このサンプルの元となったものが個人でちゃんと書いた初めてのTypeScriptなので、いろいろ多めに見てください。(というか書き方こうしたほうがいい、とかあったら教えてほしい)

Discussion