CloudflareとHonoを使ってHerokuのデフォルトドメインを（実質）封印したWebアプリケーションをつくるメモ

表題の通りだが…メモです。

**本記事に記載している内容はすべてricemountainer個人の意見・見解であり、所属企業に帰属するものではありません。(お約束)**

## はじめに

HerokuはWebアプリを作るとデフォルトで`[アプリ名]-[ランダム文字列].herokuapp.com`というドメインでURLが公開される。（以後これを「デフォルトドメイン」と呼ぶこととする。）カスタムドメインも設定可能だが、設定してもこのデフォルトドメインは残ったままで、アクセス可能になる。このデフォルトドメインへのアクセスを封印したい、という場合のメモ。構成によって色々やり方はあると思うが、ここではCloudflare -> Herokuという構成において、これを実現する方法を考える。なお、CloudflareからHerokuへはDNS Proxyを設定している前提とする。この点に関しては[この記事](https://developers.cloudflare.com/support/third-party-software/others/configure-cloudflare-and-heroku-over-https/)が詳しい。

## オーソドックスな方法（概要）

Private SpaceをつくってTrusted IP RangeにCloudflareのIPアドレスを設定する。多分これが一番オーソドックスで分かりやすい方法だと思う。これによってWebアプリケーション側でゴチャゴチャ実装する必要がなくなり、Cloudflareを経由しないデフォルトドメインへのアクセスは、HerokuのプラットフォームがWebアプリケーションに着く前に全部弾いてくれる。Private SpaceのTrusted IP Rangeについては[こちらに解説がある](https://devcenter.heroku.com/articles/private-spaces-trusted-ip-ranges)。Cloudflare（がオリジンにリクエストを送るとき）のIPアドレスも[ここで公開されている](https://www.cloudflare.com/ips/)ので設定可能。  
  
Private Spaceは従来Heroku Enterpriseの機能だったが、[2024年12月からHeroku Enterpriseでなくても作成可能になったので](https://devcenter.heroku.com/changelog-items/3077)、やろうと思えば個人でも実現可能な構成である。  

とはいうものの、CloudflareのIPアドレスは未来永劫固定というわけでもないだろうし、適宜メンテが必要になるだろう。どの程度の頻度で見ればいいのか不明だが、知らずに変更されると実質サービス停止になる可能性があるので（誰もWebアプリにアクセスできなくなる場合が考えられるので）、このやり方を取るならその辺はちゃんと押さえておきたいところである。ちなみに探したら[IFTTTでCloudflareのIP変更トリガーのアプレットがあったので](https://ifttt.com/applets/euJegpUA-cloudflare-ip-list-changed)、これを使ってThen側にHerokuのPrivate Space Trusted IP Rangeのメンテ機構を組み入れれば、ある程度は自動化できるかもしれない。やったことがないのでわからないが。。

## カスタムヘッダを使う方法

これが本題である（これを書きたかった）。同じCDN製品であるCloudFrontでは有名な（だと個人的に思っている）カスタムヘッダを用いる方法である。

Cloudflareでは、オリジンにリクエストをforwardする際にカスタムヘッダを追加できる。これを利用して、Cloudflare->Herokuでカスタムヘッダを追加し、Herokuアプリ側ではそのカスタムヘッダを参照して、正当なリクエストかどうかを判定する方法。これをHonoを使って実装する。  

### Cloudflareの設定

まずCloudflare側の設定をする。ダッシュボードのトップページ（アカウントホーム）から対象ドメインを選択し、左側のサイドバーのメニューから「ルール」>「概要」>「ルールを作成」>「Request Header Transform Rule」を選択する。  
![スクリーンショット 2025-03-03 16.53.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392942/0b1c6fa5-2782-4381-a145-d408728e4032.png)

その後、カスタムヘッダ追加対象とするドメインを指定する。以下は「完全URI」指定でドメイン完全一致指定での設定例。
![スクリーンショット 2025-03-03 16.56.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392942/5291a40a-7d6b-4c08-9168-1e0a2072d3c1.png) 

- 「ルール名」は日本語で書ける。
- 「受信リクエストが一致する場合」で「カスタムフィルタ式」か「すべての受信リクエスト」のどちらかを選択する。
- 「カスタムフィルタ式」の場合はさらに追加で条件を入力する。これは使用するドメインの内容や種類によって適宜変更が必要。
    - 例えば`https://herokutest.hogehoge.com`というドメインでのアクセスを想定する場合は「フィールド」＝「完全URI」、「オペレータ」＝「次と等しい」、「値」＝`https://herokutest.hogehoge.com`を入力する。「オペレータ」にはワイルドカードの指定もできるので、例えば`https://*.herokutest.hogehoge.com`のように「特定のドメインのサブドメイン全部」みたいな指定も可能。
    - **ただしここで指定した値のドメインに関してはCloudflareのDNS設定が「Proxy」になっている必要がある。** これに関しては入力完了時にプロンプトで注意喚起がある。冒頭のドキュメントの内容に従えばProxyにはなっているはず。
 

画面下部に移動、「実行内容...」の項で「項目の選択」プルダウンを開き、実際に追加するカスタムヘッダのKEYとVALUEを設定する。
![スクリーンショット 2025-03-03 17.06.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392942/6f1bb86a-3cc9-4faf-bfcf-d16a4905d154.png)

- 最初に「スタティック設定」「ダイナミック設定」「削除」の３種類の中からリクエストヘッダの操作の種類を選択する。リクエストヘッダの追加は前者２つなのでどちらかを選ぶ。ここでは「スタティック設定」を選択する。
    - というか「ダイナミック」は正直個人的に使い方がわかっていない。[このドキュメント](https://developers.cloudflare.com/rules/transform/request-header-modification/create-dashboard/)を読む限りでは式を入力できるらしいが、具体的にどんなexpressionが使えるのかのプレースホルダーやドキュメントの記述がないので、どう扱えばいいかわからない。「スタティック設定」だと固定でそのカスタムヘッダを追加・上書きできるようなので一旦「スタティック設定」で進む。（個人的にはこれで十分）
- 「ヘッダー名」には実際に追加するヘッダー名を入力する。ここでは`X-Heroku-Token`だが別になんでも良い。
- 「値」には適当な値を入力する。推測困難な値がよいだろう。UUID v4など。ここで指定した値は後ほどHerokuアプリ側で「正当なリクエストかどうか」を判定する際に使用するので記録しておく。

「場所:」はこのルールを実行するタイミングを指定する。「最初」か「最後」かになる。試したことないのでわからないが「最初」を指定した後別のルールで条件に当てはまるものに遭遇した場合、意図せずリクエストヘッダが書き換わる可能性もある。確実に遂行したいなら「最後」の方が良いのだろう。そもそも複数一致するような条件のルールを作らない方がいいと個人的には思うけども。。

### Herokuアプリの実装

Herokuアプリを実装する。ここでは[Hono](https://hono.dev/)を使う。`npm create hono@latest`あるいは`yarn create hono`等の後templateで`nodejs`を選択。アプリ名やらパッケージマネージャやらなんやらは好みで適当に指定してください。
```bash
% yarn create hono
yarn create v1.22.22
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Installed "create-hono@0.15.3" with binaries:
      - create-hono
[##] 2/2create-hono version 0.15.3
? Target directory my-app
? Which template do you want to use? nodejs
? Do you want to install project dependencies? yes
? Which package manager do you want to use? yarn
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd my-app
✨  Done in 28.35s.
```

`src/index.ts`を開いて以下のコードを追加。
```typescript:src/index.ts
const CLOUDFLARE_CUSTOM_HEADER_TOKEN = process.env['CLOUDFLARE_CUSTOM_HEADER_TOKEN'];

app.use(async (c, next) => {
  const herokuToken = c.req.header('X-Heroku-Token');
  if (herokuToken && CLOUDFLARE_CUSTOM_HEADER_TOKEN && herokuToken == CLOUDFLARE_CUSTOM_HEADER_TOKEN) {
    await next();
  } else {
    return c.text('invalid access', 403);
  }
})
```

難しいことは特に何もしていない。`X-Heroku-Token`というキーのリクエストヘッダの値を取ってアプリの環境変数と照合し、一致していたら先に進む、一致していなければ403を返す、という処理だけだ。シンプルだがこれが肝であり、これにより **「リクエストヘッダに正しい値が設定されているリクエスト（Cloudflare経由＝カスタムドメイン経由のアクセス（のはず） ）だけを許容し他はすべて拒否」** という動作をする。403と上のレスポンスはあくまで例であり、別に他のHTTPステータスコードでもいいし、なんなら何もレスポンスしなくても良い。ここは自由である。

この照合のため、アプリに`CLOUDFLARE_CUSTOM_HEADER_TOKEN`という名前の環境変数を設定することを前提としている。この名前の環境変数に、↑でCloudflareに指定したカスタムヘッダの「値」を指定する。

まず以下コマンドでHerokuアプリを作成して
```bash
% heroku apps:create [Herokuアプリ名]
```

以下のコマンドで環境変数をセットする。
```bash
% heroku config:set CLOUDFLARE_CUSTOM_HEADER_TOKEN=[↑でCloudflareに指定したカスタムヘッダの「値」] -a [Herokuアプリ名]
```

HerokuへのDeployは色々やり方あるので好きな方法で実施してください。。以下参考。
- [GitHub経由でDeploy](https://devcenter.heroku.com/articles/github-integration)
- [Herku Gitに直接push](https://devcenter.heroku.com/articles/git)

ちなみにちょっと注意点として、Honoの初期セットだと`tsx`が`devDependencies`にだけいるのだが、
```json
  "devDependencies": {
    "@types/node": "^20.11.17",
    "tsx": "^4.7.1"
  }
```

Herokuは基本的に[`devDependencies`のパッケージはアプリの実行開始前にすべて削除するので、](https://devcenter.heroku.com/articles/troubleshooting-node-deploys#missing-modules)そのままdeployしても「`tsx`が見つからない」というエラーで起動できない。このため使用するパッケージマネージャに応じて事前に環境変数の設定が必要になる。例えば`yarn`なら以下。
```bash
% heroku config:set YARN_PRODUCTION=false -a [Herokuアプリ名]
```

これで準備は整った。カスタムドメイン経由ならちゃんとリクエストが通るが
```bash
% curl https://[カスタムドメイン]
Hello Hono!
```

デフォルトドメインに対してはリクエストが通らず拒否される。
```bash
% curl -i -v https://[アプリ名]-[ランダム文字列].herokuapp.com
...
< HTTP/1.1 403 Forbidden
HTTP/1.1 403 Forbidden
...
invalid access
```

注意点としては。。。

- このやり方はあくまで「`X-Heroku-Token`というリクエストヘッダに正しい値が設定されている」＝「カスタムドメイン経由でのアクセスに違いない」という前提に基づく動作であり、ある意味性善説である。このリクエストヘッダに正しい値が設定されていればいいのだから、`curl -H "X-Heroku-Token: [Cloudflareで設定した値]" https://[アプリ名]-[ランダム文字列].herokuapp.com`のような形で直接カスタムヘッダを指定してリクエストすれば、デフォルトドメインに対してもリクエストは通る、という点には注意が必要である。
- これはアプリ側で処理することになるので、拒否するにしても少なからずアプリ側のリソースを使ってしまうことになる。そういう意味で、プラットフォーム側で全部弾ける（＝アプリのリソースを使うことなく拒否できる）Private SpaceのTrusted IPの方が理想的ではある。「アプリ側でできる手軽な防御策」という程度に捉えておく必要がある。