たった一行のコードが、アクセス制御モデル全体を台無しにすることがあります。リクエストボディをそのままデータベースの更新処理に流し込んでしまえば、クライアントにスキーマを書き換えるためのペンを渡してしまったも同然です。これが「マスアサインメント(Mass Assignment)」です。これは珍しいバグでも、例外的なケースでもありません。APIがペイロードをそのまま自身の更新ポリシーとして扱ってしまうときに発生する、設計上の欠陥です。

await db.users.update(req.params.id, { ...req.body });

見た目はスッキリしており、記述の手間も省けます。しかし、キーを制御しているのはクライアントです。攻撃者は、一見普通のプロフィール更新の中に、"role": "admin""accountId": "someone_else"、あるいは "credit": 99999" といった値を紛れ込ませることができます。バリデーション層が、それらの値が文字列か数値かを確認し、「問題なし」と判断するかもしれません。しかし、妥当性(Validity)は、認可(Authorization)ではありません。ユーザーが対象のレコードを正当に所有しているからといって、その中のすべてのフィールドを編集する権利があるとは限らないのです。

マスアサインメントの正体

危険性は「便利さ」の中に潜んでいます。フレームワークやORMを使えば、JSONのキーをデータベースの列に直接マッピングするのは極めて簡単です。これを行うとき、あなたはデータベースに対して、「どのように(how)」変更するかだけでなく、「何を(what)」変更すべきかについても、クライアントを信頼するように命じていることになります。

プロフィールを更新するユーザーは、displayNamebio に関する有効なデータを送信するついでに、rolebalance を紛れ込ませるかもしれません。コントローラーが単にオブジェクトを転送するだけなら、データベースはそれらすべてを書き込みます。バリデーションは不正な形式の値を検知することはできますが、悪意のあるキーを検知することは滅多にありません。「このユーザーはプロフィールの更新を許可されている」というビジネスルールが、行内のすべての列に対する無制限の権限へと変貌してしまうのです。

解決策は、バリデーションを増やすことではありません。より厳格なアーキテクチャを構築することです。

3つのゲート

安全なミューテーション(更新)は、ストレージに書き込まれる前に、3つの独立したチェックを通過する必要があります。

許可されたフィールド(許可リスト)

まず、どのキーを対象とするかを正確に決定することから始めます。許可リスト(Allowlist)にないフィールドは、リクエストを拒否するか、そのキーを破棄してください。これにより、デフォルトの姿勢が逆転します。新しいデータベースの列は、開発者が明示的に公開するまで「書き込み不可」となります。スキーマは時間の経過とともに拡張されます。チームメイトが stripeCustomerIddepartmentBudget、あるいは isVerified フラグを追加したとしましょう。許可リストがあれば、これらの新しい列はクライアントによる書き込みから自動的に保護されます。許可リストがなければ、新しい列が追加されるたびに、意図しないAPIの攻撃対象領域(Attack Surface)が増えていくことになります。

妥当な値

許可されたフィールドが判明したら、次にその値が妥当かどうかを確認します。タイムゾーンの文字列は、実際に認識可能なタイムゾーンですか? メールアドレスの形式は正しいですか? 数値は適切な範囲内ですか? これは「衛生管理」です。システムにゴミが入り込むのを防ぎますが、悪用を防ぐものではありません。たとえ "admin" という文字列が形式として完璧に正しくても、権限のない人物が role フィールドにそれを送ってきた場合、依然として危険です。

認可された変更

これは多くのチームが見落としがちなゲートであり、真の防御が機能する場所です。ここで、より粒度の細かい問いを投げかけます。「この特定の実行者は、この特定のレコードの、この特定のフィールドを変更する権限を持っているか?」。「ユーザーは管理者か?」でも、「ユーザーは write:users スコープを持っているか?」でもありません。「このユーザーは自身の displayName を変更することは許可されているが、accountId を変更することは決して許可されていない」といった問いです。フィールド単位の認可を行うことで、「エディター」や「ユーザー」といった広範な権限が、行内のあらゆるプロパティを操作できるマスターキーになってしまうのを防ぐことができます。

パッチ関数の構築

これら3つのゲートを単一のパイプラインに統合します。パッチリクエストが届いたら、以下のステージを順番に実行します。

  1. 許可リストによる入力のフィルタリング: 入力を許可リストと照らし合わせます。もし role がそのエンドポイントで許可されていないフィールドであれば、そこで処理を停止します。受け取るべきではない値をバリデーションしたり認可したりする理由はありません。

  2. 許可された値のバリデーション: 型、形式、およびビジネスルールを確認します。位置情報フィールドは、実在するタイムゾーンとして解決できる文字列でなければなりません。アバターのURLは、一定の長さ以下の有効なURIである必要があります。

  3. アクションの認可: 実行者が対象のレコードを所有しているか、あるいはそのフィールドに必要な正確な権限を持っているかを確認します。個人データについては「所有権」をデフォルトの認可基準とするのが良いですが、一部のフィールドには追加のゲートが必要です。ユーザーは自身のプロフィールを所有していても、taxRegion に触れることができるのは請求管理アドミン(Billing Admin)だけであるべきです。

  4. データの正規化: 空白のトリミング、連続するスペースの集約、メールアドレスの小文字化、制御文字の除去などを行います。これはバリデーションの後、かつストレージへの保存前に行います。これにより、認可チェックの際に汚れた文字列を比較してしまう事態を防げます。

入力がいずれかのゲート(検証)に失敗した場合、ミューテーション全体を拒否してください。安全なフィールドだけを部分的に適用し、不正なフィールドを黙って破棄してはいけません。中途半端なレスポンスを返すと、クライアントが「とりあえず考えつく限りのキーを片っ端から送り、何が通るか試してみる」という学習をしてしまいます。明示的に失敗させてください。

真に重要なエッジケース

Mass assignment(一括割り当て)への防御は、ユニットテストが見落としがちな細部に生死がかかっています。

JSONキーの重複。 攻撃者は {"role": "user", "role": "admin"} のようなペイロードを送信することがあります。使用しているHTTPパーサーやフレームワークによっては、アプリケーションコードがオブジェクトを確認する前に、2番目の値が最初の値を上書きしてしまう可能性があります。この挙動はパーサーレベルでテストしてください。もしフレームワークが最後のキーを黙って受け入れる場合、許可リスト(allowlist)は "user" を見ていても、データベースには "admin" が書き込まれてしまうことになります。

ネストされたオブジェクト、null、および配列。 ペイロードがフラットであると決めつけてはいけません。クライアントは、制限されたフィールドを { "profile": { "role": "admin" } } のようなネストされたオブジェクトの中に隠蔽して送ってくるかもしれません。スキーマが再帰的な構造を持つなら、許可リストも再帰的に処理する必要があります。同様に、null をどのように扱うかも決めておく必要があります。「このフィールドを無視する」のか、それとも「このフィールドを削除する」のか? また、配列が期待されている場合、バリデーターは予期しない構造を拒否しますか? それとも、単一のオブジェクトを配列にキャストして通してしまいますか?

Unicode正規化。 人間の目には同じに見えても、バイト列としては異なる2つの文字列が存在します。ユーザーが合成済み文字の é を送ることもあれば、分解された e と結合用アクセント記号を送ることもあります。認可チェックでの正規化とストレージ層での正規化が異なると、データに不整合が生じたり、最悪の場合、ユーザー名の衝突を利用してロジックをバイパスされたりする可能性があります。正規化は早い段階で行い、一貫性を保ってください。

レースコンディション(競合状態)。 認可の決定は静止画ではありません。それはある時点で行われるものです。2つのリクエストが同じレコードを読み込み、どちらも実行者に書き込み権限があると判断して、両方が更新を発行することがあります。その間に、状態や実行者の権限が変わってしまうかもしれません。データベースの更新を行う際は、常にバージョン番号やステートマシンの値に基づいた条件を付けてください。例えば UPDATE users SET ... WHERE id = ? AND version = 5 のような形式を使用します。読み取った後にその行が変更されていた場合、書き込みは失敗します。失敗した場合は、リトライするか拒否することで対処してください。これにより、古い認可チェックによるデータの破損を防ぐことができます。

重要なものを監視する

見えないものは守れません。監査ログは、単なる「アクション」ではなく、「決定」を中心に構築してください。

実行者ID(actor ID)と対象ID(target ID)を記録してください。許可された正確なフィールド名と、拒否されたフィールド名を記録してください。決定を下したポリシーのバージョンと最終的な結果も記録します。ユーザーのプロフィール更新で、突然 role フィールドが拒否されるようになった場合、すぐにそれを検知できるようにする必要があります。

ベアラートークンをログに記録してはいけません。リクエストボディ全体をログにダンプしてはいけません。監査トレイルは不正利用の調査を助けるためのものであり、認証情報や個人データの保管場所になってはなりません。

守るべき唯一のルール

リクエストボディはデータを「提案」するものであり、自らの権限を「定義」するものではありません。クライアントは何でも要求できます。サーバー側が、フィールドごと、行ごとに、永続ストレージへの書き込みを許可するかどうかを決定します。この分離を念頭に置いてパッチを構築すれば、Mass assignmentは認可レイヤーに到達するずっと前に解決されている問題となります。