serverless frameworkでAPI Gatewayの前にCloudFrontを立てる

ServerlessFrameworkでAPI GatewayとLambdaを利用したAPIを提供していたのですが、脆弱性スキャンで脆弱な暗号スイートを利用できるというのが検出されて対応することにしました。
以下のドキュメントのようにAPI Gatewayの前にCloudFrontを用意すればいいようだったのでその構成に変更しました。
その対応内容についてメモしておきます。

https://aws.amazon.com/jp/blogs/networking-and-content-delivery/securing-amazon-api-gateway-with-secure-ciphers-using-amazon-cloudfront/

# 参考サイト

以下のその参照先のサイトを参考にしました。

https://kdnakt.hatenablog.com/entry/2023/03/18/amazon-api-gateway-tls-information-with-cloudfront

# 対応環境

serverless: 3.33.0
serverless-domain-manager: 7.1.0
serverless-scriptable-plugin: 1.3.1
typescript: 5.1.6
@aws-sdk/client-cloudformation: 3.775.0
@aws-sdk/client-cloudfront: 3.775.0
@aws-sdk/client-route-53: 3.775.0
ts-node: 10.9.1

# 対応内容

## CloudFrontを前段に立てる

以下のようなリソースを追加してAPI Gatewayの前段にCloudFrontを立てられました。
すでにserverless-domain-managerでカスタムドメインを設定しているために、ワイルドカードを用いたエイリアスと証明書でCloudFrontを設定しています（詳細は下記のAPI Gatewayにリクエストがいかないようにする参照）。

```typescript:serverless.ts
  resources: {
    Resources: {
      CloudFrontDistribution: {
        Type: "AWS::CloudFront::Distribution",
        Properties: {
          DistributionConfig: {
            Enabled: true,
            Aliases: [ {
              // api.example.comのドメインを*.example.comに変える。
              "Fn::Join": ["*.", {
                "Fn::Split": [ "api.", "${file(config/${self:provider.stage}.yml):domainName}"]
              }]
           }],
            ViewerCertificate: {
              // *.example.comの証明書のARN
              AcmCertificateArn: "${file(config/${self:provider.stage}.yml):certificateArn}",
              MinimumProtocolVersion: "TLSv1.2_2021",
              SslSupportMethod: "sni-only",
            },
            Origins: [
              {
                Id: "ApiGateway",
                DomainName: { 'Fn::Join': [".", [
                  { Ref: "ApiGatewayRestApi" },
                  "execute-api",
                  { Ref: "AWS::Region" },
                  "amazonaws.com"
                ]]},
                OriginPath: "/${self:provider.stage}",
                CustomOriginConfig: {
                  OriginProtocolPolicy: "https-only",
                },
                // CloudFrontからのリクエストのみ許可する
                /*OriginCustomHeaders: [
                  {
                    HeaderName: "Referer",
                    HeaderValue: awsReferValue,
                  }
                ]*/
              }
            ],
            DefaultCacheBehavior: {
              AllowedMethods: [
                "HEAD", "DELETE", "POST", "GET", "OPTIONS", "PUT", "PATCH"
              ],
              TargetOriginId: "ApiGateway",
              ViewerProtocolPolicy: "https-only",
              ForwardedValues: {
                QueryString: false,
              },
              // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-policy-caching-disabled
              // CachingDisabled
              CachePolicyId: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
              // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html
              // AllViewerExceptHostHeader
              OriginRequestPolicyId: "b689b0a8-53d0-40ab-baf2-68738e2966ac",
            }
          }
        }
      }
    }
  }
```

## ドメインを移行する。

serverless-domain-managerを利用して以下のようにcustomDomainを指定してAPIを提供していましたが、enabled: falseを追加して無効化しました。また、serverless-scriptable-pluginを用いてdeploy実行後にRoute53のレコードを更新して追加したCloudFrontのAliasを設定するようにしています。

```typescript:serverless.ts
  plugins: [
    'serverless-domain-manager',
    'serverless-scriptable-plugin', // <= 追加
  ],

  custom: {
    customDomain: {
      enabled: false, // <= 追加
      domainName: "${file(config/${self:provider.stage}.yml):domainName}",
      certificateName: "${file(config/${self:provider.stage}.yml):certificateName}",
      basePath: '',
      createRoute53Record: true,
      stage: '${self:provider.stage}'
    },

    scriptHooks: { // <= 追加
      'after:deploy:finalize': "yarn run ts-node src/scripts/register_record.ts " + 
      "--stage ${self:provider.stage} " +
      "--domain ${file(config/${self:provider.stage}.yml):domainName}",
      'before:remove:remove': "yarn run ts-node src/scripts/register_record.ts " + 
      "--stage ${self:provider.stage} " +
      "--domain ${file(config/${self:provider.stage}.yml):domainName} " +
      "--unregister",
    },
  },
```

レコード更新用のスクリプト

```typescript:src/scripts/register_record.ts
import { 
    Route53Client, 
    ListHostedZonesByNameCommand, 
    ChangeResourceRecordSetsCommand,
    ChangeResourceRecordSetsCommandInput,
} from "@aws-sdk/client-route-53";

import {
    CloudFormationClient,
    DescribeStackResourceCommand,
    DescribeStacksCommand,
} from "@aws-sdk/client-cloudformation";

import {
    CloudFrontClient,
    GetDistributionCommand
} from "@aws-sdk/client-cloudfront";

import { Command } from "commander";

// レコード登録/削除
async function registerRecord(recordname: string, dnsname: string, isUnregister: boolean = false) {
    const [, ...otherElems] = recordname.split(".")

    const clientRoute53 = new Route53Client({ region: "us-east-1" });
    const command = new ListHostedZonesByNameCommand({
        DNSName: otherElems.join("."),
        MaxItems: 1,
    });
    const response = await clientRoute53.send(command);
    if (response.HostedZones[0].Name !== otherElems.join(".") + ".") {
        console.log(response.HostedZones[0].Name, otherElems.join("."));
        throw new Error("Hosted zone not found");
    }
    const zoneId = response.HostedZones[0].Id;

    const inputRecord:ChangeResourceRecordSetsCommandInput = {
        HostedZoneId: zoneId,
        ChangeBatch: {
            Comment: "",
            Changes: [
                {
                    Action: isUnregister? "DELETE" : "UPSERT",
                    ResourceRecordSet: {
                        Name: recordname,
                        Type: "A",
                        AliasTarget: {
                            HostedZoneId: "Z2FDTNDATAQYW2",
                            DNSName: `${dnsname}.`,
                            EvaluateTargetHealth: false,
                        }
                    }
                },
                {
                    Action: isUnregister? "DELETE" : "UPSERT",
                    ResourceRecordSet: {
                        Name: recordname,
                        Type: "AAAA",
                        AliasTarget: {
                            HostedZoneId: "Z2FDTNDATAQYW2",
                            DNSName: `${dnsname}.`,
                            EvaluateTargetHealth: false,
                        }
                    }
                }
            ]
        }
    };

    const commandRecord = new ChangeResourceRecordSetsCommand(inputRecord);
    await clientRoute53.send(commandRecord);
}

// ServrlessFrameworkのCloudFormationからCloudFrontのドメイン名取得
async function getCloudFormationDistribution(stackName: string, isApiDistribution: boolean = false) {
    const clientCF = new CloudFormationClient({ region: "us-east-1"});
    if (isApiDistribution) {
        const command = new DescribeStacksCommand({
            StackName: stackName,
        });
        const response = await clientCF.send(command);
        return response.Stacks[0].Outputs.find((value) => {
            return value.OutputKey == "DistributionDomainName";
        }).OutputValue;
    }
    else {
        const command = new DescribeStackResourceCommand( {
            StackName: stackName,
            LogicalResourceId: "CloudFrontDistribution",
        });
        const response = await clientCF.send(command);
        const pysicalId = response.StackResourceDetail.PhysicalResourceId;
    
        const clientCFD = new CloudFrontClient({ region: "us-east-1"});
        const commandCFD = new GetDistributionCommand({
            Id: pysicalId,
        });
        const responseCFD = await clientCFD.send(commandCFD);
        return responseCFD.Distribution.DomainName;
    }
}

// メイン処理
async function main() {
   const command = new Command();
   command.requiredOption("-s, --stage <stage>", "stage name(exsample: develop)");
   command.requiredOption("-d, --domain <domain>", "domain name(exsample: api.example.com)");
   command.option("-r, --revert", "revert record", false);
   command.option("--unregister", "unregister record", false);
   command.parse(process.argv);

   const domainName = await getCloudFormationDistribution(`api-${command.opts().stage}`, command.opts().revert);
   console.log(`change to ${domainName}`);
 
   await registerRecord(command.opts().domain, domainName, command.opts().unregister);
   console.log("change record done");
}

main();
```

### API Gatewayにリクエストがいかないようにする

上記の設定でデプロイしただけでは以下の重複する代替ドメイン名の制限によりAPI Gatewayの方にリクエストが行き続けます。
（最初はRoute53のレコードをCloudFrontのものに更新しているのにAPI Gatewayにリクエストが行き続けてハマってました。）
API Gatewayのカスタムドメインの設定を削除することでCloudFrontの方にアクセスが行くようになります。
API Gatewayのカスタムドメイン削除後はCloudFrontをワイルドカードを使わないAliasや証明書にしても良いかもしれません。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html#alternate-domain-names-restrictions

なお、最初はAPI Gatewayにアクセスがいってしまうために、以下の記事のようにRefererを使ったりしてCloudFrontへのアクセスのみを許可してAPI Gatewayにアクセスするのを許可しない場合はアクセスできない時間ができてしまいます。
CloudFrontにアクセスが行くようになった後、API Gatewayの直接アクセスを禁止する2段階のデプロイを行えば良さそうですが完全にAPIに影響しないでいけるものなのかは調査していません。

https://blog.grasswake.me/tech-posts/1646529783

## Refererを使ってAPI Gatewayに直接アクセスできないようにする

以下を追加して上記の、CloudFrontを前段に立てる、の該当のコメント部分を有効化します。

```typescript:serverless.ts
const awsReferValue = "hoge"

const serverlessConfiguration: AWS = {
  provider: {
    apiGateway: {
      // CloudFrontからのリクエストのみ許可する
      resourcePolicy: [
        {
          Effect: "Deny",
          Principal: "*",
          Action: "execute-api:Invoke",
          Resource: [ "execute-api:/*" ],
          Condition: {
            StringNotEquals: {
              "aws:Referer": awsReferValue
            }
          }
        },
        {
          Effect: "Allow",
          Principal: "*",
          Action: "execute-api:Invoke",
          Resource: [ "execute-api:/*" ],
        }
      ]
    }
  }
}
```