アクセス制御した S3 に Cloudflare から接続する

#### はじめに
[バケットポリシー](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/example-bucket-policies.html)で制御している Amazon S3 に Cloudflare Proxy で接続する方法を確認します。

以下の二つのパターンを試します。

- 送信元 IP と HTTP user-agent ヘッダーのペア
- AWS Signature Version 4

|S3 バケット|S3 条件|何を見るか|Cloudflare プロダクト|他の Cloudflare Account から接続できてしまう|
|-|-|-|-|-|
|公開|aws:SourceIP|送信元 IP|[Workers](https://developers.cloudflare.com/workers/)<br><br>* S3 で[仮想ホスティング](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/VirtualHosting.html)を使えば Worker 不要|◯|
|〃|[aws:SourceIP<br>＋<br>aws:UserAgent](https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_policies_condition-keys.html)|送信元 IP<br>＋<br>HTTP user-agent ヘッダー|Workers<br><br>* Enterprise プランなら Worker の代わりに [Rules](https://developers.cloudflare.com/rules/) でも対応可能|☓|
|非公開|[AWS SigV4 認証リクエスト](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sig-v4-authenticating-requests.html)|HTTP [Authorization ヘッダー](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html)または[クエリーパラメーター](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sigv4-query-string-auth.html)|Workers|☓|

他にも使われる制御方法があれば、教えてください。

#### S3 バケット
対象バケットのアクセス設定、および、対応するドメイン名の関連は下記とします。

<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/a5891a84-2b0e-1b9b-3cdc-98d1a63c6cfb.png">

|Eyeball Host|S3 Host|
|-|-|
|s3-ua.oyama.cf|pub-ua.s3.ap-northeast-1.amazonaws.com|
|s3-v4.oyama.cf|pub-v4.s3.ap-northeast-1.amazonaws.com|

#### 送信元 IP と user-agent ヘッダーでの制御

##### リクエストフロー
- 任意の Eyeball からリクエスト
- Cloudflare Proxy が特定の user-agent に変更（hostヘッダー・SNIも S3 に変更）
- S3 が接続を許可

``` mermaid
sequenceDiagram
    Eyeball->>Cloudflare: [送信元 IP] 任意<br>[SNI/host] s3-ua.oyama.cf<br>[user-agent] 任意
    Cloudflare->>S3: [送信元 IP] Cloudflare<br>[SNI/host] pub-ua.s3.ap-northeast-1.amazonaws.com<br>[user-agent] 指定文字列
    Note right of S3: IP ◯, UA ◯, Bucket名 ◯ <br> 許可
    S3->>Cloudflare: Hello!
    Cloudflare->>Eyeball: Hello!
```
:::note
今回 Cloudflare − S3 の通信に焦点当ててるので、Eyeball の認証はしていません。

Cloudflare で user-agent を上書きせず、Eyeball から来たものをパススルーして、Eyeball 自身を S3 に認証させることも可能です。

また、Eyeball - Cloudflare に Cloudflare のセキュリティ機能を実装すれば、より柔軟な S3 のアクセス制御を追加できます。
- 送信元 IP だけでなく国単位など色々（[フィールド一覧](https://developers.cloudflare.com/ruleset-engine/rules-language/fields/)）
- TLS でクライアント認証（[方法 1](https://developers.cloudflare.com/ssl/client-certificates/enable-mtls/)、[方法 2](https://developers.cloudflare.com/cloudflare-one/identity/devices/access-integrations/mutual-tls-authentication/)）
- HTTPで [IdP 連携や Token 認証](https://developers.cloudflare.com/cloudflare-one/identity/)、[署名付き URL](https://developers.cloudflare.com/support/firewall/learn-more/configuring-token-authentication/)
- 認証結果の[オリジン連携](https://qiita.com/KeioCF/items/5f86885587f93d83f50c#json-web-tokens)
などなど
:::

##### S3 バケットポリシー

- 送信元 IP を定義
送信元 IP のリストを Cloudflare の API から取って、整形します。

``` shell:IP リスト作成
curl -s "https://api.cloudflare.com/client/v4/ips" | jq '.result|.ipv4_cidrs+.ipv6_cidrs'
```

- user-agent を定義
特定の user-agent ヘッダーを持つものだけ受け付けるようにします。
任意のヘッダーと言うことで、[`uuidgen`](https://manpages.ubuntu.com/manpages/impish/ja/man1/uuidgen.1.html) で生成した文字列を使いました。

これらをバケットポリシーに適用します。
Condition 内の二つの条件が AND で評価されていました。

```json:バケットポリシー
{
    "Version": "2012-10-17",
    "Id": "s3-1",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::pub-ua/*",
            "Condition": {
                "StringEquals": {
                    "aws:UserAgent": "02F2F1C1-5C6F-410D-A08F-BD2D014FD3F4"
                },
                "IpAddress": {
                    "aws:SourceIP": [
                        "173.245.48.0/20",
                        :中略:
                        "2c0f:f248::/32"
                    ]
                }
            }
        }
    ]
}
```

##### Cloudflare Proxy

Cloudflare は [Workers （カスタムドメイン）](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)を利用しています。
DNS や証明書も自動でやってくれるのがいいですね。
```toml:wrangler.toml
name = "fetch-s3-ua"
main = "src/index.js"
compatibility_date = "2023-09-21"

route = { pattern = "s3-ua.oyama.cf", custom_domain = true }
```

DNS の設定を見ると Type が Worker で作成されています。
<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/3620fcc0-6b29-e9b1-aec9-0e717242ab94.png">

「user-agent を S3 のバケットポリシーと同じにもの上書きし、S3 を fetch する」スクリプトを当てます。
```js:index.js
export default {
	async fetch(request) {
         let url = new URL(request.url);
         url.hostname = "pub-ua.s3.ap-northeast-1.amazonaws.com"
         let req = new Request(url,request);
         req.headers.set("user-agent", "02F2F1C1-5C6F-410D-A08F-BD2D014FD3F4");
         return fetch(req);
         /*
         return fetch(req, {
           cf: { cacheEverything: true,
                 cacheTtlByStatus: {"200": 10, "404": 0},
                 cacheKey: request.url,
                 cacheTags: ["s3ua"] }
           });
         */
     },
};
```
::: note
コメントアウトしている [cf プロパティ](https://developers.cloudflare.com/workers/runtime-apis/request/#requestinitcfproperties)はキャッシュの制御などで利用できます。
:::

##### 接続テスト

直接接続は正しい user-agent でも AccessDenied で拒否されます。

```xml:直接接続
~ $ curl https://pub-ua.s3.ap-northeast-1.amazonaws.com/hw.txt -A "02F2F1C1-5C6F-410D-A08F-BD2D014FD3F4"
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied</Message><RequestId>ABM4J83FHPW064CM</RequestId><HostId>IrZWQtlNc2fRvMBTpZavVVNV33cF63b9hvd5wZL4g3izfo6p3XsNUwO2pqVOOUQ9RSbRG9HA4ik=</HostId></Error>%
```

Cloudflare 経由で接続すると、任意の user-agent でも成功します。

```shell:Cloudflare経由
~ $ curl https://s3-ua.oyama.cf/hw.txt -A "hogee"
Hello World
```

#### AWS Signature Version 4 認証リクエスト

次は [AWS SigV4 認証リクエスト](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sig-v4-authenticating-requests.html) を利用します。
非公開のバケットが、正常に署名されたリクエストのみ受け付けます。

``` mermaid
sequenceDiagram
    Eyeball->>S3: 不正な署名・署名なし
    Note right of S3: 拒否
```

``` mermaid
sequenceDiagram
    Eyeball->>Cloudflare: リクエスト
    Cloudflare->>S3: 署名付き リクエスト
    Note right of S3: 許可
    S3->>Cloudflare: Hello!
    Cloudflare->>Eyeball: Hello!
```

##### S3 IAM ポリシー

[AmazonS3ReadOnlyAccess](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/security-iam-awsmanpol.html) を付けた IAM ユーザーのアクセスキーで試します。

##### Cloudflare Proxy

Workers スクリプト [s3workers](https://github.com/violetgo/s3workers) を使います。（利用方法は本家ページで）
IAM ユーザーのアクセスキーとシークレットキーなどを環境変数に入れます。
<img width="500" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/655afe61-282a-5f81-b860-6f5455861380.png">

こちらもカスタムホストを利用しました。
<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/8b61c5ba-e6db-ac21-b73c-b8586e03f86e.png">


##### 接続テスト

直接接続は AccessDenied で拒否されます。

```xml:直接接続
~ $ curl https://pub-v4.s3.ap-northeast-1.amazonaws.com/hw.txt
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied</Message><RequestId>CCV2DQ63J708NY4F</RequestId><HostId>S+n0psm4czpaEZZdfNVS8HB9rj3bZM+6oIoZU6rVyW75kgR5mmtuFyRhh5jR15uVjoeIQW8wX4g=</HostId></Error>%
```

Cloudflare 経由では許可されます。

```shell:Cloudflare 経由
~ $ curl https://s3-v4.oyama.cf/hw.txt
Hello World
```

Workers でログを確認すると、Worker が [Authorization ヘッダで署名情報](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sig-v4-header-based-auth.html)を送っていることがわかります。

```shell:HTTP Authorization ヘッダー
# console.log(signedRequest.headers.get("Authorization"))

"AWS4-HMAC-SHA256 Credential=*, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ed8d405f818994ebea51846f402819e74ffc4b26a6dbfcc86800eafce716a21f"

```
#### 中締め

以上、バケットポリシーへの対応を確認しました。
また、各 Worker の指標はこんな感じでした。（短期間で参考にならないかもしれませんが）

- user-agent 書き換え
<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/3032ef2a-efe7-f53d-047f-58cd9f06f716.png">

- AWS SigV4 認証リクエスト
<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/75c8284a-2f18-21d9-f8ed-0becaf092045.png">

あとは関連しそうな事項を載せておきます。

#### S3 仮想ホスティング 

[仮想ホスティング](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/VirtualHosting.html)を使う場合を確認します。

``` mermaid
sequenceDiagram
    Eyeball->>Cloudflare: s3-v.oyama.cf
    Cloudflare->>S3: s3-v.oyama.cf
    Note right of S3: <s3-v.oyama.cf>.s3.ap-northeast-1.amazonaws.com <br> Bucket名とリクエストされたホスト名がマッチ!
    S3->>Cloudflare: Hello!
    Cloudflare->>Eyeball: Hello!
```

S3 が Eyeball のリクエストと同じドメイン名（仮想ホスト）を受け入れてくれます。
そのため、hostヘッダー・SNI の変換が不要になり、そこを Workers で補う必要がなくなります。

##### 仮想ホスティングを有効にする S3 バケット名

[バケット名の先頭にドメイン名](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/VirtualHosting.html#VirtualHostingCustomURLs)をつければ、そのドメイン名へのリクエストを受け付けてくれる模様です。


###### Cloudflare DNS

Cloudflare DNS で CNAME を設定します。
まずは Proxy せず、DNS として CNAME を返すようにします。S3 のユーザーガイドに記載の[方法](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/VirtualHosting.html#VirtualHostingCustomURLs)です。
<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/217c3efe-41f3-6b9c-7b5e-cfca6b1a6e40.png">


###### TLS 証明書

仮想ホストで S3 に HTTPS でアクセスすると、証明書エラーが出ます。


```shell:S3 仮想ホスト（S3 に直接接続）
~ $ curl https://s3-v.oyama.cf/hw.txt -A "02F2F1C1-5C6F-410D-A08F-BD2D014FD3F4"
curl: (60) SSL: no alternative certificate subject name matches target host name 's3-v.oyama.cf'
```
これは、S3 エンドポイントにアクセスしても同じです。

```shell:S3 エンドポイント（S3 に直接接続）
~ $ curl https://s3-v.oyama.cf.s3.ap-northeast-1.amazonaws.com/hw.txt -A "02F2F1C1-5C6F-410D-A08F-BD2D014FD3F4"
curl: (60) SSL: no alternative certificate subject name matches target host name 's3-v.oyama.cf.s3.ap-northeast-1.amazonaws.com'
```

どうやら、ドット付きのホストをカバーするような証明書が[発行されない](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/bucketnamingrules.html)ようです。

> 互換性を最も高くするには、静的ウェブサイトホスティング専用のバケットを除き、バケット名にドット (.) を使用しないことをお勧めします。バケット名にドットを含めると、証明書の検証を独自に実行しない限り、HTTPS 経由の仮想ホスト形式のアドレス指定は使用できません。これは、バケット名にドットが含まれていると、バケットの仮想ホスティング用のセキュリティ証明書は機能しないためです。
> 
> 静的ウェブサイトホスティングは HTTP 経由でのみ使用されるため、この制限は静的ウェブサイトホスティング用のバケットには影響しません。

証明書を見てみると、仮想ホスト名にマッチする内容は CN にも SAN にも含まれていません。
仮想ホスト用の証明書が動的に作成されるわけではなさそうです…

```
~ $ openssl s_client -connect s3-v.oyama.cf.s3.ap-northeast-1.amazonaws.com:443 -servername s3-v.oyama.cf.s3.ap-northeast-1.amazonaws.com </dev/null 2>&1|awk '/BEGIN CERTIFICAT/,/END CERTIFICAT/' | openssl x509 -noout -text| awk /s3.ap-northeast-1.amazonaws.com/
        Subject: CN = *.s3-ap-northeast-1.amazonaws.com
                DNS:s3-ap-northeast-1.amazonaws.com, DNS:*.s3-ap-northeast-1.amazonaws.com, DNS:s3.ap-northeast-1.amazonaws.com, DNS:*.s3.ap-northeast-1.amazonaws.com, DNS:s3.dualstack.ap-northeast-1.amazonaws.com, DNS:*.s3.dualstack.ap-northeast-1.amazonaws.com, DNS:*.s3.amazonaws.com, DNS:*.s3-control.ap-northeast-1.amazonaws.com, DNS:s3-control.ap-northeast-1.amazonaws.com, DNS:*.s3-control.dualstack.ap-northeast-1.amazonaws.com, DNS:s3-control.dualstack.ap-northeast-1.amazonaws.com, DNS:*.s3-accesspoint.ap-northeast-1.amazonaws.com, DNS:*.s3-accesspoint.dualstack.ap-northeast-1.amazonaws.com, DNS:*.s3-deprecated.ap-northeast-1.amazonaws.com, DNS:s3-deprecated.ap-northeast-1.amazonaws.com
```


##### Cloudflare Proxy

証明書を正しいものにする（仮想ホスト名で発行する）方法が分からないので、Cloudflare Proxy を有効にし、 [SSL/TLS モードを Full](https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/full/) にして証明書の検証エラーを回避します。


<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/ab214774-bc49-3230-792f-57f990ccfe2a.png">

アクセス可能となりました。
```shell:
~ $ curl https://s3-v.oyama.cf/hw.txt -A "02F2F1C1-5C6F-410D-A08F-BD2D014FD3F4"
Hello World
```

##### user-agent 変更も Workers なしで

S3 で仮想ホスティングを使うことで Workers なしで S3 にアクセスできるようになりました。次に、user-agent の変更も Workers を使わず、[ Transfor Rules](https://developers.cloudflare.com/rules/transform/) で試してみます。（これで Workers が不要になります）

<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/602186f5-b45c-cfed-0c13-75c6d442f90c.png">


##### 接続テスト

Eyeball から任意の user-agent でアクセスができます。
```
~ $ curl https://s3-v.oyama.cf/hw.txt -A "hogee"
Hello World
```

#### 仮想ホスティングなしで単に Proxy するとどうなるか

仮想ホスティングしていない S3 バケットを CNAME で指定して Proxy します。

<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/88c64bf0-6204-5591-8caa-e8c4ade83599.png">

NoSuchBucket と言われます。

```xml:エラー表示
~ $ curl https://s3-o.oyama.cf/hw.txt  -A "02F2F1C1-5C6F-410D-A08F-BD2D014FD3F4"
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message><BucketName>s3-o.oyama.cf</BucketName><RequestId>M0SEFQBYAMT3SWS9</RequestId><HostId>C0ZRfDUnuGBXZwzn6VyIkde1B/10RD1GlxdzJrBnZDouI03OcPU6LPOzaTY3Sk7v2NtqZRivduw=</HostId></Error>%
```

指摘の通り、その名前のバケットはありません。

``` mermaid
sequenceDiagram
    Eyeball->>Cloudflare: s3-o.oyama.cf
    Cloudflare->>S3: s3-o.oyama.cf
    Note right of S3: s3-o.ap-northeast-1.amazonaws.com <br> Bucket名とリクエストされたドメイン名が合わない
    S3->>Cloudflare: そんなバケットなし
    Cloudflare->>Eyeball: そんなバケットなし
```

これを回避するのに Workers fetch あるいは S3 仮想ホスティングを使ったということになります。

#### Enterprise プランでの機能（Page Rules または Origin Rules）

Enterprise プランの場合、host ヘッダー・SNI を Workers 以外の方法で上書き可能です。
Page Rules の [Host Header Override](https://developers.cloudflare.com/support/page-rules/using-page-rules-to-rewrite-host-headers/) または Origin Rules で [Host Header](https://developers.cloudflare.com/rules/origin-rules/features/#host-header) で実施します。

Origin Rules の場合
<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/a9bf6b64-0db3-967b-b317-922ad8222f8f.png">

```
~ $ curl https://s3-o.oyama.cf/hw.txt
Hello World
```

#### S3 静的ウェブサイトホスティング

S3 で静的ウェブサイトホスティングにしている場合は [HTTPS が使えない](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/WebsiteEndpoints.html)とのことで、HTTP ヘッダーやボディを使う認証は止めたほうがよさそうです。
送信元 IP アドレスを Cloudflare に制限するくらいでしょうか。

DNS は CNAME で設定します。
仮想ホスティングのように[受け付けて](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/WebsiteEndpoints.html#website-endpoint-dns-cname)くれます。

<img width="700" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/7ce1d387-77db-7f62-5cdc-bdfac0f12390.png">

S3 との通信を HTTP にするには、対象の静的ウェブホスティングのみ [Page Rules](https://developers.cloudflare.com/support/page-rules/understanding-and-configuring-cloudflare-page-rules-page-rules-tutorial/#summary-of-page-rules-settings) または [Configuration Rules](https://developers.cloudflare.com/rules/configuration-rules/) で SSL モードを [Flexible](https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/flexible/) にします。
Cloudflare からオリジンへの接続が HTTP になります。
<img width="500" alt="alt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2218045/89cc98b1-df0d-995b-7fc3-78e974794746.png">

これだけです。Rules や Workers も不要です。
確認すると CDN も動いています。style.css の方がキャッシュされています。(拡張子 CSS は[デフォルトでキャッシュ対象](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/)）

```shell:
~ $ curl https://s3-www.oyama.cf/ -sv 2>&1 | grep cf-cache
< cf-cache-status: DYNAMIC

~ $ curl https://s3-www.oyama.cf/style.css -sv 2>&1 | grep cf-cache
< cf-cache-status: HIT
```


#### まとめ

S3 のアクセス制限に Cloudflare Proxy で追随する方法を試しました。

S3 のアクセス制御まわりはドキュメント読んでみましたが複雑で、追いつけてませんので、記載間違えや他の方法、アイデアなどあれば指摘いただけるとありがたいです。