Lambda@Edge で動的にセキュリティヘッダーを付与する

春の陽気を感じられる良い季節になりました。天気の良い日に鴨川で日向ぼっこをしたい、そんな今日この頃。

どうも、Novelworks でエンジニアをやっております RikiyaOta です。


今回は、業務の中で検証した[AWS Lambda@Edge](https://aws.amazon.com/jp/lambda/edge/)を使って動的にセキュリティヘッダーを付与する機能についてまとめてみようと思います。

# この記事で伝えたいこと

- S3+CloudFront の構成で Lambda@Edge でセキュリティヘッダーを付与する方法
- Lambda@Edge を実際に動かすときの注意点


# 課題意識と設定

自社サイトが[クリックジャッキング](https://www.ipa.go.jp/security/vuln/websecurity-HTML-1_9.html)による攻撃を受けないようにしたいという課題意識で、対策を考えてみたいと思います。


以下のようなケースを考えることとします：


- システム構成
    - 自社で公開しているウェブサイト[^1]がある。
    - このウェブサイトのコンテンツは S3 に配置しており、CloudFront 経由で配信している。
- 問題意識
    - このページを、外部の悪意を持ったサイト上で`<iframe>`による埋め込みをされたくない。
- その他要件
    - しかし、一部の許可したサイトでは`<iframe>`による埋め込みによって自社のウェブサイトのページを利用して良いという形を取りたい。
    - このような埋め込みを許可するサイトは固定ではなく、後で変えられるようにしたい。



技術的には、自社サイトのページをクライアントに返す際に、HTTP ヘッダー[`Content-Security-Policy`](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Security-Policy)の[`frame-ancestors`ディレクティブ](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors)に、埋め込みを許可するサイトを指定することで、上記のような対策を施すことができます。

ただし、上述のように、許可するサイトは後で変えられるようにしたいため、`frame-ancestors`ディレクティブにセットする値は固定、というわけにはいきませんでした🤔


今回のような S3+CloudFront によるコンテンツ配信の場合に、上記のような HTTP ヘッダーを動的に付与するという処理を実現できないか調査・検討してみたところ、Lambda@Edge を使う方法があることを知り、実際に検証をしてみました。


# Lambda@Edge とはそもそも何か？

> Lambda@Edge は、Amazon CloudFrontの機能で、アプリケーションのユーザーに近いロケーションでコードを実行できるため、パフォーマンスが向上し、待ち時間が短縮されます。Lambda@Edge では、世界中の複数のロケーションでインフラストラクチャをプロビジョニングまたは管理する必要はありません。課金は実際に使用したコンピューティング時間に対してのみ発生し、コードが実行されていないときには料金も発生しません。

引用：https://aws.amazon.com/jp/lambda/edge/

つまり、CloudFront を使った構成のシステムにおいて、CloudFront のエッジサーバー上でコードを実行することができます。

その1つの利用例として、CloudFront からレスポンスを返す際にセキュリティヘッダーを付与するというものがあり、今回はそのアプローチを取ってみました。

また、CloudFront Functions という似たような機能もあるのですが、後述するように、今回は DynamoDB へのアクセスをする必要があったため、採用を見送りました。
両者の違いについては、[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions.html)をご参照ください。


# 検証内容の具体的な説明

それでは、具体的に取り組んだ構成やコードについて説明をしていきます。
なお、ここでは Lambda@Edge の説明にできるだけ絞る形で、簡略化したものを説明したいと思います。

## AWS リソース構成

構成は以下のようなものとします：


![qiita-article--lambda-edge (2).drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243500/abef24ed-7ca8-072c-403e-15d0d54aed39.png)


それぞれの要素の役割は以下とします：

- S3 Bucket: 静的コンテンツを配置している。
- CloudFront: 静的コンテンツを配信する。
- DynamoDB: `Content-Security-Policy: frame-ancestors`で埋め込みを許可したいサイトの情報が記録されている。
- Lambda@Edge: Origin Response イベントで起動し、DynamoDB から読み取った自社サイトの情報をレスポンスヘッダーにセットする。


なお、Lambda@Edge を起動できるイベントは４種類あります。
今回は、キャッシュヒットしなかった場合に Lambda 関数を起動するようにしたく、Origin Response を選択しました。

より詳しい CloudFront イベントにつきましては、[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html)をご参照ください。

## DynamoDB テーブル

以下のようなテーブルを1つ用意しているものとします：


- Table Name: embedding-permitted-sites
- Partition Key: id (number)
- その他のフィールド：
    - host_source: 埋め込みを許可したいサイトの URL スキームと FQDN

実際に格納されているデータは以下とします：

| id | host_source            |
|:--:|:---------------:|
| 1  | http://*****.s3-website-us-east-1.amazonaws.com |

（今回は、`<iframe>`を設置するサイトを簡易的に S3 で用意しました）

この記録されたサイトの情報を、レスポンスヘッダーに動的に追加する処理を Lambda@Edge で実現してみたいと思います。

## Lambda@Edge のコード

ランタイムは Node.js を採用します。
以下のようなコードで Lambda 関数をデプロイします。実際の構築手順の細かな点は[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html)を参照してください。


```javascript:index.js
const { DynamoDBClient, GetItemCommand } = require("@aws-sdk/client-dynamodb");

const getPermittedSiteItem = (id) => {
    const client = new DynamoDBClient({region: "ap-northeast-1"});
    const input = {
        TableName: "embedding-permitted-sites",
        Key: {id: {N: ` ${id}` }},
    };
    const command = new GetItemCommand(input);
    return client.send(command);
};

exports.handler = async (event, context, callback) => {
    const response = event.Records[0].cf.response;
    const headers = response.headers;

    // 埋め込みを許可したいサイトの host_source を取得
    // ここでは、id = 1 を決め打ちにしています。システムの要件次第で、
    // 動的に host_source を決めることが実際の利用シーンでは必要になるかと思います。
    const item = await getPermittedSiteItem(1);
    const hostSource = item.Item.host_source.S;

    // レスポンスヘッダーをセット！
    headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: ` frame-ancestors ${hostSource};` }];

    return response;
};
```

上記コードのコメントに記載していますが、DynamoDB Table から許可したいサイトの情報を取得するためのロジックを、システムの要件に応じて適切に決めることが必要になるかと思います。



## 動作確認

それでは、Lambda@Edge によりセットされた`Content-Security-Policy: frame-ancestors`ヘッダーが期待通りの動きをしているか確認してみましょう！

今回、以下2つのWebページを用意しました：

| 役割 | URL |
|:----------:|:-----------:|
| 自社サイト      | https://*****.cloudfront.net/sample.html |
| 埋め込み許可したいサイト    | http://*****.s3-website-us-east-1.amazonaws.com/index.html |


それぞれのページの内容は以下とします：

```html:https://*****.cloudfront.net/sample.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>Lambda@Edge でセキュリティ対策をやってみた</title>
  </head>
  <body>
    <p>Lambda@Edge でセキュリティ対策をやってみた紹介記事の資料です。</p>
  </body>
</html>
```

```html:http://*****.s3-website-us-east-1.amazonaws.com/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
      <meta charset="UTF-8">
      <title>Sample Web Site</title>
  </head>
  <body>
    <p>これは iframe でページを埋め込むために用意した仮の Web サイトです。</p>
    <p>このページに iframe タグを配置し、埋め込みが禁止されることを確認します。</p>
    <iframe src="https://*****.cloudfront.net/sample.html"></iframe>
  </body>
</html>
```

`<iframe>`によって、自社のサイトのページを埋め込んで表示しようと試みています。
まさにこういった操作を制御したい！というのがそもそもの目的でした。

準備が整ったので、実際に http://*****.s3-website-us-east-1.amazonaws.com にアクセスしてみましょう〜。


![埋め込み許可001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243500/c357f64e-ce47-679e-fbcd-82211385b911.png)


無事に、`<iframe>`内でページを開くことができました！
DynamoDB に許可したいサイトとして登録していたことがどうやら効いている気がしますね。もう少し詳しくみていきましょう。

今回は Chrome で上記ページを開きました。Google Chrome Developer Tools で通信の内容を見てみると、


![埋め込み許可002-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243500/81ad63bf-52c7-0b02-ed9b-703fd0330a36.png)



確かに、DynamoDB に保存した　host_source の値が`Content-Security-Policy`ヘッダーにセットされていることがわかります！


では今度は、DynamoDB で保存している host_source の値を変更して、再度ページを開いてみましょう。

DynamoDB に保存しているデータを以下のように更新したとします：

| id | host_source            |
|:--:|:---------------:|
| 1  | https://example.com |

その上で、再び http://*****.s3-website-us-east-1.amazonaws.com　にアクセスしてみます。


![埋め込み禁止001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243500/87c6da1b-2eff-f987-9020-651ea750fdbb.png)

おおお、表示されないように制御することができました🥳🥳🥳

通信の内容も確認してみると、


![埋め込み禁止002-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243500/a9079a80-8d96-c00d-8317-1c6d7fbcd37a.png)


となっており、確かに https://example.com が`Content-Security-Policy: frame-ancestors`にセットされております。

また、ブラウザのログも確認してみると、以下のようなログが出力されており、実際にこのヘッダーの値が効いており、`<iframe>`による表示を禁止できていることが確認できました。

![埋め込み禁止003-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243500/8244e7b2-9af0-c892-4ddd-f58b489625eb.png)


# 詰まった点

詰まったのは、

**Lambda@Edge 関数が実行される場所が、アクセス元の地理的位置によって変わりうる**

ということでした。


CloudFront が世界中にエッジサーバーを配置し、地理的に近いロケーションにルーティングすることで高速にコンテンツを配信していることを考えれば、実は当たり前なのですが、具体的には以下2点で詰まりました：


1. Lambda 関数のログ（CloudWatch Logs）がどこにあるか分からん・・・🥺
1. Lambda 関数から DynamoDB にアクセスするとき、テーブルが見つからないことがある・・・🥺


# 詰まった点の解決


1点目については、[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions-logs.html)に以下の記載がありました。

> Lambda@Edge は、関数ログを CloudWatch Logs に自動的に送信し、関数が実行される AWS リージョン にログストリームを作成します。ロググループ名は /aws/lambda/us-east-1.function-name の形式です。ここで、function-name は作成時に関数に付けた名前であり、us-east-1 は関数が実行された AWS リージョン のリージョン コードです。

日本からのアクセスではおそらく東京リージョンで Lambda 関数が実行されるので、東京リージョンで CloudWatch Logs を確認することで、無事にログを確認することができました。


2点目については、こちらも Lambda 関数が実行される場所・リージョンが変わりうることに起因していました。

検証に用いた Lambda　関数のコードを再掲します：

```javascript:index.js
const { DynamoDBClient, GetItemCommand } = require("@aws-sdk/client-dynamodb");

const getPermittedSiteItem = (id) => {
    // 実はここで DynamoDB テーブルのリージョンを指定している。
    const client = new DynamoDBClient({region: "ap-northeast-1"});
    const input = {
        TableName: "embedding-permitted-sites",
        Key: {id: {N: `${id}`}},
    };
    const command = new GetItemCommand(input);
    return client.send(command);
};

exports.handler = async (event, context, callback) => {
    const response = event.Records[0].cf.response;
    const headers = response.headers;

    const item = await getPermittedSiteItem(1);
    const hostSource = item.Item.host_source.S;

    headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: `frame-ancestors ${hostSource};`}];

    return response;
};
```

実は（こっそりと）DynamoDB テーブルのリージョンを明示的に指定していました。
もしこれがないと、例えば Lambda 関数がシンガポールリージョン(ap-southeast-1)で実行されてしまうと、シンガポールリージョン内で DynamoDB テーブルにアクセスをしに行くため、テーブルが見つからないことによってエラーが発生してしまいます。

以下は、今回の Lambda 関数のコードから、DynamoDB のリージョン指定を省略して、アメリカからアクセスした際に発生したエラーログです。

```
2023-03-24T08:17:30.902Z	b68f1c14-394e-43df-b4de-9921f16bdf62	ERROR	Invoke Error 	{
    "errorType": "AccessDeniedException",
    "errorMessage": "User: arn:aws:sts::*****:assumed-role/add-security-header-function-role/add-security-header-function is not authorized to perform: dynamodb:GetItem on resource: arn:aws:dynamodb:us-east-1:*****:table/embedding-permitted-sites because no identity-based policy allows the dynamodb:GetItem action",
    "name": "AccessDeniedException",
    ~~~~
    ~~~~
```

DynamoDB テーブルの ARN が`arn:aws:dynamodb:us-east-1:*****:table/embedding-permitted-sites`と表示されていることから、確かに、実際に Lambda 関数が実行されているリージョンで DynamoDB を探していることがわかります。

日本から動作確認しているだけだと、おそらく気づくことができませんでした。
気づいたときはむしろ安心しましたが、実際にシステムに組み込んでいく際に注意が必要な点だと思います。


# まとめ

今回は、S3+CloudFront の構成において、HTTP ヘッダーに動的に値をセットする処理を実現するために、Lambda@Edge を利用する方法について紹介しました。

もう少し単純な処理ならば、CloudFront Functions の方が向いているケースは多々あると思いますが、
今回の記事で検証したように、DB を使った処理の場合は、Lambda@Edge を使うと簡単に実現できそうですね😚


# 参考・引用したドキュメント・資料・記事など


- https://aws.amazon.com/jp/lambda/edge/
- https://www.ipa.go.jp/security/vuln/websecurity-HTML-1_9.html
- https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Security-Policy
- https://aws.amazon.com/jp/blogs/networking-and-content-delivery/adding-http-security-headers-using-lambdaedge-and-amazon-cloudfront/
- https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html
- https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions-logs.html


[^1]: 弊社のホームページではありません。