[AWS CDK] WAF v2のAWSマネージドルールとIP許可ルールを組み合わせてCloudFrontに適用する

2021.05.23

こんにちは、CX事業本部の若槻です。

前回の記事では、AWSマネージドルールを設定したWAF v2をAWS CDKで作成しました。

これによりWAFが適用されたリソースに「マネージドルールをパス」したリクエストのみアクセスが可能となりますが、同じWAFに対してさらに「接続元IPアドレスがIP許可リストに含まれているか」という条件を加えたい場合があります。

  • 条件1:AWSマネージドルールをパスしている
  • 条件2:接続元IPアドレスがIP許可リストに含まれている

そこで今回は、AWS CDKでWAF v2のAWSマネージドルールとIP許可ルールを組み合わせてCloudFrontに適用し、上記2つの条件を満たすアクセスのみアプリケーションへの接続を許可するようにしてみました。

やってみる

前回の記事では、AWSマネージドルールを設定したWAF v2をAWS CDKで作成しましたが、今回は前回作成したリソースに修正を加える形で手順をご紹介します。

IP Setの作成

アクセス許可対象とするIPv4アドレスを登録するIP Setを作成します。create-ip-setコマンドでの作成時にはアドレスを1つ以上指定する必要があります。リージョンはWAFと同じus-east-1を指定します。(ここでIP SetのリソースをAWS CDKで作成しないのは、許可IPリストの変更をシステム運用者などがマネジメントコンソールからできるようにするためです。)

aws wafv2 create-ip-set \
  --name custom-allow-ip-set \
  --scope CLOUDFRONT \
  --ip-address-version IPV4 \
  --addresses "XXX.XXX.XXX.XXX/32" \
  --region us-east-1

{
    "Summary": {
        "Name": "custom-allow-ip-set",
        "Id": "df4459fe-9746-4bba-ba8f-a76a38d6935e",
        "Description": "",
        "LockToken": "a3602646-674f-429f-ba28-703303c2b259",
        "ARN": "arn:aws:wafv2:us-east-1:XXXXXXXXXXXX:global/ipset/custom-allow-ip-set/df4459fe-9746-4bba-ba8f-a76a38d6935e"
    }
}

作成したIP SetのArnは事項で使用するので控えておきます。

CDKスタックの修正

CDKスタックの記述を修正します。ハイライト部分が前回からの修正部分です。

  • Web ACLのデフォルトアクションはBlockにする
  • 許可するIPアドレスはIP Setルールで明示的に指定するため、マネージドルールはIP 評価ルールグループは指定せず、ベースラインルールグループのみ指定する
  • ルールのPriorityは、マネージドルールを先とし、IP Setルールを最後とする
  • IP Setルールでは先ほど作成したIP SetのArnを指定する
    • 今回はベタ書きしていますが、実際の実装では環境変数やSSMパラメータを使用しての指定になるかと思います。

lib/aws-cdk-deploy-react-stack.ts

import * as cdk from "@aws-cdk/core";
import * as cloudfront from "@aws-cdk/aws-cloudfront";
import * as s3 from "@aws-cdk/aws-s3";
import * as s3deploy from "@aws-cdk/aws-s3-deployment";
import * as iam from "@aws-cdk/aws-iam";
import * as wafv2 from "@aws-cdk/aws-wafv2";

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

    const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
      websiteErrorDocument: "index.html",
      websiteIndexDocument: "index.html",
    });

    const websiteIdentity = new cloudfront.OriginAccessIdentity(
      this,
      "WebsiteIdentity"
    );

    const webSiteBucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [websiteIdentity.grantPrincipal],
      resources: [`${websiteBucket.bucketArn}/*`],
    });

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    // WebACLの作成
    const websiteWafV2WebAcl = new wafv2.CfnWebACL(this, "WafV2WebAcl", {
      // デフォルトアクションは "block" とする
      defaultAction: { block: {} },
      scope: "CLOUDFRONT",
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: "websiteWafV2WebAcl",
      },
      rules: [
        {
          name: "AWSManagedRulesCommonRuleSet",
          priority: 1,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesCommonRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: "AWSManagedRulesCommonRuleSet",
          },
        },
        {
          name: "AWSManagedRulesAdminProtectionRuleSet",
          priority: 2,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesAdminProtectionRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: "AWSManagedRulesAdminProtectionRuleSet",
          },
        },
        {
          name: "AWSManagedRulesKnownBadInputsRuleSet",
          priority: 3,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesKnownBadInputsRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: "AWSManagedRulesKnownBadInputsRuleSet",
          },
        },
        // IP許可リストのルールはPriorityを最後にする
        {
          name: "CustomAllowIpSetRule",
          priority: 4,
          statement: {
            ipSetReferenceStatement: {
              arn: "arn:aws:wafv2:us-east-1:XXXXXXXXXXXX:global/ipset/custom-allow-ip-set/df4459fe-9746-4bba-ba8f-a76a38d6935e", // 作成したIP SetのArnを指定
            },
          },
          action: { allow: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "CustomAllowIpSetRule",
          },
        },
      ],
    });

    const websiteDistribution = new cloudfront.CloudFrontWebDistribution(
      this,
      "WebsiteDistribution",
      {
        errorConfigurations: [
          {
            errorCachingMinTtl: 300,
            errorCode: 403,
            responseCode: 200,
            responsePagePath: "/index.html",
          },
          {
            errorCachingMinTtl: 300,
            errorCode: 404,
            responseCode: 200,
            responsePagePath: "/index.html",
          },
        ],
        originConfigs: [
          {
            s3OriginSource: {
              s3BucketSource: websiteBucket,
              originAccessIdentity: websiteIdentity,
            },
            behaviors: [
              {
                isDefaultBehavior: true,
              },
            ],
          },
        ],
        priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
        webACLId: websiteWafV2WebAcl.attrArn, //作成したWebACLをCloudFrontに適用する
      }
    );

    new s3deploy.BucketDeployment(this, "WebsiteDeploy", {
      sources: [s3deploy.Source.asset("./web/build")],
      destinationBucket: websiteBucket,
      distribution: websiteDistribution,
      distributionPaths: ["/*"],
    });
  }
}

CDKスタックの更新をデプロイします。

% cdk deploy

修正したWeb ACLをマネージドコンソールから見ると、マネージドルールが指定したPriorityで設定されていることが確認できます。

動作確認

許可されたIPからアクセスして、全てのルールをパスする場合

CloudFrontのURLからアプリケーションにアクセスしてみます。IP Setで指定したアドレスから接続したためアプリにアクセスできています。

許可していないIPからアクセスして、デフォルトアクションで拒否される場合

マネジメントコンソールからIP Setの管理画面にアクセスします。[Global (CloudFront)]の一覧から先ほど作成したIP Setを選択します。

指定済みのIPアドレスを選択して[Delete]をクリックし削除します。

数十秒後にアプリケーションにアクセスすると、アクセスがブロックされていることが確認できます。

WAFのサンプルリクエストのログからも、アクセスがデフォルトアクションによりブロックされていることが確認できます。

許可されたIPからアクセスして、マネージドルールで拒否される場合

マネジメントコンソールからIP Setルールに先ほど削除したアクセス元IPアドレスを[Add IP address]をクリックして再度指定し許可リストに追加します。

CloudFrontのURL + /adminにアクセスしてみます。攻撃者が管理ページへのアクセスを試みているという想定です。すると今度もReactアプリが表示されました。

しかしWeb ACLでのサンプルリクエストの記録を見てみると、マネージドルールグループAWSManagedRulesAdminProtectionRuleSetのうちAdminProtection_URIPATHルールでブロックされていることが分かります。

Web ACLでブロックされているにも関わらずページが表示されたのは、今回使用したReactアプリは既定の設定でパスによらず同じコンテンツを返すようになっているためです。/adminページへのルーティングを定義している場合はブロックにより何も表示されない動作となります。

おわりに

AWS CDKでWAF v2のAWSマネージドルールとIP許可ルールを組み合わせてCloudFrontに適用し、「AWSマネージドルールをパスしている」「接続元IPアドレスがIP許可リストに含まれている」の2つの条件を満たすアクセスのみアプリケーションへの接続を許可するようにしてみました。

IP Setのルールが適用できればマネージドルールを組み合わせなくとも良さそうに思えますが、アクセス元がEC2などのIaaSやZScalerなどのネットワークサービスなどとなる場合は、許可するIPアドレスのレンジを広く取らざるを得ず、同じIPレンジを使用しての攻撃者からのアクセスが無いとも限りません。そのような場合にマネージドルールを組み合わせることにより、より強固なセキュリティを実現できるようになります。

以上