Cloudflare

クライアントに見せるモック置き場を自前で作った — Cloudflare Pages で案件ごとにパスワードを分ける

ドアの鍵穴に差し込まれた鍵とキーホルダー
Photo by George Becker on Pexels

「どこに置いて見てもらうか」が毎回問題になる

デザインのモックや改修案を作ったあと、それをどうやって相手に見てもらうかで毎回止まります。

ローカルで動かしているものはそのままでは見せられません。本番サイトに置くわけにもいかない。HTMLファイルを zip で送るのは、相手に解凍と手元での表示をお願いすることになるので気が引けます。スマホで確認したいと言われたら詰みます。

やりたいことは単純で、URLとパスワードを渡すだけで見てもらえる状態です。ただ、これを満たす方法を探すと意外と選択肢が絞られました。

既製のサービスを検討した

まず既存のホスティングサービスの機能を調べました。

手段費用引っかかった点
Netlify Password ProtectionPro プラン($19/月)保護の単位がサイト全体。案件ごとに分けたいならサイトを増やすことになる
Vercel Deployment ProtectionAdvanced は Pro に追加課金/Hobby は Vercel AuthenticationHobby だとプレビューは守られるが本番ドメインは公開のまま。パスワード方式は実質有料
Cloudflare Access(Zero Trust)無料・50ユーザーまで認証がメールのワンタイムコードなどになり、相手にログイン操作をお願いすることになる。席数の管理も発生する

費用の問題もありますが、それ以上に引っかかったのが保護の単位相手の手間でした。

案件は同時に複数走ります。A社に渡したURLでB社のモックが見えてはいけないので、案件ごとに別のパスワードで仕切りたい。ところが Netlify や Vercel の保護は基本的にサイト単位なので、この形にするなら案件のたびにサイトを立てることになります。

Cloudflare Access は無料枠が大きくて魅力的でしたが、相手にメールを開いてコードを入力してもらう手順が挟まります。こちらの都合で相手に手数をかけるのは避けたいところでした。

結局、やりたいことは「1つの置き場を作って、その中を案件ごとに仕切る」だけです。それなら自分で作ったほうが早い、という結論になりました。

作ったもの:2段のゲート

構成はシンプルです。

Cloudflare Pages(静的ファイル)
  └── Pages Functions の middleware
        ├── 1段目:全体の Basic 認証
        └── 2段目:案件パスごとのパスワードゲート(Cookie)

GitHub にpushすると自動でデプロイされます。ホスティング費用は無料枠に収まるので、固定費は0円です。

置き場の構造も単純で、案件ごとに1ディレクトリを切るだけにしました。

public/
├── index.html        ← 案件一覧
├── project-a/        ← 案件ごとに1ディレクトリ
└── project-b/

サーバー側で弾く

ここが一番重要なところです。

パスワードゲートをJavaScriptで実装してはいけません。「パスワードが合っていたらコンテンツを表示する」という書き方をすると、HTMLはすでに手元に届いています。ソースを開けば中身が読めてしまうので、ゲートとして成立しません。

Pages Functions の middleware なら、HTMLを返す前にリクエストを止められます

export const onRequest = async (context) => {
  const { request, env, next } = context;

  // 1段目:Basic 認証(全体)
  const expected = 'Basic ' + btoa(`${env.BASIC_AUTH_USER}:${env.BASIC_AUTH_PASS}`);
  if (!timingSafeEqual(request.headers.get('Authorization') || '', expected)) {
    return new Response('Authentication required', {
      status: 401,
      headers: { 'WWW-Authenticate': 'Basic realm="preview"' },
    });
  }

  // 2段目:パスごとのゲート(後述)
  // …
  return next();
};

next() を呼ばない限り静的ファイルは配信されないので、未認証の相手にはHTMLそのものが届きません。

案件ごとのパスワードは、環境変数にJSONで持たせています。パスのプレフィックスをキーにする形です。

{ "/project-a/": "…", "/project-b/": "…" }

リクエストのパスと照合して、マッチしたものだけゲートをかけます。プレフィックスが複数マッチする場合は長いほうを優先します(/project-a//project-a/detail/ で別のパスワードにしたくなったときのため)。

Cookieまわりで考えたこと

パスワードを入力させる部分は、?gate=<パスワード> で受けて、正解なら Cookie を発行してクエリを消したURLへリダイレクトする形にしました。

return new Response(null, {
  status: 302,
  headers: {
    'Location': cleanUrl.pathname,
    'Set-Cookie':
      `${cookieName}=…; Path=${cookiePath}; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax`,
  },
});

細かいところで判断が要りました。

SameSite=Lax にする。 ここを Strict にすると、メールやチャットに貼ったリンクから開いたときにCookieが送られず、毎回パスワードを聞かれます。相手からすると「さっき入れたのにまた聞かれる」となるので体験が悪い。このサイトは閲覧するだけで状態を変える操作がないため、Lax で困りません。

Max-Age は30日。 提案から検討までの期間、一度入れたら聞かれない長さにしています。パスワードを変えればCookieの値が一致しなくなるので、その時点で失効します。

Cookieの Path は末尾のスラッシュを外す。 /project-a/ のまま指定すると、/project-a にアクセスしたときにCookieが送られません。スラッシュを落として /project-a にしておくと、その配下すべてに効きます。

比較は定数時間で。 文字列比較を === でやると、一致した文字数によって処理時間が変わります。実害が出る場面は限られますが、認証まわりでは避けておくのが無難です。

function timingSafeEqual(a, b) {
  if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

パスワード入力画面には noindex, nofollow を入れておきます。

ハマったところ3つ

シークレットは読み出せない

Cloudflare Pages の環境変数を「シークレット」として登録すると、ダッシュボードからもAPIからも現在値を読み出せません

最初は1つのJSONに全案件のパスワードを詰めていたのですが、この持ち方だと案件を1つ足すたびに全案件ぶんを書き直すことになります。既存分が読めないので、控えから拾ってきて全部書き直すしかない。取り違えると、関係のない案件のゲートまで壊れます。

環境変数は1案件につき1つに分けておけば、追加は1件を書き込むだけで既存には触りません。値が1つ壊れても、その案件が無効になるだけで他は動き続けます。最初からこうしておけばよかっただけの話です。

それとは別に、パスワードの控えをどこに置くかも先に決めておく必要があります。リポジトリやコミットメッセージに平文で書かないのは当然として、控えを失うと二度と取り出せないので、保管場所のほうが本体という感覚になります。

環境変数を変えたら再デプロイが必要

Pages Functions は環境変数をデプロイ時にバインドします。値を書き換えただけでは、すでに動いているデプロイには反映されません。

パスワードを追加したのに新しい案件のゲートが動かない、という状態になって少し悩みました。空コミットをpushして再デプロイすれば反映されます。

wrangler pages deploy を使わない

GitHub連携で自動デプロイしている場合、CLIから直接デプロイすると連携が壊れます。手元から一発で上げたくなる場面はありますが、そこは我慢してpush経由に統一しました。

技術より難しかったのは、文言と情報の分離

ここからが本題かもしれません。

このサイトはクライアントが直接見る画面です。つまり、制作側の都合や内部事情が表に出ていない状態を常に保つ必要があります。実装より、こちらのほうが気を使いました。

一覧に並べた時点で「存在すること」は伝わる

案件ページの中身はパスワードで守られます。でも、一覧ページに案件名が並んでいれば、その案件が存在することは伝わります。クライアント名や業種が読み取れる状態だと、他社に「あそこの仕事をしているらしい」と分かってしまう。

中身が守られていることと、存在が隠れていることは別問題です。

最初は「新しい案件を一覧に載せるかどうか」を毎回考えていたのですが、途中で一覧ページ自体もゲートの中に入れてしまえばいいと気づきました。相手には個別のURLを渡しているので、一覧を開くのは自分だけです。だったら守っても誰も困りません。

やることは、一覧のパス(/)にもパスワードを設定するだけでした。前方一致で長いほうを優先する作りにしてあるので、/ を1件足しても案件ページの挙動は変わりません。新しい仕組みを足すのではなく、一覧を「案件の1つ」として扱うだけで済みました。

これで、一覧に何が並んでいても外からは見えません。載せる載せないの判断が要らなくなったぶん、うっかりの余地も減りました。

読み手を第三者のように指さない

一覧ページに「クライアント様向けページ」と書きかけて、手が止まりました。それを読んでいるのは当のクライアントです。 読み手自身を第三者のように指す文言になってしまいます。

自分向けの管理画面のつもりで書いた言葉が、そのまま相手の目に入る。当たり前のことですが、作っているときは意識から抜けます。

保護を低く見せる言葉を使わない

「簡易パスワード」と書きかけて、これもやめました。

実装はサーバー側でリクエストを弾いていて、簡易でも何でもありません。それなのに自分から「簡易」と名乗ると、保護が弱いという誤った印象を与えます。「仮」「とりあえず」も同じで、事実として確定していないなら「検討用」「ご確認用」と書けば済みます。

謙遜のつもりの言葉が、相手の不安になることがあります。

内部の符丁を画面に出さない

ディレクトリ名、リポジトリ名、内部での呼び方。これらは制作側の都合でしかないので、画面には出しません。案件名は先方の正式表記に合わせます(略称を勝手に使わない)。

ゲートは「設定した」で通さない

パスワードまわりを触ったら、必ず実際のリクエストで確認しています。

  1. 未入力で開く → フォームが出るか
  2. 誤ったパスワードを入れる → 弾かれるか
  3. 正しいパスワードを入れる → 通るか
  4. 他の案件のパスワードで入ろうとする → 弾かれるか

4番目が特に大事です。パスワードの照合をパスと紐づけずに書いてしまうと、「どれか1つ合っていれば通る」実装になりかねません。設定したつもりで動いていなかった、が一番怖いので、毎回この4通りを踏むようにしました。

まとめ

  • クライアントに見せる場所は「URLとパスワードを渡すだけ」で済ませたい。既製サービスは保護の単位がサイト単位だったり、相手にログイン操作を求めるので意外と噛み合わない
  • Cloudflare Pages + Pages Functions なら、middleware でHTMLを返す前に弾ける。JSで隠す実装はゲートにならない
  • Cookie は SameSite=LaxPath は末尾スラッシュを外す。認証の比較は定数時間で
  • シークレットは読み出せない。環境変数は1案件につき1つに分けておき、控えの置き場所を先に決める。変更したら再デプロイ
  • 技術より、文言と情報の分離のほうが難しい。中身を守ることと存在を隠すことは別問題で、後者は一覧ページも案件の1つとして守ると片付く

自分で作ると決めた最大の理由は、費用ではなく「相手に手間をかけない形にしたかった」ことでした。見てもらう相手の操作が1つ減るなら、こちら側の実装が少し増えるのは悪くない取引だと思っています。

参照リンク