プレビュー・ステージング環境向けパスワードゲート
すべてのリクエスト(静的アセットを含む)の手前に置く共有パスワードゲート -- 改ざん検知付きのマーカー Cookie、POST 限定のログイン、ブルートフォース対策の遅延を備える
概要 -- ゲートは認証ではない
パスワードゲートは、ステージングやプレビューのデプロイを検索エンジンや通りすがりの訪問者から遠ざけるための仕組みだ。見せてよい相手全員が知っている 1 つの共有パスワードが、1 つの Cookie を介してサイト全体を保護する。これは認証とは根本的に別の仕事である。
ゲートには identity がなく、ユーザーごとの境界もなく、パスワードのローテーション以外に無効化の手段もない
ゲートを通過した全員が、まったく同じマーカー Cookie を受け取る。ユーザー名も、ユーザーごとのセッションもなく、訪問者を互いに区別する方法もない。サイトが本物のユーザーデータを扱う、ユーザーごとに異なる権限が必要である、あるいは他の全員をログアウトさせずに特定の 1 人だけをキックできる必要がある場合、このレシピは適切な道具ではない -- 代わりに HTTP-only Cookie セッション や Auth with Pages Functions を使う。パスワードゲートは、それが実際にできること -- 本番外のサイトを Google の検索結果や何気ない閲覧から遠ざける共有シークレット -- のためだけに使う。
ゲートのフロー
HTML、JSON、そしてすべての静的アセットを含む、あらゆるリクエストがまず Worker に届かなければならない。そうしなければ、ファイルを直接リクエストすることでゲートを迂回できてしまう。これには run_worker_first = true が必要だ。デフォルトの false では、Cloudflare のアセット層が一致する静的ファイルを Worker が実行される前に返してしまい、そこに巻いたゲートを黙って迂回してしまう。仕組みの全体と、他のユースケース向けに存在する配列形式という中間案については Workers Static Assets: Routing Traps を参照(ゲートにはサイト全体に対するデフォルト拒否が必要であり、限定されたサブツリーではないため、この中間案はここには合わない)。
Wrangler 設定とシークレット
name = "my-preview-site"
main = "./dist/_worker.js"
compatibility_date = "2025-01-01"
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
# Every request must reach the Worker so the gate runs before any asset
# is served. See Workers Static Assets: Routing Traps for what happens
# at the default `false`.
run_worker_first = trueシークレットは 2 つ。どちらも平文のパスワードそのものではない。
# Compute the digest locally once; the plaintext password is never stored
# anywhere, not even as a Worker secret. `-r` prints "<hex> *stdin" --
# copy just the hex portion into the prompt below.
printf '%s' 'correct-horse-battery-staple' | openssl dgst -sha256 -r
npx wrangler secret put GATE_PASSWORD_HASH
# A separate signing key for the marker cookie, unrelated to the password.
openssl rand -hex 32 | npx wrangler secret put GATE_SECRETinterface Env {
ASSETS: Fetcher;
GATE_PASSWORD_HASH: string; // sha256 hex digest of the real password
GATE_SECRET: string; // HMAC signing key for the marker cookie
}GATE_PASSWORD_HASH はパスワード保管用のハッシュではなく、ただのダイジェストだ
1 つの共有プレビューパスワードは、マルチユーザーのログインシステムとは異なる脅威モデルにある。ここで SHA-256 が高速であるのは意図的なもので、(後述する)生の可変長文字列同士の比較を避けるためだけに存在し、ダイジェストを盗んだ攻撃者がオフラインで総当たりすることへの耐性を持たせるためではない。このゲートが本物のユーザーごとの認証情報を守ることになった場合は、遅くてソルト付きのパスワードハッシュ(bcrypt/scrypt/Argon2)が必要になる。これは共有ゲートパスワードの範囲外だ。
パスワードの検証: まずハッシュ化し、それから一定時間で比較する
生の送信パスワードと生の保存パスワードに対する素朴な === や自前の文字ループは、タイミングを通じてパスワードの長さ(そして素朴なループの場合は最初に不一致となったバイトの位置)を漏らしてしまう -- 2 つの可変長文字列が異なった時点で比較が返ってしまうためだ。まずハッシュ化することで入力を固定長のダイジェストに変換し、比較するのはその固定長ダイジェストだけになる。
とはいえ、2 つのダイジェストに対する自前の XOR ループそのものは、それだけでは安全な着地点にならない。ECMAScript はどの JS の一片についても一定時間実行を保証しておらず、JIT コンパイラは比較ループを、まさにそれが避けようとしていたタイミング信号を再び持ち込むような形で最適化する自由を持っている。crypto.subtle.timingSafeEqual() は、まさにこの比較のために Workers ランタイムが自ら提供するプリミティブだ -- JS の実行の外側で実装されており、エンジンの裁量に委ねられていない。
async function sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// Both arguments are fixed-length hex digests (64 chars for SHA-256) by the
// time this runs -- that is what makes the length check below safe (the
// digest length is public, not the secret's) and what guarantees the two
// encoded buffers are the same length, which crypto.subtle.timingSafeEqual()
// requires: it throws, rather than returning false, on a length mismatch.
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const encoder = new TextEncoder();
return crypto.subtle.timingSafeEqual(encoder.encode(a), encoder.encode(b));
}
async function verifyPassword(submitted: string, env: Env): Promise<boolean> {
const submittedHash = await sha256Hex(submitted);
return timingSafeEqualHex(submittedHash, env.GATE_PASSWORD_HASH);
}これは、このレシピがシークレットをチェックするあらゆる箇所で使う比較だ: 可変長の入力をまずハッシュ化し、その結果の固定長ダイジェストに対してのみ crypto.subtle.timingSafeEqual() を走らせる。Bot Worker と HMAC-SHA256 によるウェブフック署名 も、同じ理由から同じ形を使っている。
マーカー Cookie
サーバー発行で推測不能。予測可能な固定値ではない
「この訪問者はすでにゲートを通過した」ことを示す Cookie は、訪問者が自分で作れてしまうものであってはならない。gate=1 のような固定値は、その Cookie を一度でも見た人なら誰でも偽造できてしまう -- サーバーが発行したことの証明を何も持っていないからだ。以下のマーカーは、有効期限のタイムスタンプに対する HMAC で、GATE_SECRET で鍵付けされている。鍵なしでは偽造できず、期限自体も署名でカバーされているため、改ざんすると Cookie を延長するのではなく無効化する。
const MARKER_COOKIE_NAME = "gate_ok";
const MARKER_TTL_SECONDS = 60 * 60 * 12; // 12 hours
async function hmacHex(message: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function mintMarker(env: Env): Promise<string> {
const expiresAt = Math.floor(Date.now() / 1000) + MARKER_TTL_SECONDS;
const signature = await hmacHex(`gate:${expiresAt}`, env.GATE_SECRET);
return `${expiresAt}.${signature}`;
}
async function verifyMarker(cookieValue: string | undefined, env: Env): Promise<boolean> {
if (!cookieValue) return false;
const [expiresAtRaw, signature] = cookieValue.split(".");
if (!expiresAtRaw || !signature) return false;
const expiresAt = Number(expiresAtRaw);
if (!Number.isInteger(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) {
return false; // malformed, or past its own signed expiry
}
// The signature is a fixed-length hex digest already -- no separate
// hashing step needed before the timing-safe compare here.
const expected = await hmacHex(`gate:${expiresAtRaw}`, env.GATE_SECRET);
return timingSafeEqualHex(signature, expected);
}GATE_SECRET をローテーションすると、既存のマーカー Cookie がすべて一度に無効化される
verifyMarker は GATE_SECRET に対してのみ署名を検証するため、このシークレットを差し替えると、その時点ですべての訪問者が持っているマーカー Cookie がすべて即座に無効になる -- 全員が再ログインを求められる。GATE_PASSWORD_HASH だけをローテーションしても、これは起こらない -- それは新しいログインが何を受け入れるかを変えるだけで、古いパスワードの下ですでに発行済みのマーカーには何も影響しない。パスワードの漏洩が懸念であれば、両方をローテーションすること。
Cookie の属性は省略できない
function buildMarkerCookie(value: string): string {
const parts = [
`${MARKER_COOKIE_NAME}=${value}`,
"HttpOnly",
"Secure",
"SameSite=Lax",
"Path=/",
`Max-Age=${MARKER_TTL_SECONDS}`,
];
return parts.join("; ");
}HttpOnly-- このマーカーはページの JavaScript から読む必要が一切ない。document.cookieからアクセスできないようにしておくことで、XSS による窃取経路を 1 つ塞ぐ。Secure-- 平文の HTTP では絶対に送信されない。Path=/-- 明示的であることが重要で、これは省略できない。この Cookie はPOST /gate/loginハンドラーから発行される。Pathを明示しないと、ブラウザはそれを設定したリクエストのディレクトリ(/)をデフォルトにしてしまい、マーカーがgate/ /にスコープされてサイトの残りの部分がゲートされないままになる。gate/ * Path=/があってはじめて、ゲートがサイト全体に適用される。Max-Age--MARKER_TTL_SECONDSと一致しており、これは署名済みのexpiresAtに焼き込まれた値と同じだ。Cookie の属性と署名済みのペイロードは意図的に一緒に期限切れになる: 属性はブラウザが古い Cookie を自発的に送らなくなる仕組みであり、署名済みのタイムスタンプは、クライアントがそれより前に保存しておいた Cookie のコピーを単純に送り返すことで期限切れを回避できないようにする仕組みだ。SameSite=Lax-- ゲートされたプレビューへの共有リンク(Slack のリンクやメール)はトップレベルのクロスサイトGETナビゲーションであり、Laxはそれでも Cookie を通す。Strictだとそうしたリンクを踏むたびにゲートに 2 度当たることになる。HTTP-only Cookie セッションの CSRF セクションも同じLax対Strictのトレードオフで締めくくられており、ここでもそのまま当てはまる。このゲートにはログイン POST 自体を除いて状態を変更するルートがないため、目の前にあるパスワードチェック以上に別途ゲートすべき CSRF の攻撃面はない。
プロジェクトにすでに Cookie ユーティリティがあるなら、汎用的な parse/serialize ヘルパーについては HTTP-only Cookie セッション を参照。このゲートが必要とする Cookie は 1 つだけなので、汎用シリアライザーを持ち込むのではなく、ここで直接組み立てている。
ログインは POST 限定、失敗時は固定の遅延を入れる
ログインルートは POST しか受け付けない。パスワードを送信できる GET は、パスワードをサーバーログ、ブラウザ履歴、そして訪問者が次にクリックするものの Referer ヘッダーに残してしまう -- これらはすべて、fetch によるフォーム POST なら避けられるものだ。
const FAILURE_DELAY_MS = 800;
async function handleLogin(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
}
const form = await request.formData();
const submitted = String(form.get("password") ?? "");
const nextRaw = String(form.get("next") ?? "");
const ok = await verifyPassword(submitted, env);
if (!ok) {
// A small fixed delay on every failure is cheap and needs no storage --
// it caps a naive online brute-force sweep to a handful of guesses per
// second per connection. It is not a substitute for real rate limiting
// if the gate is worth automating against at scale; for that, add a
// per-IP failure counter in KV with expiry, the same TTL-based shape as
// Bot Worker's [rate limiting](./bot-worker.mdx#rate-limiting-with-kv).
await new Promise((resolve) => setTimeout(resolve, FAILURE_DELAY_MS));
return new Response("Incorrect password", { status: 401 });
}
const marker = await mintMarker(env);
const target = sanitizeRedirectTarget(nextRaw);
return new Response(null, {
status: 303,
headers: new Headers([
["Location", target],
["Set-Cookie", buildMarkerCookie(marker)],
]),
});
}ログイン後のリダイレクトは SSRF レシピのサニタイザーを再利用する
上記の next の値は、信頼できない POST フォームデータとして届く -- 訪問者のブラウザはログインフォームの hidden フィールドに入っていた値をそのまま送り返すだけであり、代わりに細工したリクエストが任意の値をそこへ入れることを止めるものは何もない。これはまさに SSRF とリダイレクト安全性、パート 2 が扱っている形そのものだ: 同一オリジンパスの検証、デコードは 1 回だけ行ってから検証すること、そして /、バックスラッシュ、埋め込まれた CR/LF の拒否。sanitizeRedirectTarget は再実装せずそこからインポートする -- ゲートのログインフローと一般的なログイン後リダイレクトフローは検証のセマンティクスが同一なので、正しく保つべき実装は 1 つだけでよい。
import { sanitizeRedirectTarget } from "./redirect-safety"; // see SSRF and Redirect Safety, Part 2next のもう 1 つの登場箇所 -- ブロックされた GET リクエストに対してゲートが最初にログインフォームを描画する場面 -- は信頼できないものではない。これは Worker がすでに処理しているリクエストの url.pathname + url.search からそのまま来ているので、構造上同一オリジンであり、hidden フィールドに書き込む前に必要なのは HTML エスケープだけで、オープンリダイレクト用のサニタイザーは不要だ。
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "<")
.replace(/>/g, ">");
}
function gateFormHtml(next: string): string {
return `<!doctype html>
<html>
<body>
<form method="POST" action="/gate/login">
<input type="password" name="password" required autofocus />
<input type="hidden" name="next" value="${escapeHtmlAttr(next)}" />
<button type="submit">Enter</button>
</form>
</body>
</html>`;
}ここでのエスケープは HTML インジェクションを防ぐためのもので、sanitizeRedirectTarget が防ぐオープンリダイレクトとは別の問題だ。この 2 つのチェックは別々の問題を解決していて、どちらも必要になる: sanitizeRedirectTarget はログイン POST を経て戻ってきて Location ヘッダーで使われる値に対して一度だけ実行され、escapeHtmlAttr はその値がどこから来たかに関わらず、フォームの HTML に書き込まれる値に対して実行される。
全体を組み立てる
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/gate/login") {
return handleLogin(request, env);
}
const cookies = parseCookieHeader(request.headers.get("Cookie") ?? "");
const passed = await verifyMarker(cookies[MARKER_COOKIE_NAME], env);
if (!passed) {
const next = url.pathname + url.search;
return new Response(gateFormHtml(next), {
status: 401,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// Gate passed -- serve the real site, assets included, ourselves.
return env.ASSETS.fetch(request);
},
};
function parseCookieHeader(header: string): Record<string, string> {
const cookies: Record<string, string> = {};
for (const pair of header.split(";")) {
const trimmed = pair.trim();
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
cookies[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
return cookies;
}run_worker_first = true により、HTML、JSON、そして directory 配下のすべてのファイルを含む、あらゆるリクエストがまずこのハンドラーに届く。マーカー Cookie の検証を通過すると、Worker は env.ASSETS.fetch(request) によって実際のアセットを自ら配信する。これはまさに 完全なゲーティング修正 に書かれているペアリングそのものだ: フラグ単体はルーティングを変えるだけであり、Worker 自身が認可を行い、そのうえでアセットを配信する必要がある。
関連
Workers Static Assets --
run_worker_firstと、デフォルトのfalseがなぜ静的サイトに巻いたゲートを黙って迂回してしまうか。SSRF とリダイレクト安全性 -- このレシピがログイン後のリダイレクトで再利用しているリダイレクト先サニタイザー。
Bot Worker -- 可変長シークレットを比較する前にハッシュ化するという
timingSafeEqualHexの tip。HMAC-SHA256 によるウェブフック署名 -- Webhook 署名検証に適用された、同じハッシュ優先・一定時間比較。
HTTP-only Cookie セッション -- サイトが 1 つの共有パスワードではなく本物のユーザーごとの identity を必要とするようになったときに使うパターン。