Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

UIでは編集を凍結していても、サーバー側が同じ条件を見ていなければ偽装POSTで書き換えられる

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

UIの入力欄は「稼働中」または「団体が買い切り済み」のどちらかで凍結表示していたのに、サーバー側の更新処理は稼働中かどうかしか見ておらず、買い切り直後で傘下の申込がまだ0件のタイミングだけ、フォームを直接POSTして配信先を書き換えられる状態になっていた。

結論

入力欄を凍結表示する条件と、更新処理がリクエストを拒否する条件は、同じ2つの状態(稼働中・買い切り済み)を見ているつもりでも、実装が別の場所に分かれていると簡単にずれます。 今回は「稼働中」の1条件しかサーバー側になく、「買い切り済みだが稼働はまだこれから」という1状態だけが検証を素通りしていました。

症状というより「気づきにくさ」

これは本番で実害が出た話ではありません。配信枠の編集フォームを追加したコミットの12分後、レビューでこの不一致が指摘されて同日中に直っています。それでも書く価値があるのは、両方の実装がそれぞれ単体で見ると正しく動いていたからです。

  • 編集画面を開くと、稼働中の枠と、団体が買い切り済みの枠は、配信先(スコープ・対象モニタ・対象校)の入力欄がちゃんと凍結表示される
  • 更新処理へフォームから普通に送信しても、稼働中の枠は「配信先は変更できません」と正しく拒否される
  • 動作確認をUI経由でどれだけ丁寧にやっても、この2つのテストケースだけでは異常は見えない

異常が見えるのは、団体が枠を買い切った直後で、傘下の申込がまだ1件も入っていないという3つ目の状態のときだけです。この状態では画面上の入力欄は正しく凍結されているのに、更新処理のエンドポイントへフォームを経由せず直接POSTすると、配信先を書き換えられました。

原因

UI側の凍結条件は、編集フォームを追加した最初のコミットから一貫してこうでした。

// 売れた枠は配信先を変えさせない(団体占有中も同じ=傘下へ配る前提が変わるため)。
const targetingLocked = isEdit && ((loop?.used ?? 0) > 0 || !!loop?.boughtOutById);

「稼働中の占有がある」か「団体の買い切りが付いている」かの、どちらか一方が真なら凍結です。

一方、サーバー側の更新処理(updateLoop)は、修正前は稼働中の占有件数だけを見ていました。

const occupied = await activeOccupantCount(admin, id);
// ...
if (occupied > 0 && targetingChanged)
  return { ok: false, error: "この枠は既に売れているため、配信先は変更できません。" };

activeOccupantCountplacements テーブルを availability in ("商談中", "申込済", "掲載中") で数える関数で、団体が枠を買い切った直後・傘下の申込がまだ0件の状態では0を返します。この状態は boughtOutByIdbought_out_by_company_id)には値が入っているのに、occupied は0という組み合わせになり、サーバー側の条件だけを満たしません。

UIのdisabled表示は、あくまでブラウザ側の見た目の制御です。フォームのボタンが押せない状態でも、更新処理のエンドポイントへ直接POSTするリクエスト自体は、サーバーが同じ条件を検証していない限り通ります。UIとサーバーで同じ2条件を別々に実装していて、両者が一致していることを機械的に保証する仕組みが無かったのが原因です。

直し方

サーバー側の判定を、UIと同じ2条件に揃えました。

// 団体(紹介店)の占有中も同じ扱い。まだ傘下の申込が1件も入っていなくても、団体は
// 「この配信先で押さえた」前提で動いているため、占有 placements の有無だけで判定しない。
const targetingFrozen =
  (await activeOccupantCount(admin, id)) > 0 || !!current.bought_out_by_company_id;

current(DBから読んだ現在値)に bought_out_by_company_id を追加で select し、稼働中の占有件数とのOR条件に変えています。この targetingFrozen は、配信先の変更を拒否する判定だけでなく、対象校の同期を許可するかどうかの判定(managesSchools && !targetingFrozen)にも使われていて、修正前はこちらも occupied === 0 という同じ1条件の判定だけで動いていました。凍結条件を1箇所(targetingFrozen)にまとめたことで、この2箇所が別々にずれる余地も同時に無くなっています。

気づけなかった理由(すぐには気づいたが)

編集画面の動作確認は、実際にはUIを経由してしか行われません。UIが正しく凍結表示している限り、「稼働中の枠の配信先を直そうとしたら止められた」という確認は毎回成功します。UIの制御が正しいことと、その制御をサーバーが独立に再現していることは別の確認が要る、というのはこのブログで何度も出てきている形の話ですが、今回は「表示条件」と「検証条件」という、見た目には同じことをしているはずの2つの実装が、それぞれ別ファイルで育っていったケースでした。

同じ「凍結条件をUIとサーバーの2箇所に書く」設計そのものを完全にやめない限り、この種のズレは形を変えて再発します。今回は条件を targetingFrozen という1つの変数にまとめてサーバー内の重複を消しましたが、UI側の targetingLocked とは依然として別の実装のままです。

よくある質問

Q1UIとサーバーで判定がずれていたのは、片方を直し忘れたからですか?

違います。UI側は最初からずっと2条件でした。原因はサーバー側の更新処理が、編集フォームを追加した時点から一貫して1条件しか実装していなかったことです。UIとサーバーは別の場所に同じ条件を別々に書いていて、両者が一致していることを保証する仕組みがありませんでした。

Q2偽装POSTとは、具体的にどういう操作ですか?

ブラウザの開発者ツールや curl などで、画面のフォームを経由せず、更新処理のエンドポイントへ直接リクエストを送ることです。フォームのボタンやチェックボックスがdisabledになっていても、それはブラウザ側の見た目の制御でしかなく、サーバー側が同じ条件を検証していなければリクエスト自体は通ります。

Q3この状態は実際の被害に繋がったのですか?

繋がっていません。編集フォームを追加したコミットの12分後に、レビューでこの不一致が指摘されて同日中に修正されています。ここで書いているのは「事故った話」ではなく、UIの表示制御とサーバーの検証が別々に書かれると、どちらも見た目には正しく動いているぶん、条件がずれていることに気づきにくいという話です。

Q4同じ種類のずれをテストで検出できますか?

UIの凍結条件とサーバーの凍結条件をそれぞれ独立にテストしても、両者が一致しているかどうかは検証できません。同じ入力(稼働中フラグ・買い切り状態の組み合わせ)をUIとサーバーの両方の判定関数に渡し、結果が一致することを確認するテストでなければ、このズレそのものは検出できません。

この記事の根拠

  • TypeScriptファイル 96〜135行目コミット d92b633
  • TypeScriptファイル 270〜400行目コミット d92b633

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。