[AWS CDK] CloudFront Functionでレスポンスにセキュリティヘッダーを追加する

2021.07.17

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

今までCloudFront + S3という構成でクライアントへのレスポンスヘッダーをカスタマイズしたい場合はLambda@Edgeを使う場合が多かったですが、最近リリースされた、よりユーザーに近いロケーションでより高速な処理が可能なCloudFront Functionsでも実装が可能です。

今回は、このCloudFront Functionを使用してレスポンスにセキュリティヘッダーを追加する設定をAWS CDKで実装してみました。

CloudFront Functionコード

lambda/add-header/index.js

function handler(event) {
  var response = event.response;
  var headers = response.headers;

  // Set HTTP security headers
  // Since JavaScript doesn't allow for hyphens in variable names, we use the dict["key"] notation 
  headers['strict-transport-security'] = { value: 'max-age=63072000; includeSubdomains; preload'};
  //headers['content-security-policy'] = { value: "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'"}; 
  headers['x-content-type-options'] = { value: 'nosniff'}; 
  headers['x-frame-options'] = {value: 'DENY'};
  headers['x-xss-protection'] = {value: '1; mode=block'};

  // Return the response to viewers 
  return response;
}

コードはAWS公式のサンプルを参考にしました。Lambda@Edgeとは記法が異なることに注意してください。今回利用したWebコンテンツ(React)だとcontent-security-policyヘッダーは制限が厳しかったため元のコードから削除しました。

CDKコード

CloudFront + S3 + CloudFront Function から成る静的ホスティングなWebサイトです。

lib/sample-app-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";

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

    const accountId = cdk.Stack.of(this).account;

    const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
      bucketName: `sample-app-${accountId}`,
      websiteErrorDocument: "index.html",
      websiteIndexDocument: "index.html",
    });

    const websiteIdentity = new cloudfront.OriginAccessIdentity(
      this,
      "WebsiteIdentity",
      {
        comment: `sample-app-identity`,
      }
    );

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

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    // CloudFront Functionリソースの定義
    const addHeaderFunction = new cloudfront.Function(
      this,
      "AddHeaderFunction",
      {
        functionName: `add-header`,
        code: cloudfront.FunctionCode.fromFile({
          filePath: "lambda/add-header/index.js",
        }),
      }
    );

    const websiteDistribution = new cloudfront.CloudFrontWebDistribution(
      this,
      "WebsiteDistribution",
      {
        comment: `website-distribution`,
        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,
                // CloudFront FunctionをDistributionに設定
                functionAssociations: [
                  {
                    eventType: cloudfront.FunctionEventType.VIEWER_RESPONSE,
                    function: addHeaderFunction,
                  },
                ],
              },
            ],
          },
        ],
        priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
      }
    );

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

DistributionへのFunctionの紐付けは、Lambda@Edgeの場合はlambdaFunctionAssociationsを使用しますが、CloudFront Functionの場合はfunctionAssociationsを使用することに注意してください。またヘッダー追加はビューワーへのレスポンス時に行うため、eventTypeの指定はVIEWER_RESPONSEとします。

cdk deployによりスタックをデプロイします。

AWSコンソールでのFunctionのテスト

CloudFront > Functionsで作成したFunctionを選択します。

アップロードされたコードが確認できます。[Test]タブをクリックします。

[Test]をクリックしてテストを実行してみます。

するとエラーとなりました。

ログを見るとheadersプロパティを取得できなかったからのようです。

Error Message: The CloudFront function associated with the CloudFront distribution is invalid or could not run. TypeError: cannot get property "headers" of undefined

次は[Sample test events]でViewer response with headersを指定し、[Test]をクリックして実行します。

今度はテストが成功しました。

実際にブラウザからアクセス

Webサイトにアクセスしてブラウザのデバッグツールからレスポンスヘッダーを見ると、セキュリティヘッダーが追加されていることが確認できました。

注意点

Lambda@Edge(L@E)と異なりCloudFront Function(CF2)のレスポンスはキャッシュされないため、今まで通りL@Eを使用した方が低コストな場合もあるようです。以下の岩田の記事にとてもよくまとめられています。

理由としては、L@Eならオリジンレスポンスでのヘッダー追加がキャッシュされるため初回アクセスでの実行のみで済むところが、CF2ならビューワーレスポンスによりアクセス毎に必ず実行されることにされるためです。 CloudFront Functions の導入 – 任意の規模において低レイテンシーでコードをエッジで実行 | Amazon Web Services ブログより引用

ただし岩田も記事末尾で述べている通り、キャッシュの有効期限やキャッシュヒット率、ブラウザキャッシュなども考慮する必要があり、どちらが適しているかは配信するウェブサイトの特性によっても変わってきます。またFunctionの実行時間はCF2は最大1ミリ秒のため、体感的なレイテンシーはほぼ無さそうです。L@EとCF2両者の特性を踏まえた上で適切なサービスを選択するようにしましょう。

おわりに

CloudFront Functionを使用してレスポンスにセキュリティヘッダーを追加する設定をAWS CDKで実装してみました。

今までLambda@Edge Functionで行っていた処理をCloudFront Functionに寄せることが出来るようになることで、ユーザー観点だと処理の高速化に期待が持てますし、また開発者観点だとフロントエンドのCDKのコードにLambdaのパッケージを含める必要が無くなり、CloudFrontに一本化できるようになるのが嬉しいところです。

参考

以上