zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

サンプル: Password Gate

静的な zfb サイトの前段に手書きの Cloudflare Worker を置き、アセットを返す前に共有パスワードを確認するサンプル

このページの内容

3 ページだけの小さな静的プレビューサイトを、共有パスワードを知らない訪問者が普通にアクセスしても読めないようにした例です。zfb がビルドするのは 静的アセットだけ で、その手前に 手書きの Cloudflare Worker を置き、まずパスワードを尋ねます。

このサンプルは、example サイト群のなかで唯一 @takazudo/zfb-adapter-cloudflare を使わずに Cloudflare と向き合っています。アダプタが担当する範囲と、自分で書く Worker の範囲との境目を知りたいときに読むページです。

ライブデモ: zfb-example-password-gate.takazudomodular.com

リポジトリ: Takazudo/zfb-example-password-gate

ライブデモが HTTP 401 を返すのは仕様です

上の URL を開くと、サイト本体ではなくログインページと 401 Unauthorized が返ります。これはデプロイが壊れているのではなく、意図どおりに動いている状態です。認証されていない訪問者がコンテンツに到達しないことこそがこのサンプルの目的だからです。デプロイ後のスモークテストもまさにそこを検査しており、正常なデプロイは GET / に対して 401 のログインページを返さなければならず、サイトの中身を返してはいけない、というアサーションになっています。

何を示すか

  • 純粋な SSG ビルド(output: "static")の dist/ を Workers Static Assets としてデプロイする構成。

  • デプロイのエントリポイントに 手書きの Worker を指定する構成(main = "src/index.ts")。zfb のアダプタはプロジェクトのどこにも入っていません。

  • [assets].run_worker_first = true — ゲートが実際に走るのか、それとも実ファイルに解決されるパスすべてで黙って迂回されるのかを決める、たった 1 行の設定。

  • Cloudflare の Worker シークレット(SITE_PASSWORD)を読み、開発用フォールバックを localhost に限定し、デプロイ先でシークレットが無い場合は閉じる方法。

  • zfb の設定を zfb.config.ts ではなく zfb.config.json で書く構成。オプションは同じで、設定ファイル自体の TypeScript ビルドが不要になります。

使用技術

要素リポジトリでの実際の内容
フレームワークzudo-react を使う @takazudo/zfb と @takazudo/zfb-runtime 3.1.0。Preact / React への依存はありません
設定ファイルzfb.config.json — TS ではなく JSON。output: "static"、outDir: "dist"、publicDir: "public"、zudo-wind の owned-v1 リセット
スタイリングstyles/global.css の手書き CSS と zudo-wind の owned-v1 リセット。生成ユーティリティクラスは使いません
レンダリング方式純粋な SSG。export const prerender = false を宣言するルートはなく、pages/ 配下の 3 ページはビルド時にのみレンダリングされます
Cloudflare の形態Workers Static Assets。wrangler.toml で main = "src/index.ts"、[assets].directory = "./dist"、binding = "ASSETS"
バインディングASSETS のみ。KV も D1 もキューも Workers AI もありません
zfb Cloudflare アダプタ未導入。@takazudo/zfb-adapter-cloudflare は package.json のどこにも登場しません
主な devDependencieswrangler 4.85.0、@cloudflare/workers-types 4.20260424.1。Worker 自体はランタイム依存をひとつも持ちません

Worker は src/index.ts にあり、Cookie の解析とシリアライズは src/cookies.ts にあります。このサンプルのサーバー側ロジックはこの 2 ファイルに収まっています。

必要なものと設定

Cloudflare アカウントなしで動く範囲。 pnpm dev、pnpm build、pnpm preview は Node と pnpm さえあれば動き、静的サイトだけを扱います。ゲートもローカルで動かせます。ビルド後に wrangler dev --local を実行すれば、ビルド済みの dist/ に対して Worker が動きます。SITE_PASSWORD が無いとき開発用パスワードにフォールバックするのは、平文 HTTP の loopback オリジンだけです。それ以外のオリジンでは、シークレットを設定するまでログインをすべて拒否します。

Cloudflare が必要になる範囲。 必要なのはデプロイだけです。

  • Worker シークレット SITE_PASSWORD — pnpm exec wrangler secret put SITE_PASSWORD で設定します。これは Cloudflare 側 のシークレットで、wrangler.toml の var でも GitHub のシークレットでもありません。Worker シークレットは即座に反映され、その後のデプロイでも保持されます。

  • リポジトリの deploy.yml が使う GitHub Actions シークレット: CLOUDFLARE_API_TOKEN と CLOUDFLARE_ACCOUNT_ID。トークンには Account · Workers Scripts(Edit)、Account Settings(Read)、さらに Zone · Workers Routes(Edit)が必要です。Zone 権限は省略できません。wrangler.toml が custom_domain ルートを張っており、そのルート作成が zone レベルの操作だからです。

  • プロビジョニングするリソースはありません。 KV ネームスペースも D1 データベースもマイグレーションもシードデータも不要です。ASSETS バインディングは dist/ から Cloudflare が自動で作ります。

デプロイしたゲートを使うには SITE_PASSWORD が必要です

デプロイ先の訪問者がログインするには、Cloudflare Worker シークレットとして SITE_PASSWORD を設定する必要があります。コミット済みの開発用フォールバックが使われるのは平文 HTTP の loopback オリジンだけです。デプロイ先でシークレットが無いか空の場合、ログインはすべて拒否され、不足しているシークレットがログに記録されます。

ライブデモはゲートされていますが、書き込みはできません。 何かを保存するフォームもデータベースもユーザーごとの状態もありません。ログイン後は共有パスワードから導出した Bearer マーカー Cookie が発行されます。共有パスワードのゲートを認証とみなす前に、仕組み の最後にある注意書きを読んでください。

仕組み

なぜアダプタではなく Worker を手書きするのか

@takazudo/zfb-adapter-cloudflare は ページ をリクエスト時にレンダリングしたい場合のための仕組みです。ルートに export const prerender = false を付けると、zfb が dist/_worker.js を生成し、リクエストをページハンドラへ振り分けて Cloudflare のバインディングを渡してくれます。生成される Worker の中身については Worker 上の SSR を、実際のセットアップ手順については SSR と Cloudflare バインディング を参照してください。

このサイトはそれをまったく必要としません。どのページも静的 HTML のままで十分です。必要なのは、リクエストがどのページにも届く 前 の判断 — 画像でも CSS でも 404 でも、あらゆるパスに対して一律に効く検査です。それはページのレンダリングではなくエッジのミドルウェアであり、背後のファイルを zfb が作ったかどうかを知る必要がありません。

そのため wrangler.toml は、生成されたバンドルではなく手書きのソースを main に指しています。

wrangler.toml
name = "zfb-example-password-gate"
# The Worker source lives OUTSIDE the assets directory — this is a hand-written
# gate, not an adapter bundle, so `main` is the TS entry, not `dist/_worker.js`.
main = "src/index.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]

このサンプルが示す判断基準はこうです。ページの HTML がリクエストに依存するならアダプタ、リクエストがそもそもページに届く必要がないなら手書きの Worker。 認証ゲート、リダイレクト、ヘッダーの書き換え、地域別ルーティングはいずれも手書き側に入ります。もちろん両者は排他ではなく、アダプタでビルドしたサイトの前段にさらに Worker のロジックを置いても構いませんが、このサンプルは使わないものを入れないことで依存関係を正直に保っています。

効いているのは run_worker_first の 1 行

wrangler.toml
[assets]
directory = "./dist"
binding = "ASSETS"
# MANDATORY — do not set to false. With the default `false` the asset layer
# answers GET/HEAD before the Worker runs, so any path resolving to a static
# file is served without ever reaching the password gate: a silent bypass that
# still looks protected. `true` forces the Worker first; it serves assets itself
# via env.ASSETS.fetch() once the request is authorized.
# scripts/smoke.mjs asserts a real static asset path is gated, which is what
# catches a regression of this line.
run_worker_first = true

Workers Static Assets では通常、実ファイルに対応するパスへの GET/HEAD は Worker のコードが動く前に アセット層が応答します。静的サイトを速くするためのデフォルトですが、ここではまさに逆効果です。dist/ 内のファイルに解決されるパスはすべて、ゲートを通らずに配信されてしまいます。

しかも壊れ方が静かです。エラーも出ずログも残らず、ただサイトが非公開でなくなるだけです。run_worker_first = true を設定すると順序が反転し、Worker が先に走り、リクエストを認可したあとに env.ASSETS.fetch(request) で自らアセットを返します。

リポジトリのスモークテストがサイトのトップページを叩くだけで終わらないのもこのためです。実行時に dist/ から実在のアセットパスを読み取り、そちら もゲートされていることをアサートします。こうしておけば、アセット層がディレクトリをどう解決するかに左右されるパスではなく、デプロイに実在するファイルにアサーションを固定できます。

ゲート本体

リクエスト処理の全体は 1 つの fetch ハンドラに収まっています。

src/index.ts
export default {
  async fetch(request: Request, env: RuntimeEnv): Promise<Response> {
    const url = new URL(request.url);

    if (await hasValidMarker(request, env, url)) {
      return withPrivateCaching(await env.ASSETS.fetch(request));
    }

    if (url.pathname === AUTH_PATH && request.method === "POST") {
      return handleAuth(request, env);
    }

    return loginResponse(sanitizeNext(url.pathname + url.search));
  },
} satisfies ExportedHandler<RuntimeEnv>;

分岐は 3 つ、この順番です。マーカー Cookie を持つリクエストはそのままアセット層へ委譲されます。POST /__auth はログイン試行です。それ以外は — どんなパスでもどんなメソッドでも — インラインのログインページを 401 で返し、本来向かおうとしていたパスを hidden な next フィールドに保持するので、ログインに成功すると元の行き先に着地します。

真似する価値のある細部は次のとおりです。

  • resolveExpectedPassword() は空でない SITE_PASSWORD を使い、開発用フォールバックは平文 HTTP の loopback オリジンに限ります。それ以外のオリジンで認証情報が無い場合は拒否します。timingSafeEqual() は SHA-256 ダイジェストを定数時間の XOR ループで比較します。

  • ログインとエラーの応答には Cache-Control: no-store、X-Robots-Tag: noindex、Vary: Cookie が付きます。認証済みアセットの応答も private no-store と Cookie による Vary を設定するため、共有エッジキャッシュが訪問者間でゲート内の内容を再配信しません。

  • hasValidMarker() は解決したパスワードから HMAC-SHA256 でマーカーを導出します。マーカーはソースに固定値として置かれておらず、パスワードを変えると既存 Cookie も無効になります。

  • マーカー Cookie は HttpOnly かつ SameSite=Lax で、Secure が外れるのは localhost、127.0.0.1、[::1] への素の http: の場合 だけ です。それ以外のホストには必ず Secure が付きます。

  • next はクライアントから渡されるリダイレクト先なので、使う前にサニタイズされます。

src/index.ts
export function sanitizeNext(value: string): string {
  if (!value.startsWith("/") || value.startsWith("//")) return "/";

  for (const char of value) {
    const code = char.charCodeAt(0);
    if (char === "\\" || code < 0x20 || code === 0x7f) return "/";
  }

  return value;
}

プロトコル相対の //evil.example(オープンリダイレクト)、バックスラッシュを使ったパスの小細工、制御文字(改行を紛れ込ませたヘッダーインジェクション)は、いずれも / に潰されます。

これは何ではないか

共有パスワードは認証ではない

リポジトリ自身のトラストモデルの節が、その限界をはっきり書いています。ユーザーもセッションもロールも監査ログもログアウトも、個人単位の認可もありません。共有パスワードを知っている人は誰でも入れます。マーカーは HMAC-SHA256(password, "zfb-preview-gate-marker-v1") なので、デプロイ先では公開ソースだけから計算できません。有効なマーカー Cookie を取得した人は、期限切れまたはパスワード変更まで利用できます。パスワードがマーカーも決めるため、変更すれば既存 Cookie は自動的に無効になります。

本当に非公開にすべきものには、Cloudflare Access や ID プロバイダ、あるいはアプリケーションレベルの認証を使ってください。このままの形で向いているのは、狙って調べる相手ではなく偶発的な発見を防げれば十分な、リスクの低いプレビューサイトです。

ローカルで動かす

pnpm install
pnpm dev        # zfb dev
pnpm build      # zfb build → dist/
pnpm preview    # zfb preview
pnpm typecheck  # zfb check

リポジトリ自身の言葉での注意点はこうです。pnpm preview は zfb の静的プレビューサーバーを使うため Cloudflare Worker のゲートは動かず、Worker の確認は pnpm build のあとに Wrangler で行うこと。

これは SSR 系のサンプル以上に効いてきます。ここで一番試したくなる部分こそ、pnpm preview が飛ばす部分だからです。ゲートを動かすには、まずビルドしてから Wrangler を起動します。

pnpm build
pnpm exec wrangler dev --local

このとき curl -i http://localhost:8787/ は 401 のログインページを返し、正しいパスワードを form-encoded で POST /__auth すると 302 と Set-Cookie が返るはずです。上で触れた next のサニタイズ確認を含む手動チェックの一式は README にまとまっています。ライブサイトに対して同じアサーションを走らせる pnpm smoke(scripts/smoke.mjs)もあり、SMOKE_URL=http://localhost:8787 pnpm smoke でローカルの Wrangler に向けることもできます。

関連ドキュメント

  • Worker 上の SSR — このサンプルがあえて使っていない、アダプタ生成の dist/_worker.js について。

  • SSR と Cloudflare バインディング — ページを本当にリクエストごとにレンダリングしたい場合のアダプタ経路。

  • 静的アセット — public/ のファイルが dist/ に届くまで。このゲートの背後にあるものです。

  • スタイリング — 手書き CSS、zudo-wind のリセット、ユーティリティによるスタイリング。

  • サンプル — その他の zfb example サイト一覧。

Revision History

作成更新