ローカルサーバのセキュリティベストプラクティス
本番サーバーのセキュリティ対策には、多くの時間と労力が注がれます。しかし、その一方で開発者の手元で動いているローカルサーバーのセキュリティは、見過ごされがちでしょう。実際に、今年の頭に複数のフロントエンド開発ツールの脆弱性を調査・報告・修正しました。この記事ではその経験と調査を元に、具体的な攻撃手法とそれらを防ぐための対策を解説します。脆弱性の調査や対応の流れを知りたい方はぜひ一つ前の記事も読んでみてください。
前提知識
まず、後述する攻撃手法の理解に必要な前提知識を説明します。既に知っているものに関しては、読み飛ばして問題ありません。
URLとその各要素
この記事では、URLの各要素をURL Standardにならい、以下の通りに呼びます。
- 「スキーム」
- プロトコルの名前
-
new URL(inputUrl).protocol
で取得可能 - 例:
http:
、https:
- 「ホスト名」
- ドメイン文字列かIPアドレス
-
new URL(inputUrl).hostname
で取得可能 - 例:
www.example.com
、127.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: *
が設定されていました。
具体的な攻撃手順は以下の通りです。
- 攻撃者が悪意のあるサイト(
http://malicious.example.com
)を配信します。 - ユーザーがそのサイトで配信されたJavaScriptを実行するサイトにアクセスします。これはそのサイトに対するトップレベルナビゲーションに限らず、iframeでの埋め込みや外部サイトでのスクリプトの埋め込みも含みます。
- 配信されたJavaScript内で
fetch('http://127.0.0.1:3000/main.js')
が実行され、そのレスポンスが攻撃者に送信されます。 - 攻撃者は
http://127.0.0.1:3000/main.js
の内容を受け取ります。
開発サーバーの場合、main.js
は変換済みのファイルですが、多くの場合は、そのファイル内、あるいは main.js.map
にソースコードが存在し、その中に元のコードが含まれています。そのため、元のソースコードの取得が可能です。
関連
- Vite:
GHSA-vg6x-rcgg-rjx6
/CVE-2025-24010
([1]: Permissive default CORS settings)- parcel: 修正PR
- esbuild:
GHSA-67mh-4wv8-2f99
- Next.js:
GHSA-3h52-269p-cp9r
/CVE-2025-48068
(説明に含まれていないがソースマップをAccess-Control-Allow-Origin: *
で許可していた)- Nuxt:
GHSA-2452-6xj8-jh47
/CVE-2025-24360
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'
などの設定によりモジュール内にソースマップが埋め込まれている場合は、変換前のファイルの内容を取得できます。
具体的な攻撃手順は以下の通りです。
- 攻撃者が悪意のあるサイト(
http://malicious.example.com
)を配信します。 - ユーザーがそのサイトにアクセスします。これはそのサイトに対するトップレベルナビゲーションに限らず、iframeでの埋め込みも含みます。
- 配信されたJavaScript内で上記で示したようなスクリプトが実行され、その結果が攻撃者に送信されます。
- 攻撃者はモジュール一覧の内容を受け取ります。
関連
- webpack-dev-server:
GHSA-4v9v-hfq4-rm2v
/CVE-2025-30359
- Next.js:
GHSA-3h52-269p-cp9r
/CVE-2025-48068
(allowedDevOrigins
をつける必要のある一因)- Nuxt:
GHSA-4gf7-ff8x-hq99
/CVE-2025-24361
3. CSWSHの利用
クラシックスクリプトと同様に、WebSocketの接続はSOPの適用外です。そのため、デフォルトでは、他のサイトからの接続が可能です。これを利用した攻撃はクロスサイトWebSocketハイジャック(Cross-site WebSocket Hijacking、CSWSH)と呼ばれます。
多くのバンドラーではWebSocketでソースコードそのものを送信していないため、ほかの脆弱性と組み合わせないと有用な情報を取得することはできません。しかし、TurbopackではソースコードそのものをWebSocketで送信していたため、この脆弱性単体でソースコードの取得が可能でした。
具体的な攻撃手順は以下の通りです。
- 攻撃者が悪意のあるサイト(
http://malicious.example.com
)を配信します。 - ユーザーがそのサイトで配信されたJavaScriptを実行するサイトにアクセスします。これはそのサイトに対するトップレベルナビゲーションに限らず、iframeでの埋め込みや外部サイトでのスクリプトの埋め込みも含みます。
- 配信されたJavaScriptがWebSocketを接続します。
- ユーザーがファイルを編集し、バンドラーがソースコードをWebSocketで送信します。
- 配信されたJavaScriptがそのソースコードを攻撃者に送信します。
- 攻撃者はソースコードを受け取ります。
関連
- Vite:
GHSA-vg6x-rcgg-rjx6
/CVE-2025-24010
([2]: Lack of validation on the Origin header for WebSocket connections)- Vitest:
GHSA-9crc-q9x8-hgqq
/CVE-2025-24964
- parcel: 修正PR
- webpack-dev-server:
GHSA-9jgg-88mc-972h
/CVE-2025-30360
- Next.js:
GHSA-3h52-269p-cp9r
/CVE-2025-48068
(allowedDevOrigins
をつける必要のある一因)
4. DNS Rebindingの利用
SOPをバイパスする攻撃としてDNS Rebindingがあります。これはドメインの向き先を変えることで、同じ「オリジン」でも別のIPアドレスにリクエストを送信できることを利用します。この攻撃に対して脆弱な場合は、適切にCORSの設定がされていても、別のサイトからのリクエストの送信とレスポンスの受信が可能です。
具体的な攻撃手順は以下の通りです。
- 攻撃者が悪意のあるサイト(
http://malicious.example.com
)をHTTPで配信します。 - ユーザーがそのサイトにアクセスします。これはそのサイトに対するトップレベルナビゲーションに限らず、iframeでの埋め込みも含みます。
- 攻撃者がドメインの向き先を
127.0.0.1
に変更します(あるいはほかのプライベートIPアドレス)。 - サイトのJavaScriptが
fetch('http://malicious.example.com/main.js')
でリクエストを送信します。このリクエストは同じ「オリジン」ですが、サイトのアクセス時とは異なり、127.0.0.1
に解決されるので、http://127.0.0.1/main.js
と同様の内容を受け取ります。 - サイトのJavaScriptが
fetch
の結果を攻撃者に送信します。 - 攻撃者は
main.js
の内容を受け取ります。
なお、HTTPSのサイトではドメインの証明書の検証に失敗するため、この攻撃は成立しません。そのため、現代では、この攻撃は主にローカルサーバーでのみ可能です。
関連
- Vite:
GHSA-vg6x-rcgg-rjx6
/CVE-2025-24010
([3]: Lack of validation on the Host header for HTTP requests)- esbuild:
GHSA-67mh-4wv8-2f99
- parcel: 修正PR
ローカルサーバーでのベストプラクティス
これらの脆弱性は、ローカルで利用するサーバーは外部からのアクセスができないだろう、という思い込みから来ているものだと考えられます。しかし、実際にはブラウザは任意のサイトからのローカル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