以前は、自分自身で認証レイヤーを構築することは一種の誇りだと思っていました。JWTを理解していて、パスワードをハッシュ化できれば、それほど難しいことではないはずだと。Node.jsのバックエンドを構築し、トークンを発行し、それで完了だと思っていました。コードは動作し、テストもパスしました。しかし、実際の攻撃がどのように行われるかを読み始めると、足元が崩れ去るような感覚に陥りました。インジェクション、列挙、サイドチャネル漏洩に関する章を読むたびに、重苦しい気持ちでエディタに向き合うことになりました。私の認証システムには単に欠陥があっただけでなく、私自身が設置した4つの大きく開いたドアが存在していたのです。以下に、私が見つけた問題と、具体的にどのように修正したかを記します。

文字列結合によるSQLインジェクション

最初のバグは、最も古典的な手法でした。ユーザーの入力をそのままSQL文字列に流し込んでいたのです。ログインルートにおいて、リクエストボディからメールアドレスを取得し、SELECT * FROM users WHERE email = '${email}' のようなクエリに結合していました。フロントエンドを自分で制御していたため、無害だと感じていました。しかし、それは危険な思い込みです。攻撃者にフロントエンドは必要ありません。巧妙に細工された単一のPOSTボディによって、そのログインチェックをデータ漏洩やデータベースの全削除へと変貌させることができます。' OR '1'='1 のようなペイロードや、さらに悪質な、テーブルを削除するスタッククエリを送信され、文字列がそのまま実行されてしまえば、データは失われます。私は、自分のコードと攻撃者のデータを区別する方法をデータベースに与えていなかったのです。

修正策は、さらなる入力バリデーションや手動でのエスケープではありませんでした。真の解決策は、パラメータ化クエリ(parameterized queries)でした。私は node-postgres に切り替え、$1 のようなプレースホルダーを使用するようにしました。クエリはテンプレートになります:SELECT * FROM users WHERE email = $1。ドライバーはSQLと値を別々のチャネルで送信します。データベースは、入力に含まれる文字に関わらず、それを厳密に「データ」として扱います。この一つの変更だけで、インジェクション攻撃というクラス全体を封じ込めることができます。コードは読みやすくなり、メンテナンスも容易になり、WHERE句を書くたびに正規表現の達人にならなければならないという負担もなくなります。

エラーメッセージによるメールアドレスの列挙

2番目のミスは、一見すると優れたUXのように見えました。ユーザーが間違ったメールアドレスを入力したときは User not found を返し、メールアドレスは正しいがパスワードが間違っているときは Incorrect password を返していました。親切であるように感じられましたが、これは攻撃者にとっては偵察ツールとなっていました。列挙スクリプトを使えば、ログインエンドポイントに対して何千ものメールアドレスを叩きつけることができます。アカウントの有無によってレスポンスボディやステータスコードが変わる場合、スクリプトによってユーザーの検証済みリストを作成されてしまいます。そのリストは、クレデンシャルスタッフィング(credential stuffing)、標的型フィッシング、さらなるブルートフォース攻撃の基盤となります。

ユーザーの利便性が、時にはセキュリティのために譲歩しなければならないことを受け入れなければなりませんでした。私は、ログインに失敗するすべてのパスが、全く同じ文字列 Invalid credentials を返すように変更しました。ヒントは一切出しません。エラーレスポンスに分岐ロジックも持たせません。メールアドレスが見つからない場合でも、パスワードが間違っている場合でも、アカウントがロックされている場合でも、テキストは同一のままです。これは登録フローやパスワードリセットのフローにも当てはまります。そのアドレスがすでにシステム内に存在するかどうかを明かしてはいけません。汎用的なメッセージを一つ使うことで、攻撃者が利用する情報漏洩を排除できるのです。

パスワード比較におけるタイミング攻撃

3つ目のバグは、目に見えないものでした。私は...