CloudFrontで配信しているS3に置かれた画像を特定サイトからしか読み込めないようにする

# はじめに

S3に置かれた画像をCloudFrontで配信するにあたって、画像を読み込めるサイトに制限をかけたいと考えました。
その特定サイトは検索にはヒットせず、しかしIP制限などはかけていないものとします。
かといってAWS lambdaなどを間に挟んでゴニョゴニョするような面倒はかけたくないため、ある程度のガバさは許容するが、特に何の工夫もなしに画像を引っ張ってこれるようにはしたくない。
そんな仕様を叶える手段の一つとして[Referer](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Referer)というヘッダーを見るというのがあります。
Refererヘッダーにはリクエストされているページへのリンク先を持った直前のウェブページのアドレスが含まれています。
つまり画像へのアクセス元ページのアドレスがわかるわけです。
ヘッダーなんてのは簡単に書き換えられてしまうため、ザルではありますが、それでも何の工夫もなしに画像を引っ張ってこれなくはなります。
ヘッダーを見てリクエストを弾いたりするのはWAFでできます。

よって本記事ではRefererヘッダーに特定サイトのURLが含まれている場合のみ、CloudFrontを通してS3にある画像を取ってこれるようにする仕組みをCloudFormationで作成します。

# CFn

WAFとS3+CloudFrontそれぞれ分けて作成します。
理由は下記記事にある通り、CloudFront用のWebACLを作成するにはus-east-1 (バージニア北部) に作成する必要があるためです。

[【小ネタ】AWS WAF v2 の WebACL （CloudFront用）を東京リージョンから CloudFormation で作成しようとしたら怒られた](https://dev.classmethod.jp/articles/cloudformation-webacl-cloudfront-error/)

## WebACL

とりあえず `https://example.com` からのアクセスであれば通すようにしています。
複数通したければ[AWS::WAFv2::WebACL OrStatement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-wafv2-webacl-orstatement.html)で複数指定します。

[AWS::WAFv2::WebACL DefaultAction](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-wafv2-webacl-defaultaction.html)でデフォルトのアクションを設定できるのですが、ここをデフォルトAllowにしてしまうと特定箇所以外からも許可されます。
私は、[AWS::WAFv2::WebACL RuleAction](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-wafv2-webacl-ruleaction.html)を特定サイトからのアクセスは通すからとAllowにして、デフォルトアクションもAllowにしていたのを見逃していたため、どこからでもAllowになってしまい時間を溶かしました。

```yaml
AWSTemplateFormatVersion: "2010-09-09"

Resources:
  RefererWebACL:
    Type: AWS::WAFv2::WebACL
    Properties:
      Scope: CLOUDFRONT
      Name: WafCloudFrontForImageDelivery
      DefaultAction:
        Block: {}
      VisibilityConfig:
        SampledRequestsEnabled: true
        CloudWatchMetricsEnabled: true
        MetricName: AllowSpecificReferrersFoImageDelivery

      Rules:
        - Name: AllowSpecificReferrersRule
          Priority: 0
          Action:
            Allow: {}
          Statement:
            ByteMatchStatement:
              SearchString: "https://example.com"
              FieldToMatch:
                SingleHeader:
                  Name: "Referer"
              TextTransformations:
                - Priority: 0
                  Type: NONE
              PositionalConstraint: "CONTAINS"
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: AllowSpecificReferrersRuleMetricForImageDelivery
```

作成します。
regionに注意。

```sh
aws cloudformation create-stack --stack-name web-acl --template-body file://web-acl.yaml --region us-east-1
```

## S3, CloudFront

これでS3とCloudFrontがいい感じに作成され、CloudFrontには上で作成したWAFが付いてくれます。
WAFは別で作成しているので、WebAclのARNをパラメータで与えてやる必要があるので注意が必要です。

[AWS::CloudFront::Distribution DefaultCacheBehavior CachePolicyId](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-cloudfront-distribution-defaultcachebehavior.html#cfn-cloudfront-distribution-defaultcachebehavior-cachepolicyid)には[マネージドキャッシュポリシーのCachingOptimized](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-caching-optimized)のポリシーIDを指定しています。

```yaml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  WebAclARN:
    Description: WAF v2 ARN
    Type: String

Resources:
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: "いい感じの一意な名前"
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256

  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: "Access identity for S3 bucket"

  S3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowCloudFrontServicePrincipalReadOnly
            Effect: Allow
            Action: s3:GetObject
            Resource: !Join [ "/", [ !GetAtt S3Bucket.Arn, "*" ] ]
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Origins:
          - Id: S3Origin
            DomainName: !GetAtt S3Bucket.DomainName
            S3OriginConfig:
              OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}
        HttpVersion: http2and3
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: allow-all
          AllowedMethods:
            - GET
            - HEAD
          Compress: true
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
        WebACLId: !Ref WebAclARN
```

パラメータをいちいちコマンドで突っ込むのが面倒なのでparam.jsonを作ります。

```json
[
  {
    "ParameterKey": "WebAclARN",
    "ParameterValue": "%WebAclのARN%"
  }
]
```

param.jsonを注入してstackを作成します。

```sh
aws cloudformation create-stack --stack-name s3-cloudfront --template-body file://s3-cloudfront.yaml --parameters=file://./params/s3-cloudfront.param.json
```

# 確認

できたと思うので確認します。

```sh
$ curl -I -L https://xxx.cloudfront.net/path/to/image

HTTP/2 403
server: CloudFront
content-type: text/html
x-cache: Error from cloudfront

$ curl -I -L -H 'Referer:https://qiita.com'  https://xxx.cloudfront.net/path/to/image
HTTP/2 403
server: CloudFront
content-type: text/html
x-cache: Error from cloudfront
```

ブロックはできてそう。
正常系を試します。

```sh
curl -I -L -H 'Referer:https://example.com' https://xxx.cloudfront.net/path/to/image
HTTP/2 307
content-type: application/xml
location: https://s3バケット名.s3-ap-northeast-1.amazonaws.com/path/to/image
x-amz-bucket-region: ap-northeast-1
server: AmazonS3
x-cache: Miss from cloudfront

HTTP/1.1 403 Forbidden
Server: AmazonS3
```

!?!?
リダイレクトされてる!?

わからんので「CloudFront」と「307」をキーワードにして検索します。
ありました。

[S3+CloudFrontでS3のURLにリダイレクトされてしまう場合の対処法](https://dev.classmethod.jp/articles/s3-cloudfront-redirect/)

S3をバージニア北部(us-east-1)以外で作成した場合、24時間以内のアクセスだとリダイレクトが発生するようですね。
待ちます。

待ちました。
もう一度叩きます。

```sh
$ curl -I -L -H 'Referer:https://example.com' https://xxx.cloudfront.net/path/to/image

HTTP/2 200
content-type: image/png
x-amz-server-side-encryption: AES256
server: AmazonS3
x-cache: Miss from cloudfront
```

取ってこれてる！

`https://example.com` で開発者ツールを使ってDOMを追加して、取れているかを確認します。

![サーバルちゃん](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/0cb5ffe9-49f6-28dc-82f8-12d68102f305.png)

やったぜ！

# 参考

- [CloudFront＋S3 OACの設定をCloudFormationでやってみた](https://dev.classmethod.jp/articles/cloudfront_s3_oac_setup_with_cloudformation/ )
- [AWS WAFで特定のリクエストヘッダーのみを許可するアクセス制限を試してみた](https://dev.classmethod.jp/articles/allow-only-specific-request-headers-with-aws-waf/)