Node.js Webアプリケーションをプロトタイプ汚染とリクエストスマグリング攻撃から防御する
Grace Collins
Solutions Engineer · Leapcell

はじめに
進化の速いWeb開発の世界において、Node.jsはスケーラブルで高性能なサーバーサイドアプリケーションを構築するための基盤として登場しました。その非同期・イベント駆動アーキテクチャと広範なライブラリのエコシステムは、それを人気の選択肢にしています。しかし、大きな力には大きな責任が伴い、Webアプリケーションの複雑さが増すにつれて、洗練されたセキュリティ脅威も出現します。これらの脅威の中でも、プロトタイプ汚染とリクエストスマグリングは、Node.js Webサービスの整合性と可用性を損なう可能性のある、特に悪質な脆弱性として際立っています。本稿では、これらの攻撃のメカニズムを掘り下げ、その潜在的な影響を例示し、それらからNode.jsアプリケーションを強化するための実行可能な戦略を提供します。これらの脅威を理解し、堅牢な防御策を実装することは、単なる良い習慣ではなく、相互接続された今日のデジタル世界において、機密データを保護し、ユーザーの信頼を維持するために不可欠です。
脅威の理解
防御戦略に入る前に、これらの攻撃に関連するコアコンセプトを明確に理解しましょう。
コア用語
- プロトタイプチェーン (Prototype Chain): JavaScriptでは、オブジェクトは他のオブジェクトからプロパティやメソッドを継承できます。この継承はプロトタイプチェーンを通じて実現されます。すべてのJavaScriptオブジェクトは、内部プロパティ
[[Prototype]](多くの環境では__proto__)を持ち、これはそのプロトタイプオブジェクトを指します。オブジェクトのプロパティにアクセスしようとしたときに、オブジェクト自体に見つからなかった場合、JavaScriptはそのオブジェクトのプロトタイプ、次にそのプロトタイプのプロトタイプ、というように検索し、Object.prototype(ほぼすべてのプロトタイプチェーンのルート)に到達するか、プロパティが見つかるまで続きます。 - プロトタイプ汚染 (Prototype Pollution): この脆弱性により、攻撃者はオブジェクトのプロトタイプ(通常は
Object.prototype)にプロパティを注入または変更できます。ほぼすべてのオブジェクトがObject.prototypeを継承しているため、これを汚染すると、フレームワークや他のライブラリによって間接的に作成されたものを含む、アプリケーション全体で任意のオブジェクトに影響を与える可能性があります。 - リクエストスマグリング (Request Smuggling): これは、攻撃者がHTTPリクエストの境界を異なるプロキシ、ロードバランサー、またはWebサーバーが解釈する方法の不一致を悪用するテクニックです。フロントエンドサーバーには1つのリクエストとして表示されるが、バックエンドサーバーには2つ以上のリクエストとして表示されるように細工されたリクエストを送信することで、攻撃者はセキュリティ管理をバイパスしたり、不正なリソースにアクセスしたり、キャッシュを汚染したりできます。
- HTTP/1.1
Content-Lengthヘッダー: メッセージボディのサイズをオクテット(8ビットバイト)で指定します。 - HTTP/1.1
Transfer-Encodingヘッダー: 安全な転送を保証するためにメッセージボディに適用されたエンコーディングを指定します。最も一般的な値はchunkedで、メッセージボディが複数のチャンクで構成されていることを意味します。
プロトタイプ汚染:メカニズムと影響
プロトタイプ汚染は、JavaScriptオブジェクトの動的な性質とプロトタイプチェーンを悪用します。この脆弱性は通常、JavaScript関数がオブジェクトを再帰的にマージしたり、入力キーを適切に検証せずにJSONデータを処理したりする際に発生し、攻撃者がユーザー制御データに __proto__ をキーとして挿入できるようになります。このようなデータが別のオブジェクトにマージされると、マージロジックが __proto__ がキーであるかどうかをチェックせずに直接プロパティを割り当てる場合、意図せず Object.prototype を変更する可能性があります。
ユーティリティ関数がデフォルト設定とユーザー提供設定をマージする一般的なシナリオを考えてみましょう。
// 単純化された脆弱なマージ関数 function merge(target, source) { for (const key in source) { if (key === '__proto__' || key === 'constructor') { // 基本的なチェックだが、しばしば見落とされるか不十分 continue; } if (target[key] instanceof Object && source[key] instanceof Object) { merge(target[key], source[key]); // 再帰的マージ } else { target[key] = source[key]; } } return target; } const defaultConfig = { env: 'production', db: { host: 'localhost' } }; // 攻撃者制御の入力 const maliciousInput = JSON.parse('{"__proto__": {"isAdmin": true}}'); // マージ関数がキーを適切にサニタイズしない場合、これが起こる: // merge(defaultConfig, maliciousInput)はこの単純な例ではObject.prototypeを直接汚染しない // しかし、マージ関数がtargetがObject.prototypeへのプロキシである場合に直接target[key] = source[key]を使用したり、 // または深くネストされたマージが関与する場合、問題となる。 // より直接的な汚染例を示す const vulnerableMerge = (target, source) => { for (const key in source) { if (key === '__proto__') { Object.assign(target, source); // デモのための直接割り当て、実際のシナリオはより複雑 } else if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { if (!target[key] || typeof target[key] !== 'object') { target[key] = {}; } vulnerableMerge(target[key], source[key]); // 再帰的呼び出し } else { target[key] = source[key]; } } }; // 汚染の例 const userControlledObject = {}; vulnerableMerge(userControlledObject, JSON.parse('{"__proto__": {"isAdmin": true}}')); // これで、新しく作成されたオブジェクトはすべてこのプロパティを継承する可能性がある const newUser = {}; console.log(newUser.isAdmin); // true (しまった!)
プロトタイプ汚染の影響は深刻であり、アプリケーションのクラッシュによるサービス拒否(DoS)、管理フラグの注入による権限昇格、特定のプロトタイププロパティ(例:テンプレートエンジンの設定)に依存するフレームワークの内部操作によるリモートコード実行(RCE)まで多岐にわたります。
プロトタイプ汚染に対する防御策
-
入力検証とサニタイズ: 最も効果的な防御策は、特にオブジェクトのマージやデシリアライゼーションが関わる場合、すべてのユーザー制御入力を注意深く検証およびサニタイズすることです。
__proto__とconstructorをキーとして防止します。function safeMerge(target, source) { for (const key in source) { // __proto__ と constructor を明示的に禁止 if (key === '__proto__' || key === 'constructor') { continue; } if (target[key] instanceof Object && source[key] instanceof Object) { // target[key] 自体がプレーンオブジェクトであることを確認し、組み込みプロトタイプを汚染しないようにする if (Object.getPrototypeOf(target[key]) === Object.prototype) { safeMerge(target[key], source[key]); } else { target[key] = source[key]; // またはエラーとして処理 } } else { target[key] = source[key]; } } return target; } -
データ専用オブジェクトには
Object.create(null)を使用する: 主にデータを格納し、Object.prototypeから継承する必要のないオブジェクトを作成する場合は、Object.create(null)を使用して作成します。これにより、プロトタイプを持たないオブジェクトが作成され、Object.prototypeの汚染から保護されます。const dataContainer = Object.create(null); -
Object.prototypeのフリーズ(本番環境では推奨されない): 理論的には可能ですが、Object.freeze(Object.prototype)を使用してObject.prototypeをフリーズすることは、極めて慎重にアプローチする必要があります。多くのライブラリやフレームワークは、Object.prototypeの変更や拡張に依存している可能性があり、それをフリーズすると予期しない動作や破損につながる可能性があります。 -
組み込み保護を備えたライブラリを使用する: セキュリティを念頭に置いて設計され、プロトタイプ汚染に対する既知の保護を備えたライブラリを活用します(例:
lodash.mergeはパッチ適用されていますが、常にバージョンを確認してください)。
リクエストスマグリング:メカニズムと影響
HTTPリクエストスマグリングは、攻撃者がHTTPメッセージの長さを決定する際の曖昧さを悪用する際に発生します。HTTP/1.1は、メッセージボディの長さを指定するために、Content-Length と Transfer-Encoding の2つの主要なヘッダーを提供します。フロントエンドサーバー(リバースプロキシやロードバランサーなど)とバックエンドサーバーがこれらのヘッダーを異なる方法で解釈する場合、攻撃者は最初のリクエストのボディ内に2番目の不正なリクエストを「密輸(smuggle)」できます。
一般的な攻撃ベクトルは以下の通りです。
- CL.TE (フロントエンドは
Content-Length、バックエンドはTransfer-Encoding): フロントエンドはContent-Lengthを使用し、バックエンドはTransfer-Encodingを使用します。 - TE.CL (フロントエンドは
Transfer-Encoding、バックエンドはContent-Length): フロントエンドはTransfer-Encodingを使用し、バックエンドはContent-Lengthを使用します。 - TE.TE (両方とも
Transfer-Encodingを使用するが、解釈が異なる): 両方ともTransfer-Encodingを使用しますが、それを異なる方法で解釈します(例:一方が不正なチャンクエンコーディングを処理し、もう一方は処理しない)。
概念的な CL.TE 攻撃を例示しましょう。
POST /search HTTP/1.1 Host: vulnerable.com Content-Length: 13 Transfer-Encoding: chunked 0 <-- サイズ0のチャンク、チャンク化されたボディの終了を示す <-- 空行、最初のチャンク部分を終了 GET /admin HTTP/1.1 Host: vulnerable.com Foo: bar
フロントエンド(Content-Length: 13 を解釈): リクエストボディ全体を `0
GET /admin...` として認識します。このブロック全体をバックエンドに転送します。
バックエンド(Transfer-Encoding: chunked を解釈): `0
部分を最初のチャンク化されたリクエストボディの終了として処理します。その後のGET /admin HTTP/1.1...` は、フロントエンドサーバーからの同じ接続からの、別個の新しいリクエストとして扱われます。
影響は深刻になる可能性があります。
- Web Application Firewall (WAF) のバイパス: 不正なリクエストが正当なリクエスト内に隠されます。
- 内部エンドポイントへのアクセス: 密輸されたリクエストは、信頼されたリバースプロキシからのものであるかのように表示され、内部APIや管理インターフェースへのアクセスを許可します。
- キャッシュポイズニング: 攻撃者が、プロキシに正当なURLに対して悪意のあるレスポンスをキャッシュさせるようなリクエストを密輸する可能性があります。
- セッションハイジャック: 密輸されたリクエスト内のCookieやセッショントークンを操作します。
リクエストスマグリングに対する防御策
Node.jsの http モジュールは、Node.jsサーバー自体のリクエストスマグリングの問題に対しては、一般的に堅牢であり、HTTP/1.1の解析ルールに厳密に従います。主な脆弱性は、Node.jsアプリケーションとアップストリームプロキシ/ロードバランサーとの相互作用にあります。
-
一貫したHTTP解析の保証: 最も重要な防御策は、アプリケーションスタック全体(ロードバランサー、プロキシ、Node.jsサーバー)で、一貫した厳密なHTTP/1.1解析パーサーを使用することを保証することです。
- 設定: フロントエンドプロキシ(Nginx、HAProxy、AWS ELB/ALBなど)を、HTTP/1.1仕様を厳密に強制するように設定します。具体的には、
Content-LengthとTransfer-Encodingヘッダーの両方を含むリクエストを拒否するか、それらを曖昧さなく処理する必要があります。 - 統一された標準: 理想的には、すべてのコンポーネントは
Content-Lengthのみに依存するか、Transfer-Encoding: chunkedを一貫して処理する必要があります。 - 両方の禁止:
Content-LengthとTransfer-Encodingの両方のヘッダーを含むリクエストを拒否するようにプロキシを設定します。これは一般的な攻撃ベクトルです。
- 設定: フロントエンドプロキシ(Nginx、HAProxy、AWS ELB/ALBなど)を、HTTP/1.1仕様を厳密に強制するように設定します。具体的には、
-
HTTP/2へのアップグレード: HTTP/2(およびHTTP/3)は、メッセージボディの決定における
Content-LengthおよびTransfer-Encodingの曖昧さがないため、リクエストスマグリングを困難、または不可能にするフレームベースのメッセージ構造を使用します。可能であれば、インフラストラクチャでHTTP/2をエンドツーエンドで使用するように設定します。クライアント-プロキシ接続のみがHTTP/2で、プロキシ-バックエンドがHTTP/1.1の場合、リスクは依然として存在する可能性があります。 -
曖昧さがある場合の接続クローズ: プロキシにとって堅牢な防御メカニズムは、HTTPヘッダーの曖昧さが検出された場合にクライアント接続を直ちに閉じることです。これにより、攻撃者が同じ接続で複数のリクエストを送信することを防ぎます。
-
定期的なセキュリティスキャンとテスト: 専門的なセキュリティスキャナーを使用し、ペネトレーションテストを実施して、インフラストラクチャにおける潜在的なリクエストスマグリングの脆弱性を特定します。Burp Suiteの「HTTP Request Smuggler」拡張機能のようなツールは非常に効果的です。
-
統一されたプロキシまたはマネージドサービスの利用: よくメンテナンスされ、実績のあるリバースプロキシまたはマネージドロードバランシングサービス(AWS ALBやGoogle Cloud Load Balancerなど)に依存することは、リスクを大幅に軽減できます。これらのサービスは、堅牢なHTTP解析とセキュリティを備えて設計されていることが多いためです。常に最新のセキュアなバージョンに更新されていることを確認してください。
結論
プロトタイプ汚染やリクエストスマグリングのような洗練された脅威からNode.js Webアプリケーションを保護するには、根本的なJavaScriptメカニズムとHTTPプロトコルの深い理解と、予防策の細心の注意を払った実装が必要です。ユーザー入力を綿密に検証し、堅牢なオブジェクトマージ戦略を設計し、アプリケーションスタック全体で一貫した厳密なHTTP解析を保証することによって、開発者はシステムを大幅に強化できます。プロアクティブなセキュリティプラクティスは、回復力があり信頼できるWebサービスを構築するために不可欠です。