🤿

ローカルサーバのセキュリティベストプラクティス

に公開

🌐 Read this post in English

本番サーバーのセキュリティ対策には、多くの時間と労力が注がれます。しかし、その一方で開発者の手元で動いているローカルサーバーのセキュリティは、見過ごされがちでしょう。実際に、今年の頭に複数のフロントエンド開発ツールの脆弱性を調査・報告・修正しました。この記事ではその経験と調査を元に、具体的な攻撃手法とそれらを防ぐための対策を解説します。脆弱性の調査や対応の流れを知りたい方はぜひ一つ前の記事も読んでみてください。

前提知識

まず、後述する攻撃手法の理解に必要な前提知識を説明します。既に知っているものに関しては、読み飛ばして問題ありません。

URLとその各要素

この記事では、URLの各要素をURL Standardにならい、以下の通りに呼びます。

URLの各要素とその名称

  • スキーム
    • プロトコルの名前
    • new URL(inputUrl).protocol で取得可能
    • 例: http:https:
  • ホスト名
    • ドメイン文字列かIPアドレス
    • new URL(inputUrl).hostname で取得可能
    • 例: www.example.com127.0.0.1, [::1]
  • ポート
    • ポート番号
    • 省略可能
    • new URL(inputUrl).port で取得可能
    • 例: 8080
  • ホスト
    • 「ホスト名」と「ポート」の組み合わせ
    • new URL(inputUrl).host で取得可能
    • 例: www.example.com:8080, 192.168.0.1
  • オリジン
    • 「スキーム」、「ホスト」、「ポート」の組み合わせ
    • new URL(inputUrl).origin で取得可能
    • 例: http://www.example.com:8080, https://192.168.0.1

DNS (Domain Name System)

ドメイン文字列をIPアドレスに変換するシステムです。

Host ヘッダー / :Authority 疑似ヘッダー

Host ヘッダーは、そのリクエストがどのサイトに対してアクセスしたいかを示します。HTTP 1.xで利用されます。HTTP 2以降では、:Authority 疑似ヘッダーを代わりに使用します。これらのヘッダーはURLの「ホスト」が指定されます。

DNSを利用してURLの「ホスト名」からIPアドレスを取得すれば、サーバーに対してリクエストの送信自体は可能です。これらのヘッダーは、同一のIPアドレスで複数のサイトがホストされている場合に、どのサイトにアクセスするかを指定するために存在します。

Origin ヘッダー

Origin ヘッダーは、そのリクエストがどのサイトから送信されたかを示します。このヘッダーはURLの「オリジン」が指定されます。このヘッダーはブラウザによって制御され、サイト内のJavaScriptで制御することはできません。

例えば、以下のようなHTMLが http://example1.sapphi.red で配信されていた場合、http://example2.sapphi.red/foo.js のリクエストの Origin ヘッダーは http://example1.sapphi.red です。http://example2.sapphi.red ではありません。

<html>
  <head>
    <script src="http://example2.sapphi.red/foo.js"></script>
  </head>
</html>

同一オリジンポリシー(SOP)

同一オリジンポリシー(Same Origin Policy、SOP)は、ある「オリジン」から読み込まれたドキュメントやスクリプトが、他の「オリジン」のリソースとどのように相互作用できるかを制限する、ブラウザのセキュリティ機構です。

例えば、http://example.com から http://example.sapphi.red への fetch リクエストは、「オリジン」が異なるため、デフォルトではブラウザによってブロックされます。

この機構により、意図しない他のサイトからのリクエストを遮断し、他のサイトによって勝手に情報を読み取られたり、POSTリクエストによって情報を書き込まれたりすることが防がれています。

攻撃手法

それでは、発見した各脆弱性に対する攻撃手法を説明します。いずれの攻撃も、外部のサイトからローカルサーバーへのリクエストを送信し、それがローカルサーバーで受け入れられ、有用なレスポンスを受け取ることを目的としています。
フロントエンドの開発サーバーでは、公開されないサーバー側のソースコードや公開前の機能がわかるようなレスポンスが「有用なレスポンス」にあたるでしょう。また、バッドプラクティスとはいえ、非公開のソースコードには、コメントなどのバンドル時に取り除かれる箇所に機密情報が含まれていることもあるでしょう。

攻撃の概要図

1. 寛容すぎるCORS設定

外部のサイトからのリクエストは、通常SOPによってブラウザが、リクエストそのもの、あるいはレスポンスの読み取りをブロックします。しかし、「オリジン」をまたいだリクエストを明示的に許可する Access-Control-Allow-Origin ヘッダーを設定した場合は、その限りではありません。このようにして異なる「オリジン」のリソースを利用することをオリジン間リソース共有(Cross Origin Resource Sharing、CORS)と呼びます。
多くの開発サーバーでは、すべての「オリジン」からのリクエストが許可される Access-Control-Allow-Origin: * が設定されていました。

具体的な攻撃手順は以下の通りです。

  1. 攻撃者が悪意のあるサイト(http://malicious.example.com)を配信します。
  2. ユーザーがそのサイトで配信されたJavaScriptを実行するサイトにアクセスします。これはそのサイトに対するトップレベルナビゲーションに限らず、iframeでの埋め込みや外部サイトでのスクリプトの埋め込みも含みます。
  3. 配信されたJavaScript内でfetch('http://127.0.0.1:3000/main.js')が実行され、そのレスポンスが攻撃者に送信されます。
  4. 攻撃者はhttp://127.0.0.1:3000/main.jsの内容を受け取ります。

開発サーバーの場合、main.js は変換済みのファイルですが、多くの場合は、そのファイル内、あるいは main.js.map にソースコードが存在し、その中に元のコードが含まれています。そのため、元のソースコードの取得が可能です。

関連

2. XSSIとプロトタイプの書き換えの利用

ブラウザでのスクリプトには二つの種類があります。一つは、モジュールスクリプトと呼ばれ、import 文や export 文が利用でき、<script> タグでの読み込みの際に type=module 属性をつけるものです。もう一つは、クラシックスクリプトと呼ばれ、古くから利用されているものです。

このうち、クラシックスクリプトの <script> タグでの読み込みに対しては歴史的な経緯からSOPが適用されません。これはすなわち、CORSの設定がされていなくとも、外部のサイトからそのスクリプトを読み込むことができるということです。これを利用した攻撃はクロスサイトスクリプトインクルージョン(XSSI、Cross-Site Script Inclusion)と呼ばれます。なお、前述の通り、fetch などによるスクリプトの内容そのものの取得はSOPによりデフォルトでブロックされます。

これは、クラシックスクリプトを利用し、モジュール一覧が取得できる形のバンドルを出力するバンドラーで問題になります。
例えば、Webpackでは、output.iife: false を指定したりすると、window.webpackChunkSomething のようなグローバル変数にモジュール一覧が露出されます。そのため、以下のようなコードですべてのモジュールの名前と変換後コードが取得できました。

const script = document.createElement('script')
script.src = 'http://localhost:3000/path/to/the/entrypoint.js'
script.addEventListener('load', () => {
  for (const page in window.webpackChunkSomething) {
    const moduleList = window.webpackChunkSomething[page][1]
    for (const key in moduleList) {
      console.log('moduleName', key)
      console.log('moduleCode', moduleList[key].toString()) // Function::toString
    }
  }
})
document.head.appendChild(script)

そのような設定を行っていなくても内部的にモジュール一覧を保持している場合は、プロトタイプの書き換えなどにより取得できることがあります。
例えば、Webpackのデフォルト設定では Array.prototype.forEach を上書きすることで、モジュール一覧が取得できました。Webpackの出力には以下のような関数が含まれています。

function __webpack_require__(moduleId) {
  var cachedModule = __webpack_module_cache__[moduleId]
  if (cachedModule !== undefined) {
    return cachedModule.exports
  }
  var module = (__webpack_module_cache__[moduleId] = { exports: {} })
  var execOptions = {
    id: moduleId,
    module: module,
    factory: __webpack_modules__[moduleId],
    require: __webpack_require__,
  }
  // ここの呼び出しが重要です!
  __webpack_require__.i.forEach(function (handler) {
    handler(execOptions)
  })
  module = execOptions.module
  execOptions.factory.call(
    module.exports,
    module,
    module.exports,
    execOptions.require,
  )
  return module.exports
}

この __webpack_require__.i.forEach のコールバックをラップすることで execOptions を取得し、execOptions.require.m からモジュール一覧を取得できます。具体的には以下のようなコードで取得ができます。

let moduleList
const onHandlerSet = (handler) => {
  moduleList = handler.require.m
}

const originalArrayForEach = Array.prototype.forEach
Array.prototype.forEach = function forEach(callback, thisArg) {
  callback((handler) => {
    onHandlerSet(handler)
  })
  originalArrayForEach.call(this, callback, thisArg)
  Array.prototype.forEach = originalArrayForEach
}

// この後、前述のスクリプト挿入コードを実行する

また、devtool: 'eval' などの設定によりモジュール内にソースマップが埋め込まれている場合は、変換前のファイルの内容を取得できます。

具体的な攻撃手順は以下の通りです。

  1. 攻撃者が悪意のあるサイト(http://malicious.example.com)を配信します。
  2. ユーザーがそのサイトにアクセスします。これはそのサイトに対するトップレベルナビゲーションに限らず、iframeでの埋め込みも含みます。
  3. 配信されたJavaScript内で上記で示したようなスクリプトが実行され、その結果が攻撃者に送信されます。
  4. 攻撃者はモジュール一覧の内容を受け取ります。

関連

3. CSWSHの利用

クラシックスクリプトと同様に、WebSocketの接続はSOPの適用外です。そのため、デフォルトでは、他のサイトからの接続が可能です。これを利用した攻撃はクロスサイトWebSocketハイジャック(Cross-site WebSocket Hijacking、CSWSH)と呼ばれます。

多くのバンドラーではWebSocketでソースコードそのものを送信していないため、ほかの脆弱性と組み合わせないと有用な情報を取得することはできません。しかし、TurbopackではソースコードそのものをWebSocketで送信していたため、この脆弱性単体でソースコードの取得が可能でした。

具体的な攻撃手順は以下の通りです。

  1. 攻撃者が悪意のあるサイト(http://malicious.example.com)を配信します。
  2. ユーザーがそのサイトで配信されたJavaScriptを実行するサイトにアクセスします。これはそのサイトに対するトップレベルナビゲーションに限らず、iframeでの埋め込みや外部サイトでのスクリプトの埋め込みも含みます。
  3. 配信されたJavaScriptがWebSocketを接続します。
  4. ユーザーがファイルを編集し、バンドラーがソースコードをWebSocketで送信します。
  5. 配信されたJavaScriptがそのソースコードを攻撃者に送信します。
  6. 攻撃者はソースコードを受け取ります。

関連

4. DNS Rebindingの利用

SOPをバイパスする攻撃としてDNS Rebindingがあります。これはドメインの向き先を変えることで、同じ「オリジン」でも別のIPアドレスにリクエストを送信できることを利用します。この攻撃に対して脆弱な場合は、適切にCORSの設定がされていても、別のサイトからのリクエストの送信とレスポンスの受信が可能です。

具体的な攻撃手順は以下の通りです。

  1. 攻撃者が悪意のあるサイト(http://malicious.example.com)をHTTPで配信します。
  2. ユーザーがそのサイトにアクセスします。これはそのサイトに対するトップレベルナビゲーションに限らず、iframeでの埋め込みも含みます。
  3. 攻撃者がドメインの向き先を 127.0.0.1 に変更します(あるいはほかのプライベートIPアドレス)。
  4. サイトのJavaScriptが fetch('http://malicious.example.com/main.js') でリクエストを送信します。このリクエストは同じ「オリジン」ですが、サイトのアクセス時とは異なり、127.0.0.1 に解決されるので、http://127.0.0.1/main.js と同様の内容を受け取ります。
  5. サイトのJavaScriptが fetch の結果を攻撃者に送信します。
  6. 攻撃者は main.js の内容を受け取ります。

なお、HTTPSのサイトではドメインの証明書の検証に失敗するため、この攻撃は成立しません。そのため、現代では、この攻撃は主にローカルサーバーでのみ可能です。

関連

ローカルサーバーでのベストプラクティス

これらの脆弱性は、ローカルで利用するサーバーは外部からのアクセスができないだろう、という思い込みから来ているものだと考えられます。しかし、実際にはブラウザは任意のサイトからのローカルIP・プライベートIPへのリクエストをブロックしません。ブラウザ側での対策としてPrivate Network Accessという仕様の策定が進められていますが、互換性の問題でブラウザへの実装にはまだまだ時間がかかりそうで、現状は各サーバーで対策を講じる必要があります。

なお、この対策はブラウザでのアクセスを想定していないサーバーでも重要です。Private Network Accessのモチベーションの説明には、ウイルス対策ソフトがブラウザによるアクセスを拒否していなかったことで任意のコマンドが実行できた脆弱性が挙げられており、この観点が抜け落ちやすいことを示唆しています。

リクエストがどのサイトから送信されたかのチェックを適切に行う

上記の攻撃手法の1~3はいずれもリクエストの送信元のチェックが十分でないことを利用しています。

「1. 寛容すぎるCORS設定」に対しては、まず、許可した「オリジン」はレスポンスを読み取れることを認識することが重要でしょう。安易に Access-Control-Allow-Origin ヘッダーを設定せず、信頼できる「オリジン」のみ適切に設定するべきです。

「2. XSSIとプロトタイプの書き換えの利用」と「3. CSWSHの利用」に関しては、SOPの例外を意識し、適切なチェックを行うことが重要でしょう。

XSSIに対しては Cross-Origin-Resource-Policyヘッダー を利用できます。このヘッダーを指定すると、ほかの「オリジン」からのリクエストのレスポンスの読み取りを制限できます。ただし、リクエストそのものがサーバーに到達することには注意が必要です。書き込みなどの副作用のある操作が含まれる場合は、Sec-Fetch-Siteヘッダーcross-site でないことのチェックをし、副作用の前にレスポンスをはじくことが必要です。

CSWSHに対しては、Originヘッダーの検証で防げます。ここで注意すべきなのは、すべてのIPアドレスを一律で許可してはいけないという点です。「DNS Rebinding」に対しては後述する理由で、すべてのIPアドレスを許可しても問題ないのですが、CSWSHに関しては、IPアドレスのサイトを提供できることから、すべてのIPアドレスを許可してしまうと、そのようなサイトからの攻撃を防げなくなります(GHSA-9jgg-88mc-972h / CVE-2025-30360)。

リクエストがどのサイトに対して送信されたかのチェックを適切に行う

「4. DNS Rebindingの利用」はリクエストの送信先のチェックが十分でないことを利用しています。HTTPSを利用することが理想です。しかし、開発サーバーではユーザーの負担となるため、採用が難しいでしょう。その際は、Host ヘッダーが自分のサイトのものかの検証を行うべきです。なお、IPアドレスを値とする Host ヘッダーは常に許可しても構いません。これは、IPアドレスのホストに対してはDNSが引かれず、DNS Rebindingが成立しないためです。Node.jsのConnect互換のミドルウェアが利用可能であれば、Viteから Host ヘッダーの検証機能を抽出したライブラリの host-validation-middleware が利用できます。

ループバックインタフェースのみへバインドする

Node.jsの server.listen では host パラメータの指定がない際は、この端末を指すすべてのIPアドレスへのリクエストを受け取ります。したがって、ファイアウォールで拒否されていない限り、同じネットワーク内からのアクセスが可能です。例えば、同じWifiにつないだスマホからPCで起動したサーバーに対して、そのPCのWifiでのIPアドレス(例: 192.168.0.5)を指定することでサーバーにアクセスできます。しかし、これは裏を返せば、そのWifi内での任意のデバイスがサーバーにアクセスできるということでもあります。必要がないのであれば、127.0.0.1::1 といったループバックインターフェースを host パラメータに指定して、同じ端末以外からのアクセスを受け取らないようにしておくとよいでしょう。Viteはv2.3からデフォルトがそのようになっているので、Viteでは設定が不要です。

結び

この記事では、フロントエンド開発ツールを中心にして説明しましたが、似たような脆弱性がMCPサーバーでも発見されています(MCP: May Cause Pwnage - Backdoors in Disguise)。
これらの脆弱性は見落とされがちだと思われるので、ローカルでサーバーを起動するようなツールを利用する際は、これらの攻撃経路が適切に塞がれていることを確認するとよいでしょう。

Discussion