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)を読み、開発用のフォールバックも持たせることで、Cloudflare アカウントなしでもローカルで動かせるようにする方法。

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

使用技術

要素リポジトリでの実際の内容
フレームワークzfb 2.3.0@takazudo/zfb@takazudo/zfb-runtime)、Preact ^10.29.1preact-render-to-string
設定ファイルzfb.config.json — TS ではなく JSON。framework: "preact"output: "static"outDir: "dist"publicDir: "public"
スタイリングTailwind CSS v4。"tailwind": { "enabled": true } と、styles/global.css 冒頭の @import "tailwindcss"
レンダリング方式純粋な SSG。export const prerender = false を宣言するルートはなく、pages/ 配下の 3 ページはビルド時にのみレンダリングされます
Cloudflare の形態Workers Static Assets。wrangler.tomlmain = "src/index.ts"[assets].directory = "./dist"binding = "ASSETS"
バインディングASSETS のみ。KV も D1 もキューも Workers AI もありません
zfb Cloudflare アダプタ未導入@takazudo/zfb-adapter-cloudflarepackage.json のどこにも登場しません
主な devDependencieswrangler 4.85.0@cloudflare/workers-types。Worker 自体はランタイム依存をひとつも持ちません

Worker の実体は src/index.ts の TypeScript 160 行と、src/cookies.ts の Cookie ヘルパー 47 行だけです。このサンプルのサーバーサイドはそれで全部です。

必要なものと設定

Cloudflare アカウントなしで動く範囲。 pnpm devpnpm buildpnpm preview は Node と pnpm さえあれば動き、静的サイトだけを扱います。ゲート自体もローカルで動かせます。ビルド後に wrangler dev --local を実行すれば、ビルド済みの dist/ に対して本物の Worker が走り、SITE_PASSWORD が無いときは Worker がハードコードされた開発用パスワードにフォールバックします。

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

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

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

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

URL を共有する前に SITE_PASSWORD を設定する

src/index.ts は、シークレットが無いときはハードコードされた開発用パスワードにフォールバックします。Cloudflare の状態なしでローカルの Wrangler 実行を成立させるための仕組みです。ただしそのフォールバック値は公開された example リポジトリにコミットされているため、SITE_PASSWORD を設定しないままデプロイすると、パスワードが公知のゲートを公開することになります。

ライブデモはゲートされていますが、書き込みはできません。 何かを保存するフォームもデータベースもユーザーごとの状態もありません。ゲートは、共有パスワードを知っている全員に同一の固定マーカー 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"
run_worker_first = true

Workers Static Assets では通常、実ファイルに対応するパスへの GET/HEADWorker のコードが動く前に アセット層が応答します。静的サイトを速くするためのデフォルトですが、ここではまさに逆効果です。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 (hasValidMarker(request)) {
      return 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 フィールドに保持するので、ログインに成功すると元の行き先に着地します。

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

  • パスワードの比較は expectedPassword = env.SITE_PASSWORD || DEV_PASSWORD で、=== ではなく SHA-256 ダイジェストと定数時間の XOR ループで突き合わせます。

  • ゲートのレスポンスは Cache-Control: no-storeX-Robots-Tag: noindexVary: Cookie を持つため、ゲートされたレスポンスがキャッシュやクローラに残りません。

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

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

src/index.ts
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(オープンリダイレクト)、バックスラッシュを使ったパスの小細工、制御文字(改行を紛れ込ませたヘッダーインジェクション)は、いずれも / に潰されます。

これは何ではないか

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

リポジトリ自身のトラストモデルの節が、その限界をはっきり書いています。ユーザーもセッションもロールも監査ログもログアウトも、個人単位の認可もありません。パスワードを知っている人は誰でも入れますし、固定のマーカー Cookie を持っている人は期限が切れるまで入り続けられます。

サンプル側では明示されていない帰結がひとつあります。AUTH_MARKERsrc/index.ts にハードコードされた定数であり、ゲートが確認する Cookie はパスワードから導出されていません。つまり、公開リポジトリを読める人なら誰でもその 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 すると 302Set-Cookie が返るはずです。上で触れた next のサニタイズ確認を含む手動チェックの一式は README にまとまっています。ライブサイトに対して同じアサーションを走らせる pnpm smokescripts/smoke.mjs)もあり、SMOKE_URL=http://localhost:8787 pnpm smoke でローカルの Wrangler に向けることもできます。

関連ドキュメント

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

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

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

  • スタイリング — 設定キー 1 つで有効になる Tailwind v4 のセットアップ。

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

Revision History

作成更新