設定を忘れたら全部落ちる — 管理画面の認証ゲートを fail-closed に倒した
認証基盤を Cloudflare Workers へ移したとき、運用者向けの管理画面を守っていたゲートが構造ごと消えた。代わりに Cloudflare Access 一本に寄せたのだが、このとき「環境変数を1つ入れ忘れたらどうなるか」の答えを、通す側ではなく落とす側に固定した。結果として管理画面は、設定が欠けている間ずっと 403 を返す。
移行で既存のゲートが2つとも消えた
移行前の管理画面は、共有シークレットによる Basic 認証と、ネットワーク層のゲート(社内ネットワークからしか到達できない)の2枚で守られていた。サーバープロセスが特定のネットワークの内側で動いていることが前提の設計だ。
Workers に移すと、この前提が両方とも成立しない。実行環境はエッジに分散していて「内側」が存在しないし、共有シークレット1本だけを残すのは、退職・漏洩・ローテーションのすべてを手作業で背負うということでもある。そこで共有シークレットの仕組みは廃止し、管理画面のゲートを Cloudflare Access 一本に統合した。以降、管理画面に入れる人間の定義は Access のポリシー側にしか存在しない。
ゲートが1枚になるということは、そのゲートが壊れたときの受け皿が無いということでもある。だから壊れ方を先に決めておく必要があった。
設定が欠けていたら全部落とす
ゲートは Cloudflare Access が付与する Cf-Access-Jwt-Assertion ヘッダーを RS256 で検証する middleware で、audience を ADMIN_ACCESS_AUD に、issuer を ACCESS_TEAM_DOMAIN から組み立てた値にピン留めしている。
肝は最初の分岐だ。2つの環境変数のどちらかが空なら、検証を試みることすらせず即座に 403 を返す。「設定されていないから素通しする」ではなく「設定されていないから何も通さない」。
function accessConfig(env: Env): { aud: string; teamDomain: string } | null {
const aud = env.ADMIN_ACCESS_AUD ?? '';
const teamDomain = env.ACCESS_TEAM_DOMAIN ?? '';
if (aud === '' || teamDomain === '') {
return null;
}
return { aud, teamDomain };
}
export function accessGate(): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const config = accessConfig(c.env);
if (!config) {
// 設定ミス → fail-CLOSED
console.warn(
'admin access: gate misconfigured (ADMIN_ACCESS_AUD or ACCESS_TEAM_DOMAIN unset)',
);
return forbidden();
}
const assertion = c.req.header('Cf-Access-Jwt-Assertion');
if (!assertion) {
return forbidden();
}
try {
const { payload } = await jwtVerify(assertion, resolverFor(config.teamDomain), {
algorithms: ['RS256'],
audience: config.aud,
issuer: `https://${config.teamDomain}`,
});
c.set('accessEmail', typeof payload.email === 'string' ? payload.email : '');
} catch (err) {
console.warn('admin access: jwt verify failed', err);
return forbidden();
}
return next();
};
}この middleware が返す応答は 403 の1種類しかない。ヘッダー欠落・audience 不一致・issuer 不一致・署名検証失敗・設定欠落のどれであっても、外から見える結果は同じ 403 forbidden で、本文にも理由を書かない。どこが違ったのかを教えると、それ自体が攻撃者への情報になるからだ。
設定漏れは「異常系」ではなく「必ず起きる事象」として扱ったほうがいい。本番環境の構築は一度きりの手作業で、シークレットの投入順序も人間が決める。忘れる前提で、忘れたときに何が起きるかを設計する。
同じシステムに fail-open な middleware もある
面白いのは、同じ Worker の中に、まったく逆の判断で書かれた middleware が同居していることだ。ログインとトークン発行に掛けているレート制限は、binding が未設定なら黙って素通しする。fail-open だ。
export function loginRateLimit(): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const limiter = c.env.LOGIN_RATE_LIMITER;
if (!limiter) {
return next(); // binding 未設定 = 素通し(fail-open)
}
// ... limiter.limit({ key }) が false なら 429
};
}同じ移行の中で書かれたコードで判断が真逆になっているのは、間違えたからではない。素通ししたときに失われるものが違う。
- 素通ししても失われるのは「攻撃を少し遅くする効果」だけで、認証そのものは別の層が持っている。
- カウンタはエッジサーバー単位の best effort で、そもそも厳密なクォータとして設計されていない。閾値ちょうどでは発火しないこともある。
- ローカルのテスト環境では binding を外して動かしたい。ここで落とすと、レート制限と無関係なテストまで巻き添えで落ちる。
一方、認証ゲートを素通しさせたときに失われるのは管理画面そのものだ。全ユーザーの一覧、consent の取り消し、クライアント登録、招待の発行。ここが無条件に開くのと、レート制限が効かないのとでは、事故の桁が違う。
判断の分かれ目は「その middleware が唯一の防壁かどうか」だった。唯一なら fail-closed 以外にありえない。他に本命の防壁があって、これは補助的に効いているだけなら、可用性を優先して fail-open にする余地がある。
エッジで遮断し、アプリでも検証する
本番では、管理画面ホストに Cloudflare Access のアプリケーションを紐付けている。未認証のリクエストはエッジで Access のログイン画面へリダイレクトされ、Worker のコードには到達しない。つまり実運用上、上記の middleware はほとんど発火しない。
それでもアプリ側の検証を残しているのは、エッジの設定が「常に正しく効いている」という前提に寄りかからないためだ。Access アプリケーションのパス設定、ホストの割り当て、ポリシーの適用範囲は、すべて別の場所で管理されている設定であって、Worker のコードと一緒にデプロイされるわけではない。ヘッダー自体はクライアントが勝手に付けられるものなので、署名と audience / issuer を検証して初めて意味を持つ。
検証に成功したときだけ、JWT から取り出した運用者のメールアドレスをリクエストコンテキストに積む。ゲートを通過していないリクエストがその内側のハンドラに届くことはないので、ハンドラ側は「誰が操作したか」が常に分かっている前提で書ける。
先に「落ちること」を確かめた
実装した時点では、Access のアプリケーションも AUD もまだ作っていなかった。つまり環境変数が未設定のまま、管理画面のルートがデプロイされている状態だ。fail-closed の設計であれば、この状態は「管理画面の全ルートが 403」になるはずだった。
検証環境で管理画面の全ルート(GET も POST も)を、JWT なしと偽の JWT の両方で叩いた。結果はすべて 403。AUD と team domain を設定していないので全遮断 = 想定どおり、と記録に残っている。管理画面が開かないことを確認してから、肯定パスの構築に進んだ。
# 環境変数を投入する前の管理画面ホストを叩く
$ curl -s -o /dev/null -w '%{http_code}\n' https://admin.example.com/users
403
# 自前で鋳造した偽の Access JWT を付けても変わらない
$ curl -s -o /dev/null -w '%{http_code}\n' \
-H "Cf-Access-Jwt-Assertion: $FORGED_JWT" \
https://admin.example.com/users
403自動テスト側でも、fail-closed の5パターンをそれぞれ 403 として固定してある。
- Cf-Access-Jwt-Assertion ヘッダーが無い
- JWT の audience が ADMIN_ACCESS_AUD と一致しない
- JWT の issuer が team domain と一致しない
- JWKS に含まれない鍵で署名された JWT
- ADMIN_ACCESS_AUD / ACCESS_TEAM_DOMAIN が未設定(設定ミス)
加えて、テスト用にローカル JWKS を注入できるようにして、正しい JWT なら通ることも同じテストファイルで実走させている(本番の JWKS 取得はリモート、テストは注入した鍵)。落ちるケースだけを固めると、「常に 403 を返すだけの middleware」でもテストが通ってしまうので、肯定パスは必ず1本要る。
設定漏れが機能停止として可視化される
安全側に倒す設計の実利は、本番切り替えの日に出た。管理画面ホストのルートを有効化し、Access のアプリケーションを作り、2つの環境変数を投入する。この順序のどこかで手が止まれば、管理画面は 403 のままだ。開こうとした人間が即座に気づく。
これが fail-open だったらどうなるか。設定を入れ忘れても、管理画面は普通に開く。開いたのだから正常だと判断して次の作業に移る。ゲートが無いことは、誰にも観測されない。手順書のチェックリストを人間が目視するしか検出手段が無くなり、その手順書は往々にして実地とずれている。
fail-closed は「設定漏れ」という観測しにくい状態を、「機能停止」という誰でも観測できる状態に変換する仕掛けでもある。安全性の話に見えて、実は運用の可視性の話だった。
同じ考え方は、メール配信のバウンス通知を受け取るエンドポイントにも入れている。こちらは条件付きで、本番のメール送信が有効(送信元アドレスが設定済み)なのに、通知トピックのピン留めが未設定なら 500 を返して閉じる。署名が有効であることは「どこかのトピックが署名した」ことしか証明しないので、トピックを自分のものに固定しないと、偽造したバウンス通知で任意ユーザーの配信を停止させられてしまう。閉じる条件を「常に」ではなく「本番相当の設定のときだけ」にしてあるのは、ローカルでの再生テストを潰さないためだ。
まとめ
middleware を書くときに毎回決めているのは、閾値やアルゴリズムより先に「依存している設定が欠けていたら、通すのか落とすのか」だ。判断基準は単純で、それが唯一の防壁なら落とす、他に本命があるなら通してよい。この一行を先に決めておくと、実装の分岐は自然に決まる。
そして落とす側に倒したなら、実装直後に「本当に落ちること」を確かめておく価値がある。設定を入れる前という、後から二度と作れない状態で検証できるのはそのタイミングだけだからだ。設定を入れたあとでは、全遮断を再現するために本番の設定を壊すしかなくなる。