UnlockOS Developers
← 記事一覧に戻る
🔐

バイパスの窓を閉じる:競合・冪等性・トークンゲート

2026年6月15日2026年6月21日
9
172 commits
深度 8/10
securitystate-machineerror-handlingtestingtypescript

バイパスの窓を閉じる:競合・冪等性・トークンゲート

物理的な扉を開けるシステムにおいて、バグは見た目の不具合では済みません。ゲストが締め出されるか、権限のない人物が侵入するかのどちらかです。本記事では、チェックイン/チェックアウト、会員登録のアクティベーション、外部PMS連携、そしてテストスイートにまたがって最近リリースした一連のハードニング対応を追いながら、それらをあらゆるアクセス制御システムに適用できるパターンへと一般化します。

貫くテーマはこうです。あらゆる多段階フローには、システムが中間状態にある「窓」が存在し、その窓こそが競合(race)とバイパスの棲み処である。


1. 終端状態への遷移は「先勝ち」ではなく冪等であるべき

チェックアウトは少なくとも3か所からトリガーされ得ます。ゲストがボタンをタップする、予定終了時刻に自動チェックアウトのジョブが発火する、オペレーターが管理コンソールから強制する、の3つです。素朴な実装はこうなりがちです。

// Fragile: read-then-write with a window between the two
const stay = await getStay(stayId);
if (stay.status !== 'checked_in') {
  throw new Error('Invalid state');
}
await updateStay(stayId, { status: 'checked_out', checkedOutAt: new Date() });

問題は2つあります。第一に、read-check-write の間の窓によって自動チェックアウトのジョブとユーザー操作がインターリーブし、副作用が重複します(監査ログの二重記録、課金シグナルの二重送信など)。第二に、競合に敗れた側がハードエラーを受け取ります。スケジューラが処理した1秒後に「チェックアウト」をタップしたゲストは、実際には成功している操作に対して不穏な失敗表示を見ることになります。

ガードをデータベース側に押し込み、「すでに終端状態である」ことを成功として扱いましょう。

update stays
set    status         = 'checked_out',
       checked_out_at = coalesce(checked_out_at, now()),
       checkout_source= coalesce(checkout_source, $2)
where  id = $1
  and  status in ('checked_in', 'checked_out')
returning id, status, checked_out_at, checkout_source;

coalesce によって書き込みが冪等になります。最初の遷移がタイムスタンプとソースを刻み、以降はno-opでありながら行を返します。where 句が状態ガードであり、書き込みと原子的に評価されるため、並行する呼び出し元が両方とも checked_in を観測することはありません。

サービス層では「自分が実行した」と「すでに実行済みだった」を区別しつつ、どちらも成功として報告します。

export type CheckoutResult =
  | { ok: true; performed: boolean; checkedOutAt: string }
  | { ok: false; code: 'STAY_NOT_CHECKED_IN' | 'STAY_NOT_FOUND' };

export async function checkout(
  stayId: string,
  source: 'guest' | 'auto' | 'operator'
): Promise<CheckoutResult> {
  const row = await db.oneOrNone(CHECKOUT_SQL, [stayId, source]);
  if (!row) {
    const exists = await db.oneOrNone('select 1 from stays where id = $1', [stayId]);
    return { ok: false, code: exists ? 'STAY_NOT_CHECKED_IN' : 'STAY_NOT_FOUND' };
  }
  return { ok: true, performed: row.checkout_source === source, checkedOutAt: row.checked_out_at };
}

経験則: 終端状態に対して正しいセマンティクスは排他的ではなく収束的です。リトライ、ダブルタップ、スケジューラの重複はすべて同じ行へ収束すべきです。


2. 終端状態はフラグを倒すだけでなく、資格情報を失効させなければならない

チェックアウトで危険なのはステータス列ではなく、ゲストのUIに残り続けるアクセス資格情報です。キーカード、PIN、「延泊」ボタンが遷移後も生き残るなら、それは実質的にチェックアウト後のアクセスを許可したことになります。

私たちは資格情報を、キャッシュされた別個の状態ではなく、ステートマシンから導出される値にしました。

interface StaySnapshot {
  status: 'reserved' | 'checked_in' | 'checked_out' | 'cancelled';
  accessWindow: { start: string; end: string };
}

const ACTIVE_STATUSES = new Set(['checked_in']);

export function visibleCredentials(stay: StaySnapshot, now: Date): CredentialView {
  const withinWindow = now >= new Date(stay.accessWindow.start)
                    && now <= new Date(stay.accessWindow.end);
  const active = ACTIVE_STATUSES.has(stay.status) && withinWindow;
  return {
    showKeyCard: active,
    showCheckoutAction: active,
    showExtendAction: active,
    // Never render a stale credential from a previous render pass.
    credential: active ? stay.credential : null,
  };
}

ここから2つの不変条件が導かれます。

  1. 単一の信頼できる情報源。 UIは、その導出元となった状態より長生きする「currentKey」を保持しません。状態が更新されるたびに、古いカードは自動的に消えます。
  2. 時刻は状態の一部。 予約終了は、明示的なチェックアウトと同じくらい終端条件です。accessWindow.end でキーを隠すのに、ジョブが時間どおり走る必要はありません。

サーバー側の失効処理も依然として重要です(クライアントはセキュリティ境界ではありません)が、ビューを状態から導出することで「幽霊キー」系のバグを丸ごと排除できます。


3. 多段階オンボーディングはステートマシンであり、スキップされたエッジはすべてバイパス

私たちは「申込先行型」の会員フローを導入しました。見込み客が申込を送信し、スタッフが審査し、承認後にはじめて申込者が支払ってアクティベートされます。これは4つの状態と厳密なエッジ集合であり、最初のレビューでエッジをスキップする経路が2つ見つかりました。

  • クライアントがアクティベーションのエンドポイントを直接呼び、承認を飛ばして pending_review → active へジャンプできた。
  • 決済webhookと手動承認の競合により、未払いのサブスクリプションがアクティブになり得た。

修正は、遷移表を明示的に書き下し、すべてのアクションでサーバー側から強制することです。

export type ApplicationStatus =
  | 'draft'
  | 'pending_review'
  | 'approved'
  | 'rejected'
  | 'payment_pending'
  | 'active'
  | 'cancelled';

const TRANSITIONS = {
  draft:           ['pending_review', 'cancelled'],
  pending_review:  ['approved', 'rejected', 'cancelled'],
  approved:        ['payment_pending', 'cancelled'],
  payment_pending: ['active', 'cancelled'],
  active:          ['cancelled'],
  rejected:        [],
  cancelled:       [],
} as const satisfies Record<ApplicationStatus, readonly ApplicationStatus[]>;

export function assertTransition(from: ApplicationStatus, to: ApplicationStatus): void {
  const allowed: readonly ApplicationStatus[] = TRANSITIONS[from];
  if (!allowed.includes(to)) {
    throw new DomainError('INVALID_TRANSITION', { from, to });
  }
}

satisfies は表の網羅性を保ちます。ユニオンにステータスを追加すると、その出力エッジを宣言するまでTypeScriptはビルドを通しません。これは型安全性が実際にセキュリティの仕事をしている例です。到達不能な状態やガードされていない状態を、こっそり混入させることはできません。

とはいえ、プロセス内のバリデーションは並行性の下では不十分です。権威あるガードは書き込みと同じ文の中に置くべきです。

update membership_applications
set    status = 'active', activated_at = now()
where  id = $1
  and  status = 'payment_pending'
  and  exists (
         select 1 from payments p
         where  p.application_id = membership_applications.id
           and  p.status = 'succeeded'
       )
returning id;

返る行が0件なら、アクティベーションは行われていません。状態が不正だったか、成功した決済が存在しなかったかのどちらかです。webhookがどんな順序で届こうとも、未払いのアクティブ会員は生まれません。


4. クライアント側の残留物で認証ステップを満たさせない

関連するバグパターンがゲスト予約フローで見つかりました。中断された試行から残っていた決済状態オブジェクトによって、ゲストがメール+OTP検証を完了しないまま確認ステップに到達できたのです。フローのガードは実質こうでした。

if (paymentState) {
  goToConfirmation(); // OTP already done... allegedly
}

オブジェクトが存在したので、チェックは通ってしまいました。truthy であることは認証ではありません。

修正は2点、いずれも一般化する価値があります。

interface VerifiedSession {
  email: string;
  verifiedAt: number;   // epoch ms of successful OTP
  facilityId: string;
  nonce: string;        // ties the session to this booking attempt
}

const OTP_TTL_MS = 15 * 60 * 1000;

export function isVerified(s: VerifiedSession | null, ctx: BookingContext, now = Date.now()): boolean {
  if (!s) return false;
  if (s.facilityId !== ctx.facilityId) return false;   // no cross-facility reuse
  if (s.nonce !== ctx.nonce) return false;             // no cross-attempt reuse
  return now - s.verifiedAt < OTP_TTL_MS;              // no infinite validity
}

そして、フローがリセットされたら残留物を破棄すること。 ユーザーがメールアドレスを変更したり、決済を中断したり、予約をやり直したりしたときは常に、以前の検証状態を再利用せずに破棄します。機微なフロー状態は試行ID単位にスコープを切り、積極的にガベージコレクトすべきです。残った オブジェクトは、残った認可そのものだからです。

もちろん、サーバー側でも再検証します。クライアント側のゲートはUXであり、サーバー側のゲートがセキュリティです。今回は両方を直す必要がありました。


5. 認可チェックには完全なサブジェクトが必要、さもないと上位ロールが静かに失敗する

ある連携ハブで、Platform Admin に対して管理者専用カードが表示されていませんでした。原因は平凡ですが示唆的です。feature-flag の評価器が施設コンテキスト付きで、しかしユーザーIDなしで呼ばれていたため、管理者バイパスの分岐が決して発火しなかったのです。

// Before: subject is incomplete, so role-based overrides are dead code
const enabled = await isEnabled('card_gcal', { facilityId });

// After: the evaluator receives the whole subject
const enabled = await isEnabled('card_gcal', { facilityId, userId, roles });

今回は「安全側」(アクセスが少なすぎる方向)に失敗しました。しかし鏡像のバグ、つまりコンテキストが欠けたときに許可をデフォルトとするポリシー関数は、fail open します。ですからコンテキストは型レベルで必須にしましょう。

export interface PolicySubject {
  userId: string;        // required, not string | undefined
  facilityId: string;
  roles: readonly Role[];
}

export async function isEnabled(flag: FlagKey, subject: PolicySubject): Promise<boolean> {
  const rule = await loadRule(flag, subject.facilityId);
  if (!rule) return false;                          // default-deny
  if (subject.roles.includes('platform_admin')) return true;
  return rule.enabledFor(subject);
}

サブジェクトを必須かつ完全に埋まった構造体にすれば、アイデンティティを引き回し忘れた呼び出し箇所をコンパイラがすべて検出します。オプショナルな認証コンテキストは潜在的な脆弱性です。


6. 特権的な読み取りは、明示的なクレーム検査を伴う definer 関数の背後へ

施設設定は、クライアントから直接テーブルをJOINして読まれていました。これは関係するすべてのテーブルで行レベルポリシーが完璧である限りにおいてのみ機能します。攻撃面が広すぎます。そこで、チェックをカプセル化した単一のデータベース関数に置き換えました。

create or replace function get_facility_settings(p_facility_id uuid)
returns table (facility_id uuid, address text, contact_email text, contact_phone text)
language plpgsql
security definer
set search_path = public
as $$
begin
  if not has_facility_claim(auth.uid(), p_facility_id) then
    raise exception 'forbidden' using errcode = '42501';
  end if;
  return query
    select f.id, f.address, f.contact_email, f.contact_phone
    from   facilities f
    where  f.id = p_facility_id;
end;
$$;

security definer 関数で重要な3点:

  • set search_path = public により、呼び出し元が制御するスキーマによる search-path ハイジャックを防ぎます。
  • 認可チェックは最初の文であり、空集合を返すのではなく例外を投げます。これにより呼び出し元が「拒否」と「空」を混同できません。
  • 射影(projection)は明示的です。select * を使わないので、後から機微な列を追加してもレスポンスが静かに広がることはありません。

7. 外部システムに渡すリソースはトークンでゲートする

受信側のPMS連携では、ゲストが当社アプリにログインすることなくゲストキーを届ける必要があります。まさにこの種の利便性が、配信URLが単に /key/<reservationId> であるときに列挙(enumeration)の穴へと化けるのです。

代わりに、短命でスコープ限定の署名付きトークンを発行します。

interface KeyDeliveryClaims {
  sub: string;        // reservation id
  fac: string;        // facility id
  scope: 'key:read';  // single capability
  exp: number;        // seconds since epoch
  jti: string;        // for replay tracking / revocation
}

export async function verifyDeliveryToken(raw: string, secret: CryptoKey): Promise<KeyDeliveryClaims> {
  const [body, sig] = raw.split('.');
  const expected = await hmacSha256(body, secret);
  if (!timingSafeEqual(decodeBase64Url(sig), expected)) {
    throw new AuthError('INVALID_TOKEN');
  }
  const claims = JSON.parse(decodeText(decodeBase64Url(body))) as KeyDeliveryClaims;
  if (claims.scope !== 'key:read') throw new AuthError('INVALID_SCOPE');
  if (claims.exp * 1000 < Date.now()) throw new AuthError('TOKEN_EXPIRED');
  if (await isRevoked(claims.jti)) throw new AuthError('TOKEN_REVOKED');
  return claims;
}

このパターンのチェックリスト:

  • 署名比較は定数時間で行う。生文字列に === は禁物です。
  • 狭いスコープ: このトークンは1件の予約のキーを読むことしかできません。キャンセルも延長も一覧取得もできません。
  • 短い有効期限jti により、署名鍵をローテートせずに漏洩したリンクを無効化できます。
  • パースして信頼する前に検証する: まず署名、次にクレーム。
  • 引き換えのたびに監査行(token jtireservationipoutcome)を書き込みます。物理アクセスにおいては「誰がこの扉を開け、どう認可されていたのか」という問いに常に答えられなければならないからです。

8. サードパーティのコンテンツは境界でサニタイズする

カレンダー連携は、任意の外部ユーザーが書いた説明文をインポートします。それらはHTMLとして届きます。オペレーターコンソールの近くでレンダリングすれば蓄積型XSSへの招待状ですし、「安全な」テキストノードの中でさえマークアップは醜いノイズです。

すべてのレンダリング箇所で正しくエスケープされることを祈るのではなく、取り込み時にフラット化します。

export function htmlToPlainText(input: string): string {
  return input
    .replace(/<\s*br\s*\/?\s*>/gi, '\n')
    .replace(/<\/\s*(p|div|li|tr)\s*>/gi, '\n')
    .replace(/<[^>]*>/g, '')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/\n{3,}/g, '\n\n')
    .trim()
    .slice(0, MAX_NOTE_LENGTH);
}

信頼境界で一度だけ正規化し、正規化後の形を保存します。レンダリング時のサニタイズは忘れる機会がN回ありますが、取り込み時なら1回です。長さの上限も防御の一部です。無制限の外部入力は、それをインデックスしたりレンダリングしたりするあらゆるものにとってDoSベクトルになります。


9. 壁時計の正しさは安全特性である

今回のバッチの修正のいくつかはタイムゾーンのバグでした。UTCの瞬間として保存されていたが本来はJSTの壁時計時刻を意図していた録画ウィンドウ、誤ったゾーンで計算されたプラン提供曜日、施設ではなくブラウザのロケールで描画された予約タイムスタンプなどです。

アクセス制御において、1日ずれや9時間ずれは見た目の問題ではありません。認可された窓の外での入室を許すか、正当なゲストを締め出すかのどちらかです。

私たちが落ち着いた規律はこうです。

// Store instants in UTC. Store the facility timezone alongside the entity.
interface AccessWindow {
  startUtc: string;   // ISO 8601 with Z
  endUtc: string;
  timezone: string;   // IANA, e.g. 'Asia/Tokyo'
}

// Convert only at the edges, never implicitly via the runtime default zone.
export function toFacilityWallClock(iso: string, timezone: string): string {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: timezone,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', hour12: false,
  }).format(new Date(iso));
}

// Day-of-week rules must be evaluated in facility time, not UTC or device time.
export function facilityDayOfWeek(iso: string, timezone: string): number {
  const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'short' })
    .formatToParts(new Date(iso));
  const weekday = parts.find((p) => p.type === 'weekday')!.value;
  return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekday);
}

ルール: ビジネスロジックの中の裸の Date は、起こるべくして起こるバグである。 アクセスを決定する比較は、必ずタイムゾーンを明示的に名指ししなければなりません。

同じ考え方がチェックインの変更にもつながりました。すべての予約から該当ゲストを検索するのではなく、検索対象を現在チェックイン可能な集合にスコープし、該当がない場合は availableAt タイムスタンプを返すようにしました。これによりUIは「見つかりません」ではなく「チェックインは15:00から開始します」と言えます。クエリを狭めることは、より良いエラーメッセージであると同時に、データ露出面の縮小でもあります。


10. エラー: 行動できる程度に具体的で、安全である程度に一般的に

いくつかの箇所で、生のシステムエラーがゲスト向けUIに漏れていました。日本語ユーザーに表示される英語のスタックトレース風文字列や、実際のバリデーション失敗をオペレーターから隠してしまう「Edge Function returned a non-2xx status code」という汎用メッセージなどです。

標準化したパターンは、ワイヤ上ではエラーコードの閉じたユニオンを流し、クライアントでローカライズされた文言にマッピングするというものです。

export type CheckinErrorCode =
  | 'CHECKIN_PERIOD_ENDED'
  | 'CHECKIN_OUTSIDE_HOURS'
  | 'CHECKIN_INFO_INSUFFICIENT'
  | 'RESERVATION_NOT_FOUND'
  | 'UNKNOWN';

interface ApiError {
  code: CheckinErrorCode;
  // Structured, non-sensitive context for the message template.
  meta?: { availableAt?: string; missingFields?: string[] };
}

const MESSAGES: Record<CheckinErrorCode, (m?: ApiError['meta']) => string> = {
  CHECKIN_PERIOD_ENDED:      () => t('checkin.periodEnded'),
  CHECKIN_OUTSIDE_HOURS:     (m) => t('checkin.outsideHours', { at: m?.availableAt }),
  CHECKIN_INFO_INSUFFICIENT: (m) => t('checkin.infoInsufficient', { fields: m?.missingFields }),
  RESERVATION_NOT_FOUND:     () => t('checkin.notFound'),
  UNKNOWN:                   () => t('common.unexpectedError'),
};

export function toUserMessage(err: ApiError): string {
  return (MESSAGES[err.code] ?? MESSAGES.UNKNOWN)(err.meta);
}

これが単なる磨き込みではなくセキュリティ特性である理由:

  • 網羅的な Record により、文言なしに新しいコードをリリースできません。コンパイラが拒否します。生の英語がすり抜けることはもうありません。
  • ワイヤ形式が運ぶのは散文ではなくコードなので、内部の詳細(テーブル名、SQL文、上流ベンダーのペイロード)がクライアントに到達しません。
  • 一部の条件はそもそもエラーではありません。「チェックイン受付期間は終了しました」は赤い失敗ではなく情報通知です。想定内の状態を誤ってエラーと表示することは、ユーザーに本物のエラーを無視する癖を教え込みます。
  • 一方、オペレーター側では、トランスポートレベルのメッセージではなくedge functionの構造化エラーボディをあえて表に出します。スタッフはどの時間枠が競合したのかを知る必要があるからです。

対象が違えば粒度も違う、しかし同じ構造化されたソースから。


11. 本当に失敗しうるテスト

今回のバッチのレビューコメントの一つは、枠付きで紹介する価値があります。あるテストは、URLビルダーが環境対応のベースURLを使っていることを、ソースファイルに特定の文字列が含まれるかで検証していました。コードコメントがあればそれを満たしてしまいます。永遠にグリーン、永遠に無意味です。

// False-green: passes because a comment mentions the symbol
expect(sourceCode).toContain('resolveAppBase');

// Behavioral: fails if the environment wiring regresses
describe('buildGoPortalShortUrl', () => {
  it.each([
    ['production', 'https://go.example.io/s/AB12CD'],
    ['staging',    'https://st-go.example.io/s/AB12CD'],
    ['local',      'http://localhost:3000/s/AB12CD'],
  ])('resolves base for %s', (env, expected) => {
    expect(buildGoPortalShortUrl({ env, code: 'AB12CD' })).toBe(expected);
  });
});

共有ビルダーにハードコードされた本番ホスト名は、現実的な危険です。ステージングのQAフローが本番のドアリンクを発行してしまえば、環境をまたぐアクセス漏洩です。テストは環境ごとに振る舞いを固定しなければなりません。

2つ目のテスト修正は分離の競合でした。RLSテストがフィクスチャの施設を共有していたため、あるテストの並列クリーンアップが別のテストの検証対象の行を削除し、チームが調査ではなく再実行を学習してしまうフレーキーな失敗を生んでいました。対処はこうです。

beforeEach(async () => {
  // Throwaway tenant per test: no shared mutable state, no cleanup ordering.
  ctx = await createThrowawayFacility({ prefix: `rls-${crypto.randomUUID()}` });
});

afterEach(async () => {
  await destroyFacilityCascade(ctx.facilityId);
});

it('denies cross-facility read of intents', async () => {
  const other = await createThrowawayFacility({ prefix: 'rls-other' });
  const client = await signInAs(ctx.memberUser);
  const { data, error } = await client.from('intents').select('*').eq('facility_id', other.facilityId);
  expect(error?.code).toBe('42501');
  expect(data).toBeNull();
  await destroyFacilityCascade(other.facilityId);
});

フレーキーなセキュリティテストは、テストがないより悪い。 なぜなら、赤を無視することをチームに教えるからです。RLSのアサーションを信頼できるものにするのは分離です。


12. シークレットは機械的にリポジトリの外へ

最後に、小さいながら効き目の大きいガードです。環境ファイルのpushを拒否するpre-pushフックを入れました。善意のローカル .env が本番設定を上書きしたり、署名シークレットを履歴に漏らしたりしかねないからです。

#!/usr/bin/env bash
set -euo pipefail
PATTERNS=('\.env$' '\.env\..*' 'supabase/\.env.*' '.*\.pem$' '.*service[-_]role.*')
files=$(git diff --cached --name-only --diff-filter=ACM)
fail=0
for f in $files; do
  for p in "${PATTERNS[@]}"; do
    if [[ "$f" =~ $p ]]; then
      echo "blocked: $f matches forbidden pattern /$p/" >&2
      fail=1
    fi
  done
done
if [[ $fail -ne 0 ]]; then
  echo 'Use the secret manager; never commit environment files.' >&2
  exit 1
fi

ハードニングの細部に注目してください。未設定変数でフックが静かに通ってしまわないよう set -euo pipefail を付け、.env.example の扱いが偶然ではなく意図的になるようパターンをアンカーし、どのルールが発火したか開発者に正確に伝わるようパターンごとのメッセージを出します。fail open するガードはガードではありません。


まとめのチェックリスト

物理的あるいは金銭的なアクセスをゲートするものを作っているなら:

  1. 終端状態への遷移は冪等に。 ガードと書き込みを1つの原子的な文にまとめ、「すでに完了済み」は成功として扱う。
  2. 資格情報は状態から導出する。 それを認可した状態とは独立にキーをキャッシュしない。
  3. 遷移表を書き下し、網羅性をコンパイラに強制させる。
  4. 不変条件はデータベースで強制する。 プロセス内チェックは競合に敗れるからです。
  5. truthy な残留オブジェクトで認証ステップを満たさせない。 フロー状態はスコープを切り、期限を設け、破棄する。
  6. 認証コンテキストを型レベルで必須にし、アイデンティティが静かに落ちないようにする。
  7. 特権的な読み取りをカプセル化する。 明示的なクレーム検査と固定 search_path を持つ definer 関数で。
  8. 外部配信はトークンでゲートする: 署名付き、スコープ限定、短命、失効可能、監査あり。
  9. サードパーティのコンテンツは取り込み時に一度だけサニタイズする。
  10. すべてのアクセス判断でタイムゾーンを名指しする。
  11. 散文ではなくエラーコードを配信し、末端で網羅的にローカライズする。
  12. ソーステキストではなく振る舞いを検証し、セキュリティテストは赤くなったときに信頼できるよう分離する。

どれも奇をてらったものではありません。これらが重要なのは累積的だからです。一つひとつが窓を閉じます。そしてアクセス制御において、窓こそが攻撃者、あるいは運の悪い競合が必要とするものそのものなのです。

主要な発見

1
状態管理

終端状態への遷移は衝突ではなく収束させる

チェックアウトはユーザー・スケジューラ・オペレーターから同時にトリガーされ得ます。状態ガードを書き込みと同じSQL文に入れ(タイムスタンプはcoalesceで保護)、操作を冪等にすることで、副作用の重複と競合に敗れた側への不要なエラーを解消できます。

2
セキュリティ

残ったフロー状態は残った認可である

古い決済状態オブジェクトによって、ゲストがメール+OTPを完了せずに確認画面へ到達できていました。truthyチェックは認証ではありません。検証状態には施設スコープ・試行nonce・TTLが必要で、フローがリセットされたら必ず破棄すべきです。

3
型安全性

網羅的な遷移表はコンパイラをポリシーチェッカーに変える

`satisfies Record<Status, readonly Status[]>` で許可されたエッジを宣言すれば、ガードを宣言せずにステータスを追加することは不可能になり、到達不能な状態やガードのない状態を本番ではなくビルド時に検出できます。

4
認可

オプショナルな認証コンテキストは潜在的な脆弱性

userIdなしで呼ばれたfeature-flagチェックが、platform-adminバイパスをデッドコードにしていました。ポリシーのサブジェクトを完全に埋まった必須構造体にすれば、すべての呼び出し箇所がアイデンティティを引き回すよう強制され、default-denyによりコンテキスト欠落はfail closedになります。

5
連携セキュリティ

外部へのキー配信はスコープ・有効期限・jtiでトークンゲートする

受信PMSフローはアプリログインなしで資格情報を届けます。定数時間で検証される署名付きトークンを、単一の読み取りスコープに限定し、短命かつjtiで失効可能にし、引き換えごとに監査すれば、予約IDをキーにしたURLの列挙を防げます。

6
テスト

フレーキーまたは偽グリーンのセキュリティテストは、無いより悪い

ソーステキストにシンボルが含まれるかの検証は、コメントだけで通ってしまいます。環境ごとの振る舞いベースのアサーションと、RLS検証のためのテスト単位の使い捨てテナントによって、赤い結果が「とりあえず再実行するもの」ではなく意味あるものになります。

7
信頼性

壁時計の正しさはアクセス制御の特性である

瞬間はUTCで保存し、施設のIANAタイムゾーンを併せて保持して末端で明示的に変換すれば、認可期間外の入室を許したり正当なゲストを締め出したりする時間ズレのウィンドウを防げます。