• #Cloudflare Access
  • #SSG
  • #Nuxt3
  • #プライベートサイト
  • #Cloudflare Pages
  • #セキュリティ
開発mdx-playgroundメモ

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.tscomponentsディレクトリ設定でapps/web/app/componentsを参照先に追加した。DocPageラッパーもそのまま共有できた。

content.config.tsapps/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で明示する