Cloudflare AccessとSSGの相性問題から別プロジェクト分離に至るまで
/private以下にCloudflare Accessをかければ済む、と思ってコードを書き始めた。ところがSPA遷移でアクセス制御をすり抜ける挙動を目の当たりにし、ミドルウェアでフルページリロードを挟んで穴を塞ぎ、それでもHTMLソースを開くとコンテンツが丸見えで手が止まった。結局プロジェクトごと分離する判断を下した。ここにその試行錯誤を残す。
最初のアプローチ: Cloudflare Accessで/privateを保護
Cloudflare Accessは、特定のパスに対してメール認証やOAuthを要求するゲートを設置できる。/private/*にポリシーを設定して、認証済みユーザーだけがアクセスできるようにした。
サーバーに直接リクエストが飛ぶ場面では、これで問題なく動く。ところが、SSG + クライアントサイドルーティングの組み合わせで穴が開いた。
SPA遷移がCloudflare Accessをすり抜ける
Nuxt 3のSSGモードでは、初回アクセス後のページ遷移がクライアントサイドで処理される。つまり、/public-pageから/private/secretへのリンクをクリックしたとき、ブラウザはサーバーにリクエストを送らずにJavaScriptでDOM差し替えを行う。
Cloudflare Accessはエッジ(CDN層)でリクエストを検査する仕組みだから、クライアントサイドのルーティングはそもそも検査対象に入らない。認証画面が一度も割り込まないまま、プライベートコンテンツがそのまま画面に描画された。思わずブラウザのDevToolsを開いて二度確認した。
対策1: ミドルウェアでフルページリロードを強制
SPA遷移をやめさせれば、すべてのナビゲーションがサーバーへのリクエストになり、Cloudflare Accessの検査を通過する。Nuxtのルートミドルウェアで/privateパスへの遷移を検知し、window.location.hrefによるフルページリロードに差し替えた。
// navigateTo()の代わりにフルリロード
if (to.path.startsWith('/private')) {
window.location.href = to.fullPath
return abortNavigation()
}
これでSPA遷移時にはCloudflare Accessの認証画面が挟まるようになった。一瞬うまくいったように見えた。
対策1の限界: プリレンダリングされたHTMLにコンテンツが入っている
しかし、ブラウザのアドレスバーに直接URLを打ち込んでアクセスしてみると、Cloudflare Accessの認証画面は出るものの、SSGでプリレンダリングされた静的HTMLファイル自体にコンテンツが含まれていることに気づいた。
SSGビルド時に/private/secretのHTMLが生成され、そのHTMLの中にはページの全テキストが埋め込まれている。Cloudflare Accessは認証を挟むが、HTMLファイル自体はCDNに配置されている。認証をバイパスする手段が見つかれば(あるいはキャッシュの挙動次第で)、コンテンツが露出するリスクが残る。
ミドルウェアの対策はクライアントサイドルーティングの穴は塞いだが、根本的な問題---プリレンダリングされたHTMLにコンテンツが平文で含まれること---には手が届かなかった。
最終判断: 別プロジェクトに分離する
ここで方針を切り替えた。同じプロジェクト内でパスベースの保護を追い求めるより、プロジェクトごと分離して別ドメインにCloudflare Accessをかける方が確実だと判断した。
理由はシンプルで、別プロジェクトなら:
- SSGビルドの成果物が完全に別のCloudflare Pagesプロジェクトに配置される
- ドメイン全体にCloudflare Accessをかけられる(パスベースの穴がない)
- コンテンツの漏洩リスクがゼロになる
apps/privateディレクトリを作成し、Nuxt 3のミニマルアプリを構築した。
apps/private の構成
メインアプリ(apps/web)のコンポーネントを再利用するため、nuxt.config.tsのcomponentsディレクトリ設定でapps/web/app/componentsを参照先に追加した。DocPageラッパーもそのまま共有できた。
content.config.tsはapps/private用に別途定義し、プライベートコンテンツ専用のスキーマを設けた。
ライフプラン関連の記事をlife-planディレクトリに配置し、プライベートサイトの最初のコンテンツとした。
private.eurekapu.com へのデプロイ
private.eurekapu.comとしてCloudflare Pagesにデプロイする過程で、2つのトラブルに遭遇した。
wranglerがプロジェクト未作成でエラー
wrangler pages deployを実行したところ、Cloudflare Pages上にプロジェクトが存在しないというエラーが返ってきた。初回デプロイ時は事前にプロジェクトを作成する必要がある。
wrangler pages project create private-eurekapu --production-branch=main
これで解決。
production branchの不一致(main vs master)
ローカルのブランチがmasterなのに、Cloudflare Pagesのproduction branchがmainに設定されていた。デプロイしてもproductionとして認識されず、preview扱いになってしまった。
--branch=mainフラグを指定してデプロイすることで、production deploymentとして正しく反映された。
wrangler pages deploy dist/ --project-name=private-eurekapu --branch=main
トップページへの導線追加
メインサイトのトップページに、プライベートコンテンツへの導線を追加した。鍵アイコンをopacity: 0.4で控えめに表示し、知っている人だけが気づくような配置にした。クリックするとprivate.eurekapu.comに遷移する。
振り返り
パスベースの保護で済むと思って手を動かし始めたが、SPA遷移のバイパスを見つけ、ミドルウェアで塞ぎ、さらにプリレンダリングの問題に突き当たって、結局プロジェクト分離に行き着いた。最初から分離していれば30分で終わった作業かもしれない。ただ、「SSGビルドの出力HTMLをブラウザのソース表示で開いて、コンテンツが平文で並んでいるのを目で確認した」あの瞬間がなければ、エッジ認証の守備範囲を正確に把握できていなかった。回り道した分だけ、判断の根拠が手元に残った。
学んだこと:
- SSG + クライアントサイドルーティング + エッジ認証は穴ができやすい。SPA遷移がエッジを経由しないため、パスベースのアクセス制御が効かない場面がある
- プリレンダリングされたHTMLは平文。SSGのビルド成果物にはコンテンツがそのまま含まれるため、CDN上のHTMLファイルにアクセスできれば中身が見える
- ドメイン分離が最もシンプルな解。パスベースで頑張るより、プロジェクトごと分けてドメイン全体を保護する方が確実で、設定もシンプルになる
- wrangler pages deployの初回は
project createが必要。自動作成はされない - production branchの名前はローカルとCloudflare側で揃えるか、デプロイ時に
--branchで明示する