Webhook認証・トークン競合・フェイルクローズなアクセス制御
はじめに
SDKが物理的な扉を開けるとき、バグは描画の不具合では済みません。正当なゲストのために解錠されない鍵か、あるいは入れてはならない相手に開いてしまう扉か、そのどちらかです。どちらの障害モードも信頼を破壊しますが、その原因となる欠陥のクラスはまったく異なります。
本記事では、スマートロックプラットフォームにおける一連の本番変更を追いながら、その背後にある一般化可能なエンジニアリングパターンを抽出します。マルチテナントのwebhook検証、セッションスコープの資格情報配信、single-flightなトークンリフレッシュ、規律あるエラー分類体系、デフォルト拒否のRBAC、フェイルクローズなfeature flag、そして意図的にランダムなコードをどうテストするか、といったトピックを扱います。
1. マルチテナントwebhook: 1つのエンドポイント、多数のシークレット
メンバーシップを付与する決済webhookは、すなわち予約と扉へのアクセスを付与するものであり、データの通り道ではなく認証境界です。典型的な失敗は、多数の組織にサービスを提供しながら、すべての受信イベントを単一のグローバル署名シークレットで検証してしまうことです。
各テナントが自分の決済アカウントを持った瞬間から、各テナントは自分の署名シークレットも持つことになります。検証は組織ごとにシークレットを解決しなければならず、組織が解決できない場合はフェイルクローズしなければなりません。
export class WebhookError extends Error {
constructor(public code: string, public status: number) {
super(code);
}
}
export async function verifyWebhook(req: Request): Promise<StripeEvent> {
const signature = req.headers.get('stripe-signature');
if (!signature) throw new WebhookError('missing_signature', 400);
// Raw body BEFORE any JSON parsing — re-serialization breaks HMAC.
const raw = await req.text();
const orgId = resolveOrgFromRoute(req);
if (!orgId) throw new WebhookError('unresolved_tenant', 404);
const secret = await loadWebhookSecret(orgId);
// No global fallback. Unknown tenant => reject, never "trust by default".
if (!secret) throw new WebhookError('unknown_endpoint', 404);
return stripe.webhooks.constructEvent(raw, signature, secret);
}内面化しておく価値のある3つのルール:
- raw bodyに対して検証する。 JSONをパースして再シリアライズするミドルウェアは、静かにHMACを無効化します。
- グローバルなフォールバックシークレットを持たない。 フォールバックはテナントごとの境界を共有の境界に変えてしまい、1テナントから漏洩したシークレットが全テナントのイベントを偽造できるようになります。
- 解決できないテナントは拒否であり、デフォルトではない。 解決ロジックのすべての分岐でフェイルクローズします。
同じ領域の関連修正として、サブスクリプションの期間フィールドがsubscriptionオブジェクトからsubscription item に移動した件があります。誤った階層から current_period_end を読むと、静かに誤った有効期限が生成されます。アクセス管理システムにおいては、それは寿命が長すぎる資格情報を意味します。上流のAPIがフィールドの形を変えたときは、静かに undefined を返すオプショナルチェーンよりも、throwするパース処理を選びましょう:
const periodEnd = subscription.items.data[0]?.current_period_end;
if (typeof periodEnd !== 'number') {
throw new WebhookError('missing_period_end', 422);
}2. セキュリティ上重要な呼び出し経路における this バインディングの罠
今回のバッチで最も気づきにくいバグの1つ: データベースRPCヘルパーが裸のメソッド参照として渡され、レシーバを失っていました。
// BROKEN: `rpc` loses its `this` binding and blows up at call time
const { rpc } = supabase;
await rpc('grant_membership_access', { p_user_id: userId });これはwebhookハンドラの内部で、しかもイベントが既にackされた後に実行時エラーとなります。例外のコストが最も高い場所そのものです。防御策は2つ:
// 1. Wrap instead of destructure — the arrow preserves the receiver.
type RpcCall = <T>(fn: string, args: Record<string, unknown>) => Promise<{ data: T | null; error: DbError | null }>;
const rpc: RpcCall = (fn, args) => supabase.rpc(fn, args);{
"rules": {
"@typescript-eslint/unbound-method": ["error", { "ignoreStatic": true }]
}
}このlintルールは、この欠陥クラス全体を静的に検出します。トークン発行、権限付与、資格情報の配信といった特権的なコードパスでは、メソッドを参照で渡し回すのではなく、型を明示したラッパー関数を使いましょう。
3. 資格情報の配信エンドポイントはアクセス制御エンドポイントである
「修正した電話番号に鍵を再送する」エンドポイントは、便利機能のように見えます。しかし実際には、プロダクトで最も危険な経路です。有効な資格情報を、攻撃者が指定した宛先へと再ターゲットするからです。トランザクションIDだけを要求する実装なら、そのIDを推測または観測できる者は誰でも鍵を乗っ取れます。
修正は、その操作をトランザクションを作成したセッションに紐付けることです:
export async function handleResend(req: Request): Promise<Response> {
const { sessionToken, transactionId, phone } = ResendSchema.parse(await req.json());
const session = await loadSession(sessionToken);
if (!session || session.transactionId !== transactionId) {
return json({ code: 'FORBIDDEN' }, 403);
}
if (session.expiresAt <= Date.now()) {
return json({ code: 'SESSION_EXPIRED' }, 403);
}
if (await rateLimiter.exceeded(`resend:${transactionId}`, { max: 3, windowMs: 600_000 })) {
return json({ code: 'TOO_MANY_REQUESTS' }, 429);
}
await auditLog({
action: 'key.resend',
transactionId,
actorSessionId: session.id,
destinationHash: sha256(normalizePhone(phone)),
ip: clientIp(req),
});
return dispatchKeyDelivery(transactionId, phone);
}資格情報を配信・再配信するあらゆるエンドポイントのチェックリスト:
- 長命なリソース識別子ではなく、短命なセッショントークンに紐付ける。
- セッションが存在するだけでなく、セッションがそのリソースを所有していることを検証する(
session.transactionId === transactionId)。 - リトライが攻撃ベクターであるため、リソース単位でレートリミットをかける。
- 宛先はハッシュとして監査ログに残す。余分なPIIを保存せずにフォレンジックを可能にするためです。
- 資格情報そのもの(PIN、鍵URL、OTP)を平文でログに出さない。
同じ原則が、チェックイン処理の修正も導きました。そこでは有効な設定を施設のデフォルトではなくリカバリトークンから解決するようにしました。トークンは発行時点の正確なコンテキストを運びますが、デフォルトは施設が今どうなっているかを運ぶだけです。トークンが存在するなら、それが信頼できる情報源です。そうでなければ、施設設定の変更が、過去に発行された資格情報の意味を遡って書き換えてしまいます。
4. トークンリフレッシュの競合: single-flightと正直なエラー分類
ロックハードウェアの連携は、ローテーションするOAuthスタイルのトークンの上で動きます。2つのバグが同時に現れます:
- 同時リフレッシュ。 N個のリクエストがトークンの期限切れ間近を検知し、すべてがリフレッシュし、ローテーションされたトークン同士が互いを無効化する。
- 誤分類されたエラー。 ローテーション進行中に発生した
401が、運用者には「ロックが切断されました」と報告される。
後者こそが信頼の破壊者です。誤った「デバイスオフライン」アラートを見た運用者は、あらゆるアラートを信じなくなります。まずsingle-flightで競合を修正し、それからエラーを正直に分類しましょう。
let refreshInFlight: Promise<AccessToken> | null = null;
export async function getAccessToken(): Promise<AccessToken> {
const cached = await tokenStore.read();
if (cached && !isExpiringWithin(cached, 60_000)) return cached;
refreshInFlight ??= refreshToken(cached)
.then(async (next) => {
await tokenStore.write(next); // persist rotation before returning
return next;
})
.finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}type FailureKind = 'auth' | 'transient' | 'fatal';
export function classify(err: unknown): FailureKind {
if (isStatus(err, 401) || isStatus(err, 403)) return 'auth';
if (isNetworkError(err) || isStatus(err, 429) || isStatus(err, 502) || isStatus(err, 503)) {
return 'transient';
}
return 'fatal';
}
export async function callDevice<T>(op: () => Promise<T>): Promise<T> {
try {
return await op();
} catch (err) {
if (classify(err) === 'auth') {
await getAccessToken(); // single-flight refresh, then one retry
return op();
}
throw err;
}
}ローテーションの永続化には独立した注記が必要です。リフレッシュしたトークンをメモリ上にしか保持しないと、プロセスが再起動するたびにローテーションを1回消費し、やがてプロバイダ側と同期がずれます。返す前に永続化し、さらにトークン寿命より十分に短いカットオフでプロアクティブなリフレッシュジョブをスケジュール実行しましょう(例: 有効期限5日に対して日次実行)。こうすれば1回の実行漏れが障害になることはありません。
一時的なネットワーク障害も、他の場所で同じ正直さを必要とします。TLSハンドシェイクエラーで失敗したカレンダー同期はバックオフ付きでリトライすべきですが、不正なペイロードによる 400 はまったくリトライすべきではありません。
export async function withRetry<T>(op: () => Promise<T>, attempts = 3): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await op();
} catch (err) {
lastErr = err;
if (classify(err) !== 'transient') throw err;
await sleep(2 ** i * 250 + Math.random() * 100); // jittered backoff
}
}
throw lastErr;
}5. 500を決して漏らさないエラー分類体系
今回のバッチのいくつかの修正は、1つの根本原因を共有しています。ポリシー上の結果がクラッシュとして表出していたことです。月間の予約クォータに達すると 500 が返り、プランへの再サブスクライブはユニーク制約違反で 500 を返し、中断した決済の再開は 404 を返していました。
これらはいずれも既知の、想定内のビジネス状態です。明示的にマッピングしましょう:
| 状況 | ステータス | クライアントの挙動 |
|---|---|---|
| 不正な入力 | 400 |
修正して再試行 |
| 未認証 / 誤ったセッション | 403 |
再認証 |
| ポリシー上の上限到達(クォータ) | 403 |
ローカライズされた説明を表示 |
| 既に存在 / 重複操作 | 409 |
既存リソースを再開 |
| 想定外の欠陥 | 500 |
オンコールにアラート |
export class AppError extends Error {
constructor(readonly detail: { code: string; status: number; i18nKey: string; meta?: Record<string, unknown> }) {
super(detail.code);
}
}
export async function subscribe(userId: string, planId: string) {
try {
return await db.insertMembership({ userId, planId });
} catch (err) {
if (isUniqueViolation(err, 'membership_user_plan_uniq')) {
throw new AppError({
code: 'MEMBERSHIP_ALREADY_ACTIVE',
status: 409,
i18nKey: 'errors.membership.already_active',
});
}
throw err; // genuinely unexpected — let it page someone
}
}金銭や資格情報が絡む複数ステップのフローでは、ハードな失敗ではなく冪等な再開を:
export async function initiatePayment(input: InitiateInput): Promise<InitiateResult> {
const pending = await findPendingIntent(input.membershipId);
if (pending && !isExpired(pending)) {
return { status: 'resumed', checkoutUrl: pending.checkoutUrl };
}
const created = await createIntent(input);
return { status: 'created', checkoutUrl: created.checkoutUrl };
}副次的な利点が2つあります。500 が再び本物のアラートシグナルになること、そして500以外のすべての結果が i18nKey を持つため、メッセージをレンダリング時にローカライズしてユーザーの言語切り替えに追従させられることです。エラー生成時にサーバーが使っていたロケールで固定されることはありません。
6. デフォルト拒否のRBACと冪等な権限マイグレーション
新しい機能(ロッカー管理、買取管理)をリリースするとき、権限は誰かに付与される前に存在していなければならず、既存ロールは意図的に更新されなければなりません。デフォルト拒否モデルであれば、シード漏れは「メニューが表示されない」として現れます。煩わしいですが安全です。逆のデフォルトでは、新機能が静かに全員に読めてしまいます。
権限マイグレーションは冪等でなければなりません。環境をまたいで再実行され、ブランチ間でバックマージされるからです:
insert into role_permissions (role_id, permission_key)
select r.id, p.key
from roles r
cross join (values ('locker-management'), ('buyback-management')) as p(key)
where r.scope = 'organization'
and r.name in ('org_admin', 'org_manager')
on conflict (role_id, permission_key) do nothing;強制はデータ層にも属します。UIガードの書き忘れが、ユーザーとロッカーの間に立つ唯一のものであってはなりません:
create policy locker_boxes_read on locker_boxes
for select using (
exists (
select 1 from user_permissions up
where up.user_id = auth.uid()
and up.org_id = locker_boxes.org_id
and up.permission_key = 'locker-management'
)
);7. ロールアウトの安全網としてのフェイルクローズなfeature flag
これらのコミットの大きな割合はflag関連の作業です。新しいメニューflagをデフォルトOFF・override専用で出荷し、さらにグルーピング・フィルタリングと施設横断の一括無効化を備えた管理画面を追加しました。最後の機能こそが重要です。高速かつ広範なキルスイッチのないロールアウト機構は、安全網とは呼べません。
export interface FlagDefinition {
key: FlagKey;
defaultValue: boolean;
overrideOnly?: boolean; // never on by default, even if defaultValue flips
}
export function isEnabled(key: FlagKey, ctx: FlagContext): boolean {
const def = FLAG_DEFINITIONS[key];
if (!def) return false; // unknown flag => disabled, never enabled
const override = ctx.facilityOverrides?.[key];
if (typeof override === 'boolean') return override;
return def.overrideOnly === true ? false : def.defaultValue;
}不変条件は次のとおりです。未知のflagは false。評価は決してthrowしない。overrideはスコープ(施設/組織)を持つため、まずいロールアウトは封じ込められる。そしてすべてのflag変更は実行者とスコープ付きで監査ログに残る。「誰がその施設の、扉に関わる機能をONにしたのか?」という問いは、必ず尋ねられるからです。
8. CIにおけるシェル補間の防御
あるCIジョブは、プルリクエストのタイトルを直接シェルコマンドに補間して検証していました。クォートを含むタイトルはスクリプトを壊しました。そして同じ仕組みは、そのままコマンドインジェクションの経路になります。PRタイトルは攻撃者が制御できるテキストだからです。
- name: Validate PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: node scripts/validate-pr-title.mjs "$PR_TITLE"ルールは単純かつ絶対です: 信頼できないテンプレート式を run: ブロックに決して補間しないこと。環境変数経由で渡せば、シェルはそれをコードではなくデータとして扱います。あなたのCIは資格情報を持っています。本番環境として扱いましょう。
9. 意図的にランダムなコードをテストする
ロッカーのPIN発行は、空きボックスを選んでコードを生成します。選択戦略が「最初の空き」からシャッフルに変わったとき、正確なボックスIDをアサートしていたテストが壊れました。これは、テストが性質ではなく実装詳細をアサートしていたことの典型的な兆候です。
乱数源を注入し、不変条件をアサートしましょう:
export interface Rng {
next(): number;
}
export async function issueLockerBoxPin(deps: { boxes: Box[]; rng: Rng }): Promise<Issued> {
const free = deps.boxes.filter((b) => b.status === 'available');
if (free.length === 0) {
throw new AppError({ code: 'NO_AVAILABLE_BOXES', status: 409, i18nKey: 'errors.locker.no_capacity' });
}
const picked = free[Math.floor(deps.rng.next() * free.length)];
return { boxId: picked.id, pin: generatePin(deps.rng) };
}it('always picks from the free set, never an occupied box', async () => {
const boxes = makeBoxes({ free: 3, occupied: 5 });
for (let seed = 0; seed < 200; seed++) {
const result = await issueLockerBoxPin({ boxes, rng: seededRng(seed) });
expect(freeIds(boxes)).toContain(result.boxId);
expect(result.pin).toMatch(/^\d{6}$/);
}
});
it('reports capacity exhaustion as 409, not a crash', async () => {
const boxes = makeBoxes({ free: 0, occupied: 8 });
await expect(issueLockerBoxPin({ boxes, rng: seededRng(1) })).rejects.toMatchObject({
detail: { code: 'NO_AVAILABLE_BOXES', status: 409 },
});
});2つ目のテストは実際の修正を反映しています。すべてのボックスが物理的に使用中のとき、ユーザーが受け取るべきは汎用的な失敗ではなく、空き容量に関するメッセージです。「すべてのリソースが使用中」は一級のドメイン状態であり、そのようにモデル化しテストされるべきです。
このバッチからのもう1つのテストに関する注記: 外部クライアントはRPC境界でモックし、環境変数のバリデーションはモジュールロード時に移しました。設定を起動時に一度だけ検証することで、実行時の不意打ちのクラスを、騒がしい起動失敗へと変換できます:
const EnvSchema = z.object({
DEVICE_API_BASE_URL: z.string().url(),
DEVICE_API_CLIENT_ID: z.string().min(1),
DEVICE_API_CLIENT_SECRET: z.string().min(1),
});
export const env = EnvSchema.parse(process.env); // throws at import time, not mid-unlock10. 静かに書き換えられない状態遷移
ガイド付きのプロビジョニングフローは、2つのおなじみの危険を露呈しました。1回目の実行が進行中に2回目を開始してしまう二重サブミットと、既に書き込まれたレコードを空にしてしまう再実行です。
フローを明示的なdiscriminated unionとしてモデル化し、再入をガードし、置き換えられたレコードをイミュータブルにします:
type ProvisionState =
| { status: 'idle' }
| { status: 'applying'; attempt: number }
| { status: 'verifying'; appliedAt: number }
| { status: 'applied'; snapshot: Snapshot; frozen: true }
| { status: 'failed'; error: AppError };
export function reduce(state: ProvisionState, event: ProvisionEvent): ProvisionState {
switch (state.status) {
case 'idle':
return event.type === 'START' ? { status: 'applying', attempt: 1 } : state;
case 'applying':
if (event.type === 'START') return state; // re-entrancy guard
if (event.type === 'APPLIED') return { status: 'verifying', appliedAt: Date.now() };
return event.type === 'FAILED' ? { status: 'failed', error: event.error } : state;
case 'verifying':
return event.type === 'VERIFIED'
? { status: 'applied', snapshot: event.snapshot, frozen: true }
: state;
case 'applied':
return state; // terminal: a new run appends a record, never mutates this one
case 'failed':
return event.type === 'START' ? { status: 'applying', attempt: 1 } : state;
}
}コンパイラが網羅性を強制し、不正な遷移は破壊的ではなく無反応となり、履歴は追記専用になります。これはまさに監査証跡が必要とするものです。同じ規律はライフサイクルの副作用にも当てはまります。プランが無効化されるとき、その遷移はメンバーのアクセス権の取り消しと課金の停止をアトミックに行わなければなりません。そうすれば、権利の状態と支払いの状態が食い違うことはありません。
まとめ
これらすべての変更に共通する筋は、あらゆる曖昧な分岐は、拒否か、明示的に名付けられた状態のどちらかに解決されなければならないということです。静かなデフォルトに向かってはなりません:
- テナントごとのwebhookシークレット、グローバルフォールバックなし、raw bodyに対する検証。
- 資格情報の再配信は、そのリソースを所有していると証明できるセッションでゲートし、レートリミットと監査ログを付ける。
- single-flightで永続化されるトークンリフレッシュと、正直な
auth/transient/fatalの分類。 - クォータは
403、重複は409、500は本物の欠陥のために予約するエラー分類体系。 - 冪等な権限マイグレーションとデータベースレベルの強制を伴うデフォルト拒否のRBAC。
- 一括キルスイッチと監査ログを備えたフェイルクローズなfeature flag。
- 信頼できないCI入力は環境変数経由で渡し、決してシェルに補間しない。
- 注入された乱数に対するプロパティベーステストと、不正な遷移を表現不可能にする明示的なステートマシン。
どれ1つとして単独で気の利いたものではありません。しかしそれらが揃ったとき、単に扉を開けるシステムと、扉を開けさせても構わないと思えるシステムとの違いが生まれます。