この章で分かること: ユーザー入力もAPIの返事もファイルも、外から来るデータをなぜ一切信用してはいけないのか。どこで・どうやって検証すれば事故が止まるのか。
聴く台本
セキュリティの世界には、たったひとつ覚えておけば半分守れる、っていう原則がある。それが「入力を信じない」だ。外から自分のシステムに入ってくるデータは、全部、敵が混ぜたものかもしれないと思って扱う。ユーザーがフォームに打ち込んだ文字、URLのうしろにくっついてくるパラメータ、外部のAPIから返ってきた答え、アップロードされたファイル、ぜんぶだ。「うちのユーザーはまともな人ばっかりだから」は通用しない。なぜなら、攻撃する側は、フォームを通さずに直接データを送りつけてくるからだ。
なんでそこまで疑うのか、理由から話そう。あなたのシステムは、受け取ったデータを使って何かをやる。データベースに問い合わせる、画面に表示する、ファイルを読みにいく、別のサービスを呼ぶ。このとき、もし入ってきたデータが「ただの文字」じゃなくて「命令」だったら、システムはそれを命令として実行してしまうことがある。たとえば名前を入れる欄に、データベースを操作する命令文を仕込む。表示される場所に、ブラウザで動くプログラムを仕込む。システムはそれを区別できない。だから、命令が混ざっていないかを、データが中に入る手前でチェックする必要がある。
その「手前」っていうのが大事で、専門用語で信頼境界って言う。トラストバウンダリー、つまり「ここから内側は信用していい、ここから外側は信用できない」っていう線のことだ。外の世界と自分のシステムのあいだに引かれた国境みたいなものだね。この国境で、入ってくるデータを全部いったん止めて、検査する。中に入れてから検査するんじゃ遅い。混ざりものは、国境で弾く。これが鉄則だ。
じゃあ国境で何を検査するのか。四つある。型、長さ、形式、範囲だ。順に言うと、まず型。数字が来るはずの欄に文字が来ていないか。次に長さ。名前の欄に十万文字みたいな、ありえない長さが来ていないか。長すぎる入力は、それだけでシステムを詰まらせる攻撃になる。次に形式。メールアドレスならアットマークを含む決まった形になっているか。最後に範囲。年齢なら、マイナスや三百歳みたいな、ありえない数になっていないか。この四つを通らないものは、中に入れない。
ここで、検査のやり方に決定的な分かれ道がある。許可リスト方式か、拒否リスト方式か。これは絶対に間違えちゃいけないところだ。拒否リストっていうのは、「これと、これと、これは危ないから弾く」っていう、悪いものを並べて禁止するやり方。一見よさそうに見える。でもこれは必ず漏れる。なぜか。世の中の「危ないもの」を全部、事前に数え上げることは不可能だからだ。あなたが知らない攻撃の形が、明日また新しく見つかる。拒否リストは、あなたが思いついた攻撃しか防げない。
だから、逆をやる。許可リスト方式、別名ホワイトリストだ。これは「これと、これだけは安全だと分かっているから通す。それ以外は全部弾く」というやり方。たとえば、郵便番号の欄なら「数字七桁、それ以外は受け付けない」と決める。すると、数字以外のものは何が来ようと、考える必要すらなく弾かれる。攻撃の新種が出てきても関係ない。「安全と確認できたものだけ通す」から、知らない攻撃にも勝てる。悪いものを数えるんじゃなく、良いものを定義する。これが許可リストの強さだ。迷ったら、必ず許可リストにする。
それから、検証と似て非なるものにサニタイズってのがある。違いをはっきりさせよう。検証、つまりバリデーションは、「おかしい入力を弾いて、受け取るか拒否するかを決める」こと。一方サニタイズは、「受け取ったデータを、安全な形に変換する」こと。たとえば、表示する文字に含まれる記号を、命令として解釈されない無害な記号に置き換える。検証が門番なら、サニタイズは消毒だ。どっちが先かというと、まず検証で弾けるものは弾く。これがフェイルファストっていう考え方で、おかしいものは奥まで通さず、入り口で早く失敗させる。早く弾けば、その後の処理に汚染が広がらないし、エラーの原因もすぐ分かる。そして弾ききれずに通したデータは、使う直前にサニタイズして無害化する。検証で減らして、サニタイズで仕上げる。両方いる。
さて、実際の開発で一番やりがちな事故を言う。フロントエンドで検証したから、サーバーでは省略する、これだ。画面側、つまりユーザーのブラウザの中で、入力チェックをやる。これは親切なことで、やったほうがいい。でも、それは「ユーザーに優しく即座にエラーを見せる」ためのものであって、セキュリティの守りには一切ならない。なぜか。ブラウザの中のチェックは、攻撃者が自由に消せるからだ。攻撃する側は、あなたが作った画面なんか使わない。ブラウザを通さず、サーバーに直接データを送りつける道具を使う。そうすると、フロントの検証は素通りされて、何のチェックもされていない生のデータがサーバーに届く。つまり、フロントの検証は「飛び越えられる前提」のものなんだ。本当の国境は、サーバー側にしかない。フロントでやったとしても、サーバーで必ずもう一度、同じ検証をやり直す。これを省いた瞬間、守りはゼロになる。
これが、他社の機密データをAIに預かる事業だと、どれだけ重いか考えてほしい。顧客から送られてくる質問文、アップロードされる業務ファイル、外部から取り込むデータ。そのどれかに命令が混ざっていて、それをそのままAIや裏側のデータベースに渡したら、別の顧客のデータを引き出されたり、システムを乗っ取られたりする。預かっているのが他人の機密なら、入力一個の検証漏れが、会社の信用を消す。だからこの事業では、外から来るデータは全部、敵が触ったものとして扱う。例外はない。
で、実装ではどうするか。まず、データを受け取るサーバー側の入り口、一個一個に、検証を必ず置く。型・長さ・形式・範囲を、許可リスト方式でチェックする。手で書くと漏れるから、入力の「あるべき形」をひとつのスキーマ、つまり型の定義としてまとめて宣言して、それに照らして機械的に弾く。おかしいものはその場で、はっきりしたエラーで早く落とす。これがフェイルファストだ。そして、フロントでどれだけ検証していても、サーバーの検証は絶対に省略しない。フロントは親切、サーバーは防御。役割が違う。この一線さえ守れば、入力経由の事故の大半は、入り口で止まる。
この章のまとめ(実装で守る一線)
外から来るデータは全部、敵が触ったものとして扱う。検証は中に入れる前、信頼境界=サーバー側で必ずやる。チェックは型・長さ・形式・範囲の四つを、拒否リストではなく許可リスト方式で。おかしいものは入り口で早く弾く(fail-fast)。そして「フロントで検証したからサーバーは省略」は守りがゼロになるので絶対にやらない。フロントは親切、サーバーは防御だ。