【セキュリティ】Lambda AuthorizerでHTTP API GatewayへのリクエストをCloudFrontだけに制限する

# はじめに
API Gatewayはリソース作成時にエンドポイントが割り当てられます。
エンドポイントはグローバルに公開されている為、誰でもアクセス可能な状態です。
野ざらしで公開しておくのはセキュリティ的な問題があるため、いくつかの制限が必要です。
今回、エンドポイントへのアクセスをCloudFront経由のみに制限しようとした際に考慮した事について記載しようと思います。

# HTTP APIはWAF使えない
API Gatewayには **REST API** と **HTTP API** の2種類の選択肢があります。
AWS 公式ブログによると、後発のHTTP API は REST API よりも最大 60% のレイテンシー削減、71% のコスト削減が見込めると記載されています。
なので、要件を満たせるのであれば、HTTP APIを選択するケースは多いかと思います。
両者の比較ページを載せておきます。

https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html

ただ、HTTP APIはWAFのサポートをしていないこともあり、WAFを用いてAPI へのアクセスを CloudFront ディストリビューション経由のみに制限することはできません。

>Support for AWS Web Application Firewall (WAF) is currently limited to REST API Gateway. Therefore, restricting access to your API only via your CloudFront Distribution becomes a challenge. In this post, we demonstrate how to utilize HTTP APIs in API Gateway while restricting access to only CloudFront using AWS Lambda Authorizer function.

なので、解決策としてAWS公式のブログで紹介されている **Lambda Authorizerによるアクセス制限** を実際にやってみました。

https://aws.amazon.com/jp/blogs/networking-and-content-delivery/restricting-access-http-api-gateway-lambda-authorizer/





# 今回実施した環境構成概要
対象とする弊社プロダウトの環境は以下構成で構築されています。

- バックエンド環境は、[AWS Serverless Application Model (AWS SAM)](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/what-is-sam.html) を利用して構築
- フロントエンド環境は、[cdk](https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/home.html)を利用して構築
- アプリケーションの前段にCloudFrontを設置


本記事ではSAMを用いて環境構築していますが、上記AWS公式ブログで紹介されている方法はSAMを使わなくても導入できます。
CloudFrontは現状cdkで構築しています。いずれSAMで構築するかもしれませんが、ACMでSSL証明書作るのSAMじゃ無理なので、CloudFrontへのSSL証明書紐づけとかカスタムドメイン紐付けとか考えると、フロントエンド環境はcdkで構築した方が良いという判断をしています。
ということで途中cdkのソースコードが出てきますが、そのあたりはあんまり本題ではないので、気にせずやっていきましょう。

# 仕組みを簡単に説明

AWS公式ブログの図を抜粋させていただくと、こんな感じです。

![スクリーンショット 2024-12-16 16.33.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/aecaf00e-2000-02e4-7797-0b85e6f811d6.png)

簡単に説明すると、
CloudFrontからのリクエストヘッダーに含まれる値を、エンドポイントで呼び出されたLambda関数にてチェックし、OK・NGの判断をするという仕組みです。
上記図を見ていただくとわかるように、ブラウザ側にトークン値が介在しないため、ブラウザのdeveloper toolsで値が見える事もありません。
値はSecretsMagegerに格納されたものを参照します。
さらに、定期的に値を更新する（ローテーションする）ことで漏洩への対策とし、安全を担保しています。

それでは手順に移っていきます。

# Secrets Managerにトークン値を設定する

SAM deploy時にシークレットマネージャーのシークレット値が生成されるようにします。
OriginVerifyTokenというキーで値は32文字のランダム文字列で設定します。
samconfig.yamlで環境毎の変数設定をしているので、それも載せておきます。

```samconfig.yaml
# prod環境の設定（色々と省略しています）
prod:
  deploy:
    parameters:
      parameter_overrides:
        - "ParameterKey=SecretName,ParameterValue=prod-secrets"
```

```template.yaml
# Secrets Managerでのシークレット定義
  Secret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Ref SecretName
      Description: "Secret for hogehogeProject"
      GenerateSecretString:
        SecretStringTemplate: '{"OriginVerifyToken": ""}'
        GenerateStringKey: "OriginVerifyToken"
        PasswordLength: 32  
```

# シークレットのローテーションにLambda関数を設定する

シークレット値のローテーション設定を追加するための記載をします。
ローテーション用のLambda関数をシークレットに設定します。
今回、1日ごとにローテーションする設定とします。

行う処理を段階分けすると、以下となります。
- 新たなtoken値の生成
- 既存シークレットマネージャーのOriginVerifyToken値更新（Lambda関数が参照する値）
- CloudFrontのオリジンに紐づけているカスタムヘッダー値（OriginVerifyToken値）更新
- CloudFrontのキャッシュ削除

```samconfig.yaml
# prod環境の設定（色々と省略しています）
prod:
  deploy:
    parameters:
      parameter_overrides:
        - "ParameterKey=OriginVerifyToken,ParameterValue={{resolve:secretsmanager:prod-secrets:SecretString:OriginVerifyToken}}"
        - "ParameterKey=SecretName,ParameterValue=prod-secrets"
        - "ParameterKey=DistributionId,ParameterValue=hogehoge" # cdk deploy 後に書き換えてsam deploy
        - "ParameterKey=OriginId,ParameterValue=hogehoge" # cdk deploy 後に書き換えてsam deploy
```

```template.yaml
  # Secrets Managerでのシークレットの自動更新設定
  SecretRotationSchedule:
    Type: AWS::SecretsManager::RotationSchedule
    Properties:
      # 先ほど記載したSecretを参照する
      SecretId: !Ref Secret
      RotationLambdaARN: !GetAtt RotateSecretFunction.Arn
      RotationRules:
        AutomaticallyAfterDays: 1 # 1日ごとにローテーション

  # Lambda Functions
  RotateSecretFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: handlers/rotateSecretHandler.lambdaHandler
      Policies:
        - Statement:
          - Effect: Allow
            Action:
              - secretsmanager:UpdateSecret
              - cloudfront:GetDistributionConfig
              - cloudfront:UpdateDistribution
              - cloudfront:CreateInvalidation
            Resource: '*'
      Environment:
        Variables:
          SECRET_NAME: !Ref SecretName
          DISTRIBUTION_ID: !Ref DistributionId
          ORIGIN_ID: !Ref OriginId
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        EntryPoints:
          - handlers/rotateSecretHandler.ts
        External:
          - "@aws-sdk/*"
          - aws-sdk
        Minify: false

  # Permissions
  RotateSecretFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt RotateSecretFunction.Arn
      Principal: secretsmanager.amazonaws.com
```

```handlers/rotateSecretHandler.ts
import { SecretsManagerClient, UpdateSecretCommand } from '@aws-sdk/client-secrets-manager';
import {
  CloudFrontClient,
  GetDistributionConfigCommand,
  UpdateDistributionCommand,
  CreateInvalidationCommand,
} from '@aws-sdk/client-cloudfront';
import { randomBytes } from 'crypto';

const secretsManager = new SecretsManagerClient({});
const cloudFront = new CloudFrontClient({});

const secretName = process.env.SECRET_NAME!;
const distributionId = process.env.DISTRIBUTION_ID!;
const originId = process.env.ORIGIN_ID!;

// 新しいトークンを生成
const rotateSecret = async () => {
  const newToken = randomBytes(32).toString('hex');

  await secretsManager.send(
    new UpdateSecretCommand({
      SecretId: secretName,
      SecretString: JSON.stringify({ OriginVerifyToken: newToken }),
    }),
  );

  return newToken;
};

const updateCloudFrontOriginHeader = async (newToken: string) => {
  // CloudFrontのdistributionの設定を取得
  // 競合の防止: ETagを指定せずに更新を試みると、他のプロセスが設定を変更していた場合に、意図しない上書きが発生する可能性があるため、ETagを指定して更新する
  const { DistributionConfig, ETag } = await cloudFront.send(new GetDistributionConfigCommand({ Id: distributionId }));

  // 更新するoriginを取得
  const origin = DistributionConfig.Origins.Items.find(o => o.Id === originId);
  if (!origin) {
    throw new Error(`Origin with ID ${originId} not found`);
  }

  // カスタムヘッダーを更新
  const customHeader = origin.CustomHeaders.Items.find(h => h.HeaderName === 'x-origin-verify');
  if (customHeader) {
    customHeader.HeaderValue = newToken;
  } else {
    // カスタムヘッダーが存在しない場合は追加
    origin.CustomHeaders.Items.push({
      HeaderName: 'x-origin-verify',
      HeaderValue: newToken,
    });
  }

  // CloudFrontのoriginのカスタムヘッダー値更新
  await cloudFront.send(
    new UpdateDistributionCommand({
      Id: distributionId,
      IfMatch: ETag,
      DistributionConfig,
    }),
  );
};

// CloudFrontのキャッシュ削除
const invalidateCloudFrontCache = async () => {
  const invalidationBatch = {
    Paths: {
      Quantity: 1,
      Items: ['/*'],
    },
    CallerReference: `invalidate-${Date.now()}`,
  };

  await cloudFront.send(
    new CreateInvalidationCommand({
      DistributionId: distributionId,
      InvalidationBatch: invalidationBatch,
    }),
  );

  console.log('CloudFront cache invalidated successfully.');
};

export const lambdaHandler = async () => {
  if (!distributionId || !originId) {
    const errorMessage = 'DistributionId or OriginId is not set';
    console.error(errorMessage);
    throw new Error(errorMessage);
  }

  try {
    const newToken = await rotateSecret();
    await updateCloudFrontOriginHeader(newToken);
    await invalidateCloudFrontCache();
    console.log('Secret rotated and CloudFront origin header updated successfully.');
  } catch (error) {
    console.error('Error rotating secret or updating CloudFront:', error);
  }
};

```

# CloudFrontのオリジン設定する
オリジンにAPI Gatewayを設定します。
リクエストヘッダーに `x-verify-token` が含まれるようにします。
ここはcdkのコードになります。

```cloudfront-stack.ts
import * as cf from 'aws-cdk-lib/aws-cloudfront';
import * as cf_origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';

..
..

// ヘッダーの許可設定
const cachePolicy = new cf.CachePolicy(this, 'CachePolicy', {
  cachePolicyName: 'AllowAuthorizationXOriginVerify',
  headerBehavior: cf.CacheHeaderBehavior.allowList('x-origin-verify'),
});
    
new cf.Distribution(
  ..
  ..
  additionalBehaviors: {
    // apiパスのリクエストはAPI Gatewayへ転送される
    '/api/*': {
      // API_GATEWAY_DOMAIN=hogehogehoge.execute-api.ap-northeast-1.amazonaws.com
      origin: new cf_origins.HttpOrigin(process.env.API_GATEWAY_DOMAIN, {
        customHeaders: {
          'x-origin-verify': secretsmanager.Secret.fromSecretNameV2(
            this,
            'OriginVerifySecret',
            `secrets-name`
            // 値はシークレットのローテーションにより1日ごとに更新される            
          ).secretValueFromJson('OriginVerifyToken').unsafeUnwrap(),
        },
      }
    ),
    viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
    allowedMethods: cf.AllowedMethods.ALLOW_ALL,
    cachePolicy: cachePolicy,
    originRequestPolicy: cf.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER
  },
}
```

# API GatewayにLambda Authorizerを設定する
エンドポイントにリクエストが到達した時点で、Lambda Authorizerに設定されたLambda関数が実行されるよう設定を追加します。
リクエストのヘッダーに含まれる値と、シークレットマネージャーに保存されている値が一致しているかチェックし、その結果に応じてリクエストを許可または拒否します。

```template.yaml
# API Gateway
  hogehogeApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      StageName: api # apiパスで切る
      Auth:
        DefaultAuthorizer: LambdaAuthorizer
        Authorizers:
          LambdaAuthorizer:
            AuthorizerPayloadFormatVersion: 2.0
            FunctionArn: !GetAtt AuthorizerFunction.Arn
            EnableSimpleResponses: true # 2.0形式ではSimpleResponses形式で書くことができるので有効化
            Identity:
              ReauthorizeEvery: 0 # キャッシュ無効化。リクエストごとに認証を行う
              Headers:
                - x-origin-verify

  # Lambda Functions
  AuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: handlers/authorizerHandler.lambdaHandler
      Policies:
        - Statement:
          - Effect: Allow
            Action:
              - secretsmanager:GetSecretValue
            Resource:
              - !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${SecretName}-*'
      Environment:
        Variables:
          ORIGIN_VERIFY_TOKEN: !Ref OriginVerifyToken
          SECRET_NAME: !Ref SecretName
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        EntryPoints:
          - handlers/authorizerHandler.ts
        External:
          - "@aws-sdk/*"
          - aws-sdk
        Minify: false
  # Permissions
  AuthorizerFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt AuthorizerFunction.Arn
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${SrcLogsApi}/*'      
```

```handlers/authorizerHandler.ts
import { APIGatewayTokenAuthorizerEvent } from 'aws-lambda';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const secretsManager = new SecretsManagerClient({});

const getSecretValue = async (secretName: string): Promise<string> => {
  const data = await secretsManager.send(new GetSecretValueCommand({ SecretId: secretName }));
  if ('SecretString' in data) {
    return data.SecretString!;
  }
  throw new Error('SecretString not found in secret');
};

export const lambdaHandler = async (
  event: APIGatewayTokenAuthorizerEvent,
): Promise<{ isAuthorized: boolean; context?: any }> => {
  const originVerify = event.headers?.['x-origin-verify'];

  try {
    const secretName = process.env.SECRET_NAME!;
    const secretString = await getSecretValue(secretName);
    const secretJson = JSON.parse(secretString);
    const originVerifyToken = secretJson.OriginVerifyToken;

    // リクエストのヘッダーに含まれるトークンと、シークレットマネージャーに保存されているトークンが一致しているかチェックする
    if (originVerify === originVerifyToken) {
      return {
        isAuthorized: true,
        context: {
          user: 'user',
        },
      };
    }
  } catch (error) {
    console.error('Error retrieving secret:', error);
  }

  return {
    isAuthorized: false,
  };
};
```

これで設定は完了となります。

# さいごに
シークレットのローテーションとか初めてやったんですが、こういうのやるとセキュリティの意識高まりますね。
それはそうと、HTTP APIのWAF対応早く〜。