一日で、数か月動いていた文字起こし API のセキュリティ上の欠陥を六つ塞ぎました。どれもテストが見つけたものではありません。テストは前も後も緑のままでした。変わったのは道具ではありません。自分のコードを、思ったとおりに動くかではなく、壊すとしたらどう壊すかを問いながら読んだことです。
これは態度の違いに見えます。実際は方法の違いで、出てくる欠陥の種類に表れます。
テストは自分が想像した道筋を確かめる
テストはユースケースから書きます。まともな入力、まともな出力。攻撃者はまともな入力を渡しません。空、ゼロ、負、巨大な値を渡します。そうした値がテストスイートにあることはめったにありません。テストを書く時点では思いつかず、思いついた時点ではもうバグを見てしまっているからです。
いちばん分かりやすかったのは空の ACL でした。検索の取得部分は、ユーザーがアクセスできる識別子のリストを受け取り、それでコーパスを絞り込みます。リストが空で来た場合、コードはそれを「このユーザーは何にもアクセスできない」ではなく「適用するフィルタがない」と解釈していました。コーパス全体を返していたわけです。
これは開いた側に倒れる失敗です。権限がないという状態の既定値が、すべてを許可することになっていました。ハッピーパスのテストでは絶対に触れません。ハッピーパスではリストに必ず何か入っているからです。
それ以来、認可のフィルタはすべて同じ問いで見直しています。コレクションが空のとき、これは何をするのか。 「ユーザーに権限がないときどうなるか」ではなく、空のときどうなるか。ふつう前者はきちんと覆われています。
カテゴリは思ったより繰り返す
見つかるものは、ほぼいつも同じ系統に集まります。
- 必要な場所で強制されない権限。 構造管理の権限を要求せずにデータを持ち出せる書き出しエンドポイント。あるドメインのカスケード削除が、影響を受ける各要素について物理削除の権限を確認するのではなく、最初に一度だけ権限を確認していた件。カスケードは典型的な場所です。操作は認可されるのに、内側のループが誰も認可していないことをやります。
- 存在しない上限。 管理画面の一覧は、届いた
limitをそのままクエリに渡していて、天井がありませんでした。スクリプトから大きな値を送れば、テーブル全体がワーカーのメモリに載ります。修正はMath.min(Math.max(limit, 1), 1000)の一行で済みますが、UI ではlimitが常に十だったので入っていませんでした。 - 一度だけ確認されて、その後は確認されない状態。
authenticateApiKeyはキーのハッシュ、失効、isActiveフラグを検証していました。しかしキーの行は持ち主のアカウントの状態より長生きします。解約時に課金レコードが消される作りなら、そのフラグを下ろす人はおらず、キーは認証し続けます。いまは、キーの所有者に課金レコードが残っていることを関数が再検証します。一か所で行うので、キー認証を使うすべてのエンドポイントがそれを継承し、個別に思い出す必要がありません。 - 描画される他人のコンテンツ。 いちばん驚いたのはこれです。
CSS もインジェクションです
markdown のサニタイザは、div、span、svg に対して style と class 属性を自由に許していました。処理は raw からサニタイズ、ハードニングへと流れますが、どの段階も CSS の中身を見ません。サニタイズのライブラリは style を不透明な文字列として扱い、ハードニングは a と img の URL しか書き換えません。スタイルは無傷のまま最終描画に届いていました。
プロパティのホワイトリストを持つ CSS パーサーを書こうかと考えました。書きませんでした。正当な画面で style を必要とするものがひとつもないことを確認し、許可リストからその二つの属性を外しました。自前の CSS フィルタは、存在しない事例を覆うために維持する新しい攻撃面です。
この発見で大事なのは修正ではなく、ベクトルの種類です。他のユーザーを攻撃するのに JavaScript は要りません。CSS だけでボタンを覆い、文字を別の文字に重ね、メッセージの見た目の意味を変えられます。そして自分以外のコンテンツを描画する画面はすべて、ユーザーをまたぐ攻撃の入口です。
ログの中の秘密と、長生きしすぎるリンク
ロジックのバグではなく、ライフサイクルの問題が二つ。
ひとつ目。API キー認証が壊れたキーや不正な形式のせいで失敗すると、catch がキーを生のままログのコンテキストに入れ、ロガーはそのコンテキストを JSON.stringify で何も伏せずに直列化していました。有効なキーを誤ったエンドポイントに送れば、平文でログに残ります。入れたマスキングは二方向から攻めます。フィールド名(/rawkey|token|authorization|password/i)と値の形(dk_live_ / dk_test_ の接頭辞)です。先頭八文字のあとに ***REDACTED*** を付けて残します。UI がキーを一度表示したあとに隠し直す形式と同じです。
ふたつ目。音声プレーヤー用の S3 署名付き URL は expiresIn が七日でした。<audio> が読み込まれるまで持てばいいリンクにとって、七日は永遠です。転送された、履歴に残った、企業プロキシにキャッシュされた、そうしたリンクを持つ誰もがその期間ずっと元の音声にアクセスでき、文字起こしを削除しても何も取り消されませんでした。有効期限を 600 秒に下げ、クライアント側で 480 秒のときに更新するようにし、文字起こし削除時にオブジェクトへ DeleteObjectCommand を投げるようにしました。それまではカスケードが行しか消していなかったからです。
ユーザー体験のコストは、ひとつの流れを除いてゼロでした。音声のリンクを同僚にメールで転送することです。その流れはもともとサポートされた機能でもなく、アクセス制御も一切ありませんでした。動かなくなったのは正しいと思います。
いまは覚えておく代わりに仕組みにしている
監査の問題は、その気になる日が来るかどうかに左右されることです。私の場合は、設計判断を書き残していて読み返したときに引っかかったのがきっかけでした。
見つかった項目はそれぞれ docs/security/ の短いファイルになります。ベクトルと、なぜその修正でなければならないのかを書きます。そこから、残りを見直すためのリストができます。プロジェクトの土台にしているボイラープレートで同じことをやったときは、セキュリティ、SEO、アーキテクチャあわせて三十九点出ました。その大半は、ひと握りの同じ問いの変形でした。空のリストのときどうなるか、内側のループで権限を強制するのは誰か、この上限の天井はいくつか、このリンクはどれだけ生きるのか。
動くことを確かめるためにコードを読むのと、どう壊すかを見るために読むのは、別々の読み方です。二つ目は、一つ目をしている最中に勝手には起きません。
