Webのコードの多くは、リクエストが1つずつ届くかのように書かれています。残高を読み、十分かを確認し、差し引いて、保存する。手作業で試せば毎回うまくいきます。しかし20件を並列で送ると、同じお金を20回使えてしまうことがあります。
これらのバグは競合状態(CWE-362)であり、最もよくある形が使用時点と検査時点のずれ、すなわちTOCTOU(CWE-367)です。アプリケーションが条件を確認し、それに基づいて動作し、その間に条件が変わってしまうのです。1行ずつ見ればどれも正しく見えるため、レビューで見落とされがちです。バグは2行のあいだの隙間にあります。
Node.jsも例外ではない理由
シングルスレッドのランタイムなら競合状態は起きない、という思い込みがよくあります。JavaScriptは一度に1つのコールバックを実行しますが、awaitはどれも別のリクエストが動ける地点です。データベースは、すべてのインスタンス、すべてのワーカー、すべてのリクエストで共有されています。SELECTとUPDATEのあいだには、何でも起こりえます。
// 脆弱:2つのawaitをまたぐ「確認してから実行」
export async function withdraw(userId: string, amount: number) {
const account = await db.account.findUnique({ where: { userId } });
if (!account || account.balance < amount) {
throw new Error('Insufficient funds');
}
// この行が実行される前に、別のリクエストが同じ確認を通過しうる
await db.account.update({
where: { userId },
data: { balance: account.balance - amount },
});
}2つのリクエストが残高100を読み、どちらも100の引き出しの確認を通過し、どちらも0を書き込みます。ユーザーは100しかない口座から200を引き出したことになります。2つ目の書き込みが、古いデータから計算した値で1つ目を上書きするため、ログには異常が何も現れません。ORMでもこれは変わりません。レコードをオブジェクトに読み込み、オブジェクトを書き換えて保存するのは、同じ隙間を持つ同じ読み取り・変更・書き込みのパターンです。
競合が現れる場所
- 残高、クレジット、ウォレット:同じ資金の二重支払い。
- クーポンとギフトカード:1回限りのコードを複数回使用。
- プランと利用上限:プランが許す以上のプロジェクト、シート、API呼び出しの作成。
- サインアップと招待:同じメールアドレスで2つのアカウントを作る、1つの招待を2回受ける。
- 投票、いいね、評価:1人のユーザーを複数回数える。
- 決済と注文のフロー:Webhookとリダイレクトが同時に届き、注文を2回履行してしまう。
悪用は難しくありません。Promise.all、curl、プロキシツールでリクエストをまとめて送れば、数ミリ秒の窓に当てるには十分ですし、シングルパケット攻撃のような技法を使えばリクエストをほぼ同時にサーバーへ到達させられます。競合が存在するなら、誰かがそれに勝てると考えてください。
修正1:ルールをデータベースに守らせる
最も強力な修正は、ルールをデータベースへ移すことです。データベースなら同時リクエストが自動的に直列化されます。ほとんどのケースは2つの道具で足ります。一意制約と、原子的な条件付き更新です。
-- ユーザーごとに1回限り:タイミングに関係なく2回目のINSERTは失敗する
CREATE UNIQUE INDEX coupon_redemptions_once
ON coupon_redemptions (coupon_id, user_id);
-- 全体の利用上限:確認と加算を1つの文で行う
UPDATE coupons
SET uses = uses + 1
WHERE id = $1
AND uses < max_uses
RETURNING id;
-- 残高:条件は現在の行に対して評価される
UPDATE accounts
SET balance = balance - $1
WHERE user_id = $2
AND balance >= $1
RETURNING balance;
-- 念のため:残高が負になることは決してない
ALTER TABLE accounts
ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);条件付きUPDATEが有効なのは、WHERE句を評価しているあいだデータベースが行をロックするからです。2つのリクエストが競合すると、2つ目は1つ目がコミットした行に対して条件を再評価します。行が返らなければ条件が満たされなかったということなので、エラーを返します。確認と書き込みが同じ文なので、突く隙間がありません。
アプリケーションのコードでは、一意制約違反を通常の結果として扱ってください。PostgreSQLではエラーコード23505として届きます。500ではなく「クーポンは使用済みです」のような明確なメッセージに対応付けましょう。
ほとんどのORMは条件付き更新を表現できます。Prismaなら、where句に残高の条件を入れたupdateManyが件数を返し、件数がゼロなら確認が失敗したということです。findUniqueのあとに別のupdateを行う形では、確認をどれほど丁寧に書いても同じ保証は得られません。
修正2:SELECT ... FOR UPDATE で行をロックする
判断に複数の文が必要なこともあります。複数のフィールドを読み、価格計算の関数を呼び、2つのテーブルに書き込むような場合です。そのときはトランザクションの中で行ロックを取ります。SELECT ... FOR UPDATE は、同じ行をロックしようとする他のトランザクションを、自分がコミットまたはロールバックするまで待たせます。
トランザクションだけでは不十分です。PostgreSQLの既定の分離レベルはREAD COMMITTEDで、このレベルでは先ほどの脆弱なコードをBEGINとCOMMITで囲んでも何も変わりません。両方のトランザクションが同じ残高を読み、両方が確認を通過し、両方の書き込みが成功します。2つ目のトランザクションを待たせてコミット済みの値を読ませるのは、ロックの働きです。
import { Pool } from 'pg';
const pool = new Pool();
export async function purchase(userId: string, itemId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
[userId],
);
const item = await client.query(
'SELECT price FROM items WHERE id = $1',
[itemId],
);
if (rows.length === 0 || item.rows.length === 0) {
throw new Error('Not found');
}
const price = Number(item.rows[0].price);
if (Number(rows[0].balance) < price) throw new Error('Insufficient funds');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[price, userId],
);
await client.query(
'INSERT INTO purchases (user_id, item_id, price) VALUES ($1, $2, $3)',
[userId, itemId, price],
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}2つの点が重要です。ロックが役立つのは、残高を変更するすべてのコード経路がそれを取る場合だけです。ロックせずに更新する経路が1つでもあれば、競合は再び開きます。そして一連の処理全体が同じクライアントを使わなければなりません。プールされた接続の1つでBEGINを実行し、別の接続でSELECTを実行しては、トランザクションはまったく成立しません。ORMでも、対話的トランザクションや生クエリで同じパターンを使えます。
修正3:複数行にまたがるルールにはアドバイザリーロック
「無料プランのユーザーが持てるプロジェクトは最大3つまで」のように、まだ存在しない行についてのルールでは、行ロックは役に立ちません。2つのリクエストがそれぞれ2つのプロジェクトを数え、それぞれが3つ目を挿入できてしまいます。PostgreSQLのアドバイザリーロックを使えば、ユーザーIDのような任意のキーをトランザクションのあいだロックできます。
トランザクションの中で、ユーザーから導出したキーを使ってpg_advisory_xact_lockを呼び、それから数えて挿入します。この関数は64ビット整数のキーを取るので、たとえばhashtextでユーザーIDから安定した整数を導出してください。無関係なユーザー同士でたまに衝突しても、少し待たされるだけで、正しさが損なわれることはありません。ロックはコミットまたはロールバックで自動的に解放されます。もう1つの選択肢はSERIALIZABLE分離レベルで、PostgreSQLが競合するトランザクションを検出し、片方をエラー40001で中断します。中断されたトランザクションをコードが再試行する限り、これもうまく機能します。
うまくいかないのは、メモリ上のミューテックスやJavaScriptのMapによるロックです。守れるのは1つのプロセスだけです。インスタンスを2つ動かした瞬間、あるいはサーバーレス関数が2つ、バックグラウンドワーカーが1つ動いた瞬間に、そのロックは消えてなくなります。
修正4:再試行と二重送信には冪等キー
重複のすべてが攻撃とは限りません。ユーザーのダブルクリック、タイムアウト後のモバイルクライアントの再試行、決済プロバイダーによるWebhookの再配信などです。冪等キーは「これを実行せよ」を「これを一度だけ実行せよ」に変えます。クライアントは論理的な操作ごとにキーを生成し、サーバーは処理を始める前に一意制約付きでそのキーを記録します。
-- スキーマ
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
user_id uuid NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 先にキーを確保する。0行が返れば別のリクエストが保持している
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;INSERTが行を返したら、同じトランザクションの中で操作を実行し、レスポンスを保存します。何も返らなければ、保存済みのレスポンスを引いて返すか、最初のリクエストがまだ処理中なら409を返します。あるユーザーが他のユーザーのキーを再生したり塞いだりできないよう、キーはユーザー単位に限定し、適切な期間で期限切れにしてください。Webhookなら、プロバイダーのイベントIDが自然なキーになります。リクエストボディのハッシュをキーとともに保存し、異なるボディでの同じキーの再利用を拒否することも検討しましょう。クライアントの不具合で別の操作の結果を黙って受け取ってしまうのを防げます。
自分のコードから競合を見つける
- 同じデータの読み取りと書き込みのあいだにawaitがある箇所を探します。
- INSERTの前に件数を上限と比較している箇所を探します。
- 一意制約の裏付けなしに「探して、なければ作る」としている箇所を探します。
- 機微な値へのすべての書き込みが、同じロック済みまたは原子的な経路を通っているかを確認します。
- 同じリクエストを同時に発行し、不変条件が保たれることを検証するテストを書きます。
同時実行のテストが最も説得力のある証拠です。実際のデータベースに対してPromise.allで操作を20回実行し、残高、利用回数、行数を検証します。修正前に失敗し修正後に成功すれば、競合は塞がれています。1回ではなく複数回実行してください。5回に1回しか失敗しない競合も、やはり競合です。
競合状態は、1行だけではなくフロー全体を読むことでAIレビュアーが浮かび上がらせられる種類のバグです。CodeAuditAgentがこれを指摘するとき、その指摘事項にはCWE、該当する確認と書き込みの引用、並列リクエストを説明する攻撃シナリオ、そしてたいていは上記のいずれかの修正パッチが付きます。どちらにせよ一般的なルールは同じです。判断はデータベースに任せましょう。すべてのリクエストを見ている唯一のコンポーネントだからです。