CloudFrontのELBオリジンへ直接アクセスする通信を制限する方法

Amazon_CloudFront

はじめに

こんにちは、中山です。

CloudFrontのオリジンにパブリックなELBを設置する構成はよくあると思います。マルチオリジン構成で静的コンテンツはS3に、動的コンテンツはELBへという構成です。この構成における問題点としてよく聞くのが、CloudFrontを経由せずELBに直接アクセスさせたくないというものです。S3がオリジンの場合はオリジンアクセスアイデンティティを利用して、アクセスをCloudFrontにのみ制限することが可能です(詳細は[CloudFront + S3]特定バケットに特定ディストリビューションのみからアクセスできるよう設定する)。しかしELBの場合は現時点(2016/08/15)ではこういった機能はありません。ELBにはIAMポリシーを紐付けられないからです。また、ELBに紐付けるセキュリティグループで送信元をCloudFrontにできればよいのですが、そういった機能もありません。CloudFrontはVPC外で動作するため、セキュリティグループの送信元として指定できないからです。

今回この問題に対して2つの解決案を試してみました。それぞれ以下にご紹介します。

  • CloudFrontのIPレンジを利用した制限
  • CloudFrontのカスタムヘッダを利用した制限

CloudFrontのIPレンジを利用した制限

2016年12月1日追記
CloudFrontのリージョナルエッジキャッシュ導入により、CloudFrontで利用しているIPレンジが大幅に増えたようです。そのため、こちらのエントリで紹介しているスクリプトをそのまま実行してしまうとセキュリティグループのインバウンドルールデフォルト上限である50に引っかかってしまうようです。お使いになる際はご注意ください。ワークアラウンドとしては上限緩和申請などが考えられます。

CloudFrontのエッジロケーションで利用しているネットワークアドレスを、ELBのセキュリティグループの送信元に指定することにより、アクセスを制限する方法です。シンプルな発想なのでネット上にも散見される方法です。今回はAWSのGitHubにある以下のリポジトリをご紹介します。

CloudFrontのIPレンジを取得し、変更があった場合はセキュリティグループのインバウンドルールを更新するLambda関数です。CloudFrontのIPレンジはhttps://ip-ranges.amazonaws.com/ip-ranges.jsonで公開されています。このJSONファイルに変更が加わった場合、「arn:aws:sns:us-east-1:806199016981:AmazonIpSpaceChanged」というトピックにパブリッシュされます。詳細についてはこちらのドキュメントが詳しいです。このトピックをLambda関数の起動元に設定することで、最小限の起動回数で常に最新のIPレンジを参照できるというわけです。

使い方

例によってApexを使ったLambda関数のデプロイ用リポジトリを作りました。ご自由にお使いください。

aws-setup というディレクトリにCloudFrontなどの検証用環境をセットアップするTerraformのコードを置いているので、お好みでご利用ください。デプロイ方法については以下のエントリを参照してください。

動作確認

まずはLambda関数を実行する前の動作を確認してみます。CloudFrontとELBのドメインはご自身の環境に置き換えてください。

この時点のセキュリティグループは以下のように80番ポートがフルオープンになっています。

$ aws ec2 describe-security-groups \
  --query 'SecurityGroups[?Tags[?Value==`cloudfront`]].IpPermissions'
[
    [
        {
            "PrefixListIds": [],
            "FromPort": 80,
            "IpRanges": [
                {
                    "CidrIp": "0.0.0.0/0"
                }
            ],
            "ToPort": 80,
            "IpProtocol": "tcp",
            "UserIdGroupPairs": []
        }
    ]
]

まずCloudFrontにアクセスしてみます。

$ curl -I <cloudfront-domain>
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 3770
Connection: keep-alive
Accept-Ranges: bytes
Date: Sun, 14 Aug 2016 04:34:34 GMT
ETag: "575f1ada-eba"
Last-Modified: Mon, 13 Jun 2016 20:43:06 GMT
Server: nginx/1.8.1
X-Cache: Miss from cloudfront
Via: 1.1 8c035d44fd4c5d1bea046227d56bc015.cloudfront.net (CloudFront)
X-Amz-Cf-Id: uaY0-LLjfCzLFL2LKlh07IjPnxlFR_ED2GdK57zNHtaMwdTBkkS_ew==

アクセスできました。続いてELBに直接アクセスしてみます。

$ curl -I <elb-domain>
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 3770
Content-Type: text/html
Date: Sun, 14 Aug 2016 04:35:30 GMT
ETag: "575f1ada-eba"
Last-Modified: Mon, 13 Jun 2016 20:43:06 GMT
Server: nginx/1.8.1
Connection: keep-alive

こちらもアクセス可能です。ではLambda関数を実行してセキュリティグループをアップデートしてみましょう。IPレンジが記載されたJSONファイルが更新されるとLambda関数を起動するSNSトピックから、以下のようなイベントが通知されます。

{
  "Records": [
    {
      "EventVersion": "1.0",
      "EventSubscriptionArn": "arn:aws:sns:EXAMPLE",
      "EventSource": "aws:sns",
      "Sns": {
        "SignatureVersion": "1",
        "Timestamp": "1970-01-01T00:00:00.000Z",
        "Signature": "EXAMPLE",
        "SigningCertUrl": "EXAMPLE",
        "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e",
        "Message": "{\"create-time\": \"yyyy-mm-ddThh:mm:ss+00:00\", \"synctoken\": \"0123456789\", \"md5\": \"923813ed93b24942adbbda63882b8d0c\", \"url\": \"https://ip-ranges.amazonaws.com/ip-ranges.json\"}",
        "Type": "Notification",
        "UnsubscribeUrl": "EXAMPLE",
        "TopicArn": "arn:aws:sns:EXAMPLE",
        "Subject": "TestInvoke"
      }
    }
  ]
}

これをファイルに保存してApexコマンドに渡してあげればLambda関数が実行されます。

$ cat test.json | apex invoke update_security_groups_lambda --logs
START RequestId: 87ebcc81-6203-11e6-914a-078d8bc8d2a6 Version: 7
Received event: {
  "Records": [
    {
      "EventVersion": "1.0",
      "EventSubscriptionArn": "arn:aws:sns:EXAMPLE",
      "EventSource": "aws:sns",
      "Sns": {
        "SignatureVersion": "1",
        "Timestamp": "1970-01-01T00:00:00.000Z",
        "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e",
        "SigningCertUrl": "EXAMPLE",
        "Signature": "EXAMPLE",
        "Message": "{\"create-time\": \"yyyy-mm-ddThh:mm:ss+00:00\", \"synctoken\": \"0123456789\", \"md5\": \"923813ed93b24942adbbda63882b8d0c\", \"url\": \"https://ip-ranges.amazonaws.com/ip-ranges.json\"}",
        "Type": "Notification",
        "UnsubscribeUrl": "EXAMPLE",
        "TopicArn": "arn:aws:sns:EXAMPLE",
        "Subject": "TestInvoke"
      }
    }
  ]
}
Updating from https://ip-ranges.amazonaws.com/ip-ranges.json
Found CLOUDFRONT range: 52.46.0.0/18
Found CLOUDFRONT range: 52.84.0.0/15
Found CLOUDFRONT range: 52.222.128.0/17
Found CLOUDFRONT range: 54.182.0.0/16
Found CLOUDFRONT range: 54.192.0.0/16
Found CLOUDFRONT range: 54.230.0.0/16
Found CLOUDFRONT range: 54.239.128.0/18
Found CLOUDFRONT range: 54.239.192.0/19
Found CLOUDFRONT range: 54.240.128.0/18
Found CLOUDFRONT range: 204.246.164.0/22
Found CLOUDFRONT range: 204.246.168.0/22
Found CLOUDFRONT range: 204.246.174.0/23
Found CLOUDFRONT range: 204.246.176.0/20
Found CLOUDFRONT range: 205.251.192.0/19
Found CLOUDFRONT range: 205.251.249.0/24
Found CLOUDFRONT range: 205.251.250.0/23
Found CLOUDFRONT range: 205.251.252.0/23
Found CLOUDFRONT range: 205.251.254.0/24
Found CLOUDFRONT range: 216.137.32.0/19
Found 1 SecurityGroups to update
sg-c5c9fda1: Revoking 0.0.0.0/0:80
sg-c5c9fda1: Adding 52.46.0.0/18:80
sg-c5c9fda1: Adding 52.84.0.0/15:80
sg-c5c9fda1: Adding 52.222.128.0/17:80
sg-c5c9fda1: Adding 54.182.0.0/16:80
sg-c5c9fda1: Adding 54.192.0.0/16:80
sg-c5c9fda1: Adding 54.230.0.0/16:80
sg-c5c9fda1: Adding 54.239.128.0/18:80
sg-c5c9fda1: Adding 54.239.192.0/19:80
sg-c5c9fda1: Adding 54.240.128.0/18:80
sg-c5c9fda1: Adding 204.246.164.0/22:80
sg-c5c9fda1: Adding 204.246.168.0/22:80
sg-c5c9fda1: Adding 204.246.174.0/23:80
sg-c5c9fda1: Adding 204.246.176.0/20:80
sg-c5c9fda1: Adding 205.251.192.0/19:80
sg-c5c9fda1: Adding 205.251.249.0/24:80
sg-c5c9fda1: Adding 205.251.250.0/23:80
sg-c5c9fda1: Adding 205.251.252.0/23:80
sg-c5c9fda1: Adding 205.251.254.0/24:80
sg-c5c9fda1: Adding 216.137.32.0/19:80
sg-c5c9fda1: Added 19, Revoked 1
END RequestId: 87ebcc81-6203-11e6-914a-078d8bc8d2a6
REPORT RequestId: 87ebcc81-6203-11e6-914a-078d8bc8d2a6  Duration: 2758.25 ms    Billed Duration: 2800 ms        Memory Size: 128 MB     Max Memory Used: 45 MB
["Updated sg-c5c9fda1", "Updated 1 of 1 SecurityGroups"]

セキュリティグループがアップデートされたようです。実際に確認してみます。

$ aws ec2 describe-security-groups \
  --query 'SecurityGroups[?Tags[?Value==`cloudfront`]].IpPermissions'
[
    [
        {
            "PrefixListIds": [],
            "FromPort": 80,
            "IpRanges": [
                {
                    "CidrIp": "52.46.0.0/18"
                },
                {
                    "CidrIp": "52.84.0.0/15"
                },
                {
                    "CidrIp": "52.222.128.0/17"
                },
                {
                    "CidrIp": "54.182.0.0/16"
                },
                {
                    "CidrIp": "54.192.0.0/16"
                },
                {
                    "CidrIp": "54.230.0.0/16"
                },
                {
                    "CidrIp": "54.239.128.0/18"
                },
                {
                    "CidrIp": "54.239.192.0/19"
                },
                {
                    "CidrIp": "54.240.128.0/18"
                },
                {
                    "CidrIp": "204.246.164.0/22"
                },
                {
                    "CidrIp": "204.246.168.0/22"
                },
                {
                    "CidrIp": "204.246.174.0/23"
                },
                {
                    "CidrIp": "204.246.176.0/20"
                },
                {
                    "CidrIp": "205.251.192.0/19"
                },
                {
                    "CidrIp": "205.251.249.0/24"
                },
                {
                    "CidrIp": "205.251.250.0/23"
                },
                {
                    "CidrIp": "205.251.252.0/23"
                },
                {
                    "CidrIp": "205.251.254.0/24"
                },
                {
                    "CidrIp": "216.137.32.0/19"
                }
            ],
            "ToPort": 80,
            "IpProtocol": "tcp",
            "UserIdGroupPairs": []
        }
    ]
]

よさそうですね。CloudFrontにアクセスしてみます。

$ curl -I <cloudfront-domain>
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 3770
Connection: keep-alive
Accept-Ranges: bytes
Date: Sun, 14 Aug 2016 09:50:36 GMT
ETag: "575f1ada-eba"
Last-Modified: Mon, 13 Jun 2016 20:43:06 GMT
Server: nginx/1.8.1
X-Cache: Miss from cloudfront
Via: 1.1 eaaf7eb6072f1d7e0d8c7d2388e1bba6.cloudfront.net (CloudFront)
X-Amz-Cf-Id: UbN-LENaucVyJHFfP8A0u5cGP8ctmxiJsco6OWNLMAECRo7FcU46WQ==

制限されずにアクセスできます。では、肝心のELBはどうでしょうか。

$ curl -I <elb-domain>
curl: (7) Failed to connect to <elb-domain> port 80: Operation timed out

アクセスできずにタイムアウトしました。やりましたね。このLambda関数を動作させておけばELBへの直接アクセスを制限可能です。

CloudFrontのカスタムヘッダを利用した制限

CloudFrontにはカスタムヘッダという機能を使って、任意のヘッダをオリジンに渡すことが可能です。この機能を使えばELB配下のEC2上でヘッダをパースし、指定した文字列が入ってない場合はアクセスを制限することが可能です。今回は以下のドキュメントを参考に設定します。

設定手順

CloudFrontのカスタムヘッダに以下の設定をします。

ヘッダ
X-Cf-Secret test1234

実際に利用される場合、ヘッダの値はより複雑な文字列にすることをオススメします。なお、通信経路を暗号化させヘッダの盗聴を防ぐためにCloudFront - ELB間の通信はHTTPSのみ許可するようにしてください。

ELB配下のEC2(Amazon Linux)上でこのヘッダをパースさせます。今回はNginxを利用します。デフォルトで用意されている /etc/nginx/nginx.conf を以下のように修正してください。

$ diff -u nginx.conf.orig nginx.conf
--- nginx.conf.orig     2016-06-14 05:43:06.000000000 +0900
+++ nginx.conf  2016-08-14 22:44:47.766506133 +0900
@@ -14,7 +14,8 @@
 http {
     log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                       '$status $body_bytes_sent "$http_referer" '
-                      '"$http_user_agent" "$http_x_forwarded_for"';
+                      '"$http_user_agent" "$http_x_forwarded_for" '
+                      '"$http_x_cf_secret" "$is_not_elb" "$is_not_header_set" "$test"';

     access_log  /var/log/nginx/access.log  main;

@@ -40,6 +41,21 @@
         server_name  localhost;
         root         /usr/share/nginx/html;

+        set $is_not_elb 0;
+        if ($http_user_agent != 'ELB-HealthChecker/1.0') {
+         set $is_not_elb 1;
+        }
+
+        set $is_not_header_set 0;
+        if ($http_x_cf_secret != 'test1234') {
+         set $is_not_header_set 1;
+        }
+
+        set $test $is_not_elb$is_not_header_set;
+        if ($test = '11') {
+         return 403;
+        }
+
         # Load configuration files for the default server block.
         include /etc/nginx/default.d/*.conf;

修正内容は以下のとおりです。

  • デバッグのために各種変数をログに出力
  • ELBからのヘルスチェックとカスタムヘッダが設定されている場合、それぞれスイッチ変数をセット
  • スイッチ変数が許可しない状態の場合403を返す

動作確認

まずはCloudFrontからアクセスしてみます。

$ curl -I https://<cloudfront-domain>
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 3770
Connection: keep-alive
Accept-Ranges: bytes
Date: Sun, 14 Aug 2016 13:47:57 GMT
ETag: "575f1ada-eba"
Last-Modified: Mon, 13 Jun 2016 20:43:06 GMT
Server: nginx/1.8.1
X-Cache: Miss from cloudfront
Via: 1.1 96275b125ac8a1ca0365ff6f864de90c.cloudfront.net (CloudFront)
X-Amz-Cf-Id: aZyxo_ntaoTI49yTuCux6Dm0rrrX7TKM09Bxsns25FHhA416IKTYZg==

正常にアクセスできました。ログを確認すると以下のように test1234 という文字列がヘッダに渡されていることが確認できます。

172.16.1.221 - - [14/Aug/2016:22:47:57 +0900] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.43.0" "202.214.231.0, 54.239.196.162" "test1234" "1" "0" "10"

続いてELBに直接アクセスしてみましょう。

$ curl -I https://<elb-domain>
HTTP/1.1 403 Forbidden
Content-Length: 168
Content-Type: text/html
Date: Sun, 14 Aug 2016 13:48:21 GMT
Server: nginx/1.8.1
Connection: keep-alive

403が返ってきました。やりましたね。こちらは以下のようにログにカスタムヘッダが渡されていません。

172.16.1.221 - - [14/Aug/2016:22:48:21 +0900] "HEAD / HTTP/1.1" 403 0 "-" "curl/7.43.0" "202.214.231.0" "-" "1" "1" "11"

まとめ

いかがだったでしょうか。

2つの方法でバックエンドへの直接アクセスを制限する方法をご紹介しました。それぞれの方法を比較して表します。

実装方法 難易度 汎用性 セキュリティ 備考
CloudFrontのIPレンジ 高い Lambda関数を動作させるだけなので簡単に実装可能。IPアドレスの詐称に対しては別途対策が必要かもしれません。
CloudFrontのカスタムヘッダ やや高い 低い やや高い? ミドルウェアの設定が必要なのでやや実装が難しい。また、ELBのヘルスチェックの制限を作りこむ必要がありそうです(ユーザエージェント判定だけだと微妙なので)。HTTPSで通信経路を秘匿しておけばカスタムヘッダの漏洩はあまりないと思います。

基本的にIPレンジによる制限を導入し、さらにセキュリティを強化したい場合はカスタムヘッダの導入を検討されるとよいかと思います。

本エントリがみなさんの参考になれば幸いです。

参考リンク

本エントリを書くに当たり以下のリポジトリの記載内容を参考にさせていただきました。ありがとうございました。