Lambda関数URLが第三者により直接実行されるのをCloudFront(OAC)+WAFで防ぐCDKを書く

# はじめに
Lambda関数URLを作成し、第三者からアクセスできてしまうのを防ぐために、CloudFrontからのみアクセスできるように設定しようとしたところ、落とし穴がかなりあったので備忘録として残します。
# 目次
- [実現したこと](#実現したこと)
  - [Before](#before)
  - [After](#after)
- [実装したCDK](#実装したcdk)
  - [ディレクトリ構成](#ディレクトリ構成)
  - [コード](#コード)
- [ハマったところ](#ハマったところ)
  - [Lambda関数URLでPUT/POSTメソッドを使用する場合、x-amz-content-sha256ヘッダーにペイロードする必要がある](#lambda関数urlでput/postメソッドを使用する場合、x-amz-content-sha256ヘッダーにペイロードする必要がある)
  - [Lambda@EdgeのNodeバージョンは20](#lambda@edgeのnodeバージョンは20)
  - [リージョンの制限](#リージョンの制限)
- [参考](#参考)
# 実現したこと
## Before
Lambda関数URLは、認証NONEの場合、誰でもアクセスできてしまう状態でした。
![Beforeイメージ図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3516746/5dd79c6b-bba7-5739-3e6c-55cacd39e065.png)

## After
そこで、CloudFront経由でのみアクセス可能になるよう設定・WAFを追加し、かつ、カスタムドメインでアクセスできるように実装しました。
ここではカスタムドメインの変換については省略します。
![Afterイメージ図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3516746/e19f6e41-a1e8-8fe0-ffd4-70e7282c3281.png)


# 実装したCDK
## ディレクトリ構成
ディレクトリの構成は以下です。
今回mainHandlerでは、POSTメソッドを使用しています。
```
┣ functions
  ┣ mainHandler.ts
  ┗ lambdaEdgeHandler.ts
┣ ap-northeast-1-stack.ts
┗ us-east-1-stack.ts
```
## コード

下記のサイトを参考にlambdaEdge用のHandlerを作成しました。

https://dev.classmethod.jp/articles/cloudfront-lambda-url-sigv4-signer/#cloudfront-%252B-lambda-%25E9%2596%25A2%25E6%2595%25B0-url-%25E3%2581%25A7%25E3%2582%2582-%25E8%2587%25AA%25E5%2589%258D%25E3%2581%25A7%25E7%25BD%25B2%25E5%2590%258D%25E3%2581%2599%25E3%2582%258C%25E3%2581%25B0-post%252Fput-%25E3%2583%25AA%25E3%2582%25AF%25E3%2582%25A8%25E3%2582%25B9%25E3%2583%2588%25E3%2581%258C%25E5%258F%25AF%25E8%2583%25BD%25E3%2581%25A7%25E3%2581%2599

```typescript:'lambdaEdgeHandler.ts'
import { CloudFrontRequestEvent, CloudFrontRequestHandler } from "aws-lambda";

const hashPayload = async (payload: string) => {
  const encoder = new TextEncoder().encode(payload);
  const hash = await crypto.subtle.digest("SHA-256", encoder);
  const hashArray = Array.from(new Uint8Array(hash));
  return hashArray.map((bytes) => bytes.toString(16).padStart(2, "0")).join("");
};

export const handler: CloudFrontRequestHandler = async (
  event: CloudFrontRequestEvent,
  _context,
) => {
  const request = event.Records[0].cf.request;

  if (!request.body) {
    return request;
  }

  const body = request.body.data;
  const decodedBody = Buffer.from(body, "base64").toString("utf-8");

  request.headers["x-amz-content-sha256"] = [
    { key: "x-amz-content-sha256", value: await hashPayload(decodedBody) },
  ];

  return request;
};

```

ap-northeast-1-stackではmainHandlerの作成、OACの作成、distributionの作成を行なっています。
```typescript:'ap-northeast-1-stack.ts'
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
import * as cloudfrontOrigins from "aws-cdk-lib/aws-cloudfront-origins";

export class ApNortheast1Stack extends cdk.Stack {
  distribution: cdk.aws_cloudfront.Distribution;
  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    // The Lambda Function For Main
    const mainHandlerFunc = new lambda.Function(
      this,
      "MainHandlerFunction",
      {
        runtime: lambda.Runtime.NODEJS_20_X,
        handler: "mainHandler.handler",
        code: lambda.Code.fromAsset("lib/functions"),
        timeout: cdk.Duration.minutes(15),
      }
    );

    // OAC
    const originAccessControlForMain = new cloudfront.CfnOriginAccessControl(
      this,
      "mainHandler-cloudfront-OriginAccessControl",
      {
        originAccessControlConfig: {
          name: "mainHandler-cloudfront-OriginAccessControl",
          originAccessControlOriginType: "lambda",
          signingBehavior: "always",
          signingProtocol: "sigv4",
          description: "Access Control",
        },
      }
    );

    // CloudFront
    const lambdaEdgeFunctionArn = ssm.StringParameter.fromStringParameterAttributes(
      this,
      "LambdaEdgeFunctionArn",
      {
        parameterName: "/main/lambda-edge-function-arn",
        forceDynamicReference: true,
      }
    ).stringValue;
    const webAclId = ssm.StringParameter.fromStringParameterAttributes(
      this,
      "WebAclArnCustomResource",
      {
        parameterName: "/main/web-acl-arn",
        forceDynamicReference: true,
      }
    ).stringValue;
    const distribution = new cloudfront.Distribution(
      this,
      "mainHandler-cloudfront-Distribution",
      {
        comment: "mainHandler distribution.",
        defaultBehavior: {
          origin: new cloudfrontOrigins.FunctionUrlOrigin(
            mainHandlerFunc.addFunctionUrl({
              authType: lambda.FunctionUrlAuthType.AWS_IAM,
            })
          ),
          allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
          edgeLambdas: [
            {
              eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
              functionVersion: cdk.aws_lambda.Version.fromVersionArn(
                this,
                "OriginRequestSigv4SignerFn",
                lambdaEdgeFunctionArn
              ),
              includeBody: true,
            },
          ],
          originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
        },
        httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
        webAclId,
      }
    );
    const cloudfrontDistributionId = distribution.distributionId;
    const cfnDistribution = distribution.node
      .defaultChild as cloudfront.CfnDistribution;

    // OAC設定（OAIが自動で設定されてしまうため、削除してOACを追加）
    cfnDistribution.addPropertyOverride(
      "DistributionConfig.Origins.0.OriginAccessControlId",
      originAccessControlForMain.attrId
    );

    // リソースベースのポリシーステートメントを追加
    new lambda.CfnPermission(this, "AllowCloudFrontServicePrincipalForMain", {
      action: "lambda:InvokeFunctionUrl",
      functionName: mainHandlerFunc.functionArn,
      principal: "cloudfront.amazonaws.com",
      sourceArn: `arn:aws:cloudfront::${cdk.Aws.ACCOUNT_ID}:distribution/${cloudfrontDistributionId}`,
    });
  }
}


```

us-east-1-stack.tsでは、Lambda@Edgeの設定とWAFの設定を行っています。
スタックのリージョンを分けている理由は、Lambda@Edge、CloudFrontのWAFが共にus-east-1しか対応していないためです。
今回WAFのルールはレートベースでかけていますが、お好みで設定して下さい。

```typescript:'us-east-1-stack.ts'
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as wafv2 from "aws-cdk-lib/aws-wafv2";
import { CrossRegionParameter } from "@alma-cdk/cross-region-parameter";
import { Construct } from "constructs";

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

    // Lambda@Edge
    const lambdaEdgeFunction = new lambda.Function(this, "LambdaEdgeFunction", {
      runtime: lambda.Runtime.NODEJS_20_X,
      code: lambda.Code.fromAsset("lib/functions"),
      handler: "cloudFrontRequestHandler.handler",
      memorySize: 128,
      timeout: cdk.Duration.seconds(5),
      role: new iam.Role(this, "LambdaEdgeFunctionRole", {
        assumedBy: new iam.CompositePrincipal(
          new iam.ServicePrincipal("lambda.amazonaws.com"),
          new iam.ServicePrincipal("edgelambda.amazonaws.com")
        ),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName(
            "service-role/AWSLambdaBasicExecutionRole"
          ),
          iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambda_FullAccess"),
        ],
      }),
    });
    new CrossRegionParameter(this, `${id}-Lambda-Edge-Function-Arn`, {
      description: "The ARN of the Lambda@Edge function",
      name: "/main/lambda-edge-function-arn",
      value: lambdaEdgeFunction.currentVersion.functionArn,
      region: "ap-northeast-1",
    });

    // Web ACL
    const blockHighRateAccessRuleName = "block-high-rate-access-rule";
    const blockHighRateAccessRule: wafv2.CfnWebACL.RuleProperty = {
      priority: 100,
      name: blockHighRateAccessRuleName,
      action: { block: {} },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: `${blockHighRateAccessRuleName}`,
      },
      statement: {
        rateBasedStatement: {
          aggregateKeyType: "IP",
          limit: 1000,
        },
      },
    };
    const webACLForMain = new wafv2.CfnWebACL(this, "WebACLForMain", {
      name: "main-web-acl",
      scope: "CLOUDFRONT",
      defaultAction: {
        block: {},
      },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: "main-web-acl",
      },
      rules: [blockHighRateAccessRule],
    });
    new CrossRegionParameter(this, `${id}-WebAcl-Arn`, {
      description: "The ID of the WebACL",
      name: "/main/web-acl-arn",
      value: webACLForMain.attrArn,
      region: "ap-northeast-1",
    });
  }
}

```

# ハマったところ
## Lambda関数URLでPUT/POSTメソッドを使用する場合、x-amz-content-sha256ヘッダーにペイロードする必要がある

GETメソッドだけの場合は下記のサイトの実装のみで問題ありませんでした。

https://dev.classmethod.jp/articles/cloudfront-oac-lambda-url/#toc-lambda

しかし、今回POSTメソッドを使用していたため、x-amz-content-sha256ヘッダーにペイロードハッシュ値を含める必要があり、下記のサイトを参考にさせていただき、実装を修正しました。

https://dev.classmethod.jp/articles/cloudfront-lambda-url-sigv4-signer/#cloudfront-%252B-lambda-%25E9%2596%25A2%25E6%2595%25B0-url-%25E3%2581%25A7%25E3%2582%2582-%25E8%2587%25AA%25E5%2589%258D%25E3%2581%25A7%25E7%25BD%25B2%25E5%2590%258D%25E3%2581%2599%25E3%2582%258C%25E3%2581%25B0-post%252Fput-%25E3%2583%25AA%25E3%2582%25AF%25E3%2582%25A8%25E3%2582%25B9%25E3%2583%2588%25E3%2581%258C%25E5%258F%25AF%25E8%2583%25BD%25E3%2581%25A7%25E3%2581%2599

## Lambda@EdgeのNodeバージョンは20
Lambda@EdgeのNodeバージョンは20だとうまくいき、18だとうまくリクエストを投げられませんでした。

## リージョンの制限
今回使用したLambda@EdgeやCloudFrontのWAFはus-east-1しか対応していないとのことで、CDKのスタックの作成に苦戦しました。
[CrossRegionParameter](https://github.com/alma-cdk/cross-region-parameter)を利用して、SSMのパラメータの保存先を変えることで取り出しが簡単にできるようになったので共有します。

# 参考
- [CloudfrontのOAC を利用した Lambdaの 関数URL実行を試してみた](https://dev.classmethod.jp/articles/cloudfront-oac-lambda-url/)
- [CloudFront + Lambda 関数 URL 構成でPOST/PUT リクエストを行うため Lambda@Edge でコンテンツハッシュを計算する](https://dev.classmethod.jp/articles/cloudfront-lambda-url-sigv4-signer/#cloudfront-%252B-lambda-%25E9%2596%25A2%25E6%2595%25B0-url-%25E3%2581%25A7%25E3%2582%2582-%25E8%2587%25AA%25E5%2589%258D%25E3%2581%25A7%25E7%25BD%25B2%25E5%2590%258D%25E3%2581%2599%25E3%2582%258C%25E3%2581%25B0-post%252Fput-%25E3%2583%25AA%25E3%2582%25AF%25E3%2582%25A8%25E3%2582%25B9%25E3%2583%2588%25E3%2581%258C%25E5%258F%25AF%25E8%2583%25BD%25E3%2581%25A7%25E3%2581%2599)
- [Lambda Function URL Behind CloudFront "InvalidSignatureException" only on POST](https://repost.aws/ja/questions/QUbHCI9AfyRdaUPCCo_3XKMQ/lambda-function-url-behind-cloudfront-invalidsignatureexception-only-on-post?sc_ichannel=ha&sc_ilang=en&sc_isite=repost&sc_iplace=hp&sc_icontent=QUbHCI9AfyRdaUPCCo_3XKMQ&sc_ipos=4)
- [CloudFront+Lambda@Edgeでハマったこと](https://qiita.com/hrkmri/items/eae16870745941dbc9bd)
- [独自ドメインで CloudFront にアクセスできるようにする方法](https://zenn.dev/taiyou/articles/6dc3b1b6d7ebc9)
- [代替ドメイン名 (CNAME) を追加することによって、カスタム URL を使用する](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html)