性能とセキュリティ FAQ:遅い、カクつく、信頼できないデータ
要点:数十 MB の JSON ファイルが開かない理由、深いクローンのコスト、__proto__ 汚染の防ぎ方、XSS の侵入経路。症状別に並べた十の応急処置の結論です。

要点:まず症状で診断しましょう。パース、描画、メモリのどれかです。それから修正順に進みます。データ量を削り、深いクローンをやめ、パースを Worker に移して仮想スクロールを使い、50 MB を超えるものはストリーミングします。信頼できない入力には、__proto__、constructor、prototype をフィルタする前にサイズとネストの深さに上限を設けます。
「JSON は遅い」は十回中九回不正確です。実際に遅いのは通常、描画、深いクローン、または誤った操作順序です。
1. なぜ遅いか
大きな JSON ファイルのパースが遅いです。どう最適化しますか?
まずボトルネックを特定し、最適化はその後です。段階によって桁が数個違います。
| 段階 | 10 MB データのおおよその時間 | なぜボトルネックか |
|---|---|---|
| ネットワーク転送 | 帯域に依存 | 非圧縮 JSON は通常約 1/5 に圧縮される |
JSON.parse |
20〜80 ms | ネイティブ実装で、すでに速い |
| 再帰走査 / 深いクローン | 200〜2000 ms | 純 JS のノード単位処理 |
| DOM 描画(ツリービュー) | 秒単位、容易に固まる | ノード数が爆発する |
ですから最適化の順序はこうです。
- データ量を減らす:API をページネーションにし、必要なフィールドだけを返し、gzip/brotli を有効にする。
- 深いクローンを避ける:次の節を参照。最も一般的な隠れたコストです。
- 遅延描画:ツリービューでは展開された階層だけを描画し、数万の DOM ノードを一度に生成しない。
- ストリーミング:データが 10 MB を超えるとき、すべてを一度にパースしない。
ボトルネックが本当に JSON.parse なら、データはすでに数十 MB 以上で、正しい手はストリーミングパーサーです。
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
// 配列要素を一つずつ処理する。メモリ使用量は総サイズに依存しない
const pipeline = fs.createReadStream('big.json')
.pipe(parser())
.pipe(streamArray());
pipeline.on('data', ({ value }) => {
processItem(value);
});
数十 MB の JSON が開いた瞬間にブラウザをクラッシュさせます。どうすれば?
まず「クラッシュ」か「フリーズ」かを確認します。二つはまったく異なる扱いが必要です。
- フリーズ:タブが「ページが応答していません」を示し、数十秒後に回復することがある。原因はメインスレッドを飽和させる長いタスク。
- クラッシュ:タブが「Aw, Snap」になり、通常はメモリ枯渇(OOM)。
どちらでもメインスレッドで処理しないでください。パースを Web Worker に移します。
// worker.js
self.onmessage = (event) => {
const parsed = JSON.parse(event.data);
self.postMessage(parsed);
};
Worker が解決するのは「UI が固まらない」ことで、メモリの天井ではありません。ブラウザタブは通常 1〜4 GB を使えます。 200 MB の JSON をオブジェクトにパースすると 1 GB 以上を占めることがあり(すべてのキーと値のペアがオブジェクトのオーバーヘッドを運びます)、 描画が加われば容易に限界に達します。
本当の解決策は決して全体を読み込まないことです。
| データサイズ | 方法 |
|---|---|
| < 5 MB | JSON.parse で直接パースし、描画に注意 |
| 5〜50 MB | Worker パース + 仮想スクロール |
| > 50 MB | ストリーミングパース。必要なフィールドだけを保つ |
| > 500 MB | 形式を変える(Parquet / SQLite / 分割 JSON Lines) |
JSON.parse(JSON.stringify(obj)) は深いクローンですか?
深いクローンですが、いくつかの箇所で「静かにデータを変える」ので、汎用のクローン道具としては適しません。
const source = {
date: new Date(),
fn: () => {},
undef: undefined,
nan: NaN,
map: new Map(),
nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
// date: "2026-10-30T03:00:00.000Z", // Date が文字列になる
// nan: null, // NaN が null になる
// nested: { a: 1 } // この階層だけが正しい
// }
// fn と undef は消え、map は {} になる
問題の一覧:
| 型 | 結果 |
|---|---|
Date |
ISO 文字列になる |
NaN / Infinity |
null になる |
undefined / 関数 / Symbol |
オブジェクト内では消え、配列内では null になる |
Map / Set |
{} になる |
BigInt |
TypeError を投げる |
| 循環参照 | TypeError を投げる |
現代のブラウザには structuredClone があり、Date、Map、Set、循環参照に対応しますが、関数には対応しません。
const copy = structuredClone(source);
浅い設定マージだけなら、スプレッド演算子が最も安上がりです。{ ...config, timeout: 3000 }。
ツリービューの展開がひどくカクつくのはなぜですか?
すべてのノードが DOM 要素で、ノード数が数万に達するとブラウザのレイアウトコストが爆発するからです。
10 MB の JSON は 5 万〜10 万の葉ノードを含みえます。可視領域だけを描画しても、DOM の一度の構築に数百ミリ秒から数秒かかり、 その後のスクロールのたびにリフローが起きます。
正しい方法:
- 仮想スクロール:ビューポート内の数十行だけを描画し、スクロール中に DOM ノードを再利用する。
- 既定で折りたたむ:最初の二階層だけを展開し、深い階層はオンデマンドで描画する。
- 描画規模を制限する:1 万ノードを超えたら、無理に描画するのではなく「データが大きすぎます。検索で特定してください」と表示する。
このサイトのエディタが大きなファイルで優雅に劣化するのも同じ理由です。技術的に描画できないのではなく、描画すれば使えなくなるからです。
2. なぜ攻撃されるか
__proto__ 注入はどう成立しますか?
プロトタイプ汚染です。攻撃者が __proto__ キーを持つ JSON を構築し、あなたのコードがフィルタなしでそれをオブジェクトにマージすると、
Object.prototype を上書きし、ランタイムのすべてのオブジェクトに影響します。
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// 危険なコード:キー名を確認せずに再帰マージする
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, payload);
({}).isAdmin; // true — すべてのオブジェクトが汚染された
重要な事実:JSON.parse 自体はプロトタイプを汚染しません。{"__proto__": ...} は普通の own プロパティにパースされます。
危険なのはパース後のマージ、代入、Object.assign、lodash.merge などの操作です。
三つの防御:
- キー名をフィルタする:マージ時に
__proto__、constructor、prototypeをスキップする。 Object.create(null)をコンテナに使う。汚染できるプロトタイプチェーンがない。Mapを使う。信頼できないキーと値のペアを保存する。
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const value = source[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = safeMerge(target[key] ?? {}, value);
} else {
target[key] = value;
}
}
return target;
}
for...in は継承プロパティを歩くことにも注意してください。Object.keys() の方が安全です。
信頼できない JSON をパースするとき何に注意すべきですか?
この順で行います。低コストで高い見返りです。
- サイズに上限を設ける:しきい値を超えるものは、先にメモリに読まず即座に拒否する。
- 深さに上限を設ける:数百階層の JSON は再帰パーサーのスタックを溢れさせうる。
- キー数に上限を設ける:ハッシュ衝突攻撃とメモリ増幅を防ぐ。
- 危険なキーをフィルタする:
__proto__、constructor、prototype。 - 構造を検証する:Schema を通して型と必須フィールドを確認する。
Content-Typeを信頼しない:宣言された型と実際の中身が食い違うことがある。
深さの検査は安価です。パース前に粗い正規表現フィルタを使うか、パース後に走査します。
function measureDepth(value, depth = 0) {
if (value === null || typeof value !== 'object') {
return depth;
}
let maxDepth = depth;
for (const key of Object.keys(value)) {
maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
}
return maxDepth;
}
しかしこのコード自体が再帰的で、悪意ある深いデータでは依然スタックを溢れさせます。安全な方法はパース中に制限を強制することで、
深さオプションに対応するパーサーを使います(一部のライブラリは maxDepth パラメータを提供します)。
JSON 経由の XSS はどう起きますか?
JSON 自体は HTML ではなく、実行されません。XSS は「JSON の値が HTML に連結される」時点で起きます。
よくある三つの経路:
| 経路 | 例 | 防御 |
|---|---|---|
| サーバー側テンプレート連結 | res.send('<div>' + data.name + '</div>') |
出力時に HTML エスケープ |
フロントエンドの innerHTML |
el.innerHTML = data.html |
textContent かサニタイズを使う |
| インラインスクリプト埋め込み | <script>var d = ${JSON.stringify(data)}</script> |
<、>、& をエスケープ |
三番目が最も微妙です。JSON.stringify は < をエスケープしないので、</script><script>alert(1)</script> を含むデータが
スクリプトタグを早く閉じられます。
const data = { name: '</script><script>alert(1)</script>' };
// 出力: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>
修正は危険な文字を Unicode エスケープに変えることです。
function safeJsonForHtml(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
ネストの深さを制限すべきですか?
はい。費用対効果が最も良い防御の一つです。
制限しない場合の結果:再帰関数がスタックを溢れ、深すぎると JSON.parse が一部の実装でクラッシュし、
シリアライズも同じようにスタックを吹き飛ばします。1 KB の [[[[...]]]] で無防備なパーサーを倒すのに十分です。
妥当なしきい値は業務に依存します。
| データ種別 | 推奨制限 |
|---|---|
| 通常の業務オブジェクト | 10〜20 階層 |
| ツリー構造(組織図、ディレクトリ) | 50〜100 階層 |
| 一般的な API ゲートウェイ | 20 階層 |
JSON ハイジャックとは?
歴史的な脆弱性です。初期のブラウザはクロスオリジンの <script src> が JSON を読み込んで実行することを許し、攻撃者は
Array コンストラクタや Object.prototype.__defineSetter__ を再定義してデータを盗みました。
現代の保護はかなり完全です。
- ブラウザは
__defineSetter__で配列リテラルをハイジャックすることをもう許しません。 - 正しい
Content-Type: application/jsonがスクリプト実行を防ぎます。 X-Content-Type-Options: nosniffが MIME スニッフィングを防ぎます。
古いシステムにはなお残余リスクがあります。実践的な助言:トップレベルに裸の配列ではなくオブジェクトを使う。
{"data": [...]} のようなラッパーは、レスポンスが有効な JavaScript 文にならないようにし({ はコードブロックとして扱われます)、
構文レベルでハイジャックを遮断します。
JSON をログに出すリスクは?
三つ:
- 機密情報がディスクに書かれる:電話番号、ID 番号、トークン、平文パスワードがログ基盤に入ります。そこは通常、権限が緩く保持期間も長いです。
- ログインジェクション:改行と偽のログ接頭辞を含む JSON 値が一つのレコードを複数に分割し、監査証跡を汚染します。
- サイズの爆発:10 MB のレスポンス全体をログに書くと、ログ基盤がすぐに埋まります。
方法:
// ログの前に秘匿化と切り詰めを行う
function safeLog(data) {
const cloned = structuredClone(data);
for (const key of ['password', 'token', 'idCard', 'phone']) {
if (key in cloned) cloned[key] = '***';
}
const text = JSON.stringify(cloned);
return text.length > 2000 ? text.slice(0, 2000) + '...(切り詰め)' : text;
}
構造化ログ基盤(pino など)は改行自体をエスケープしますが、秘匿化と切り詰めは依然としてあなたの仕事です。
3. クイックリファレンス
| 症状 | まず確認 | 修正 |
|---|---|---|
| パースが遅い | データ量、深いクローンが関与しているか | チャンク化/ストリーミング、JSON.parse(JSON.stringify()) を除去 |
| ページが固まる | メインスレッドの長いタスク、描画ノード数 | Worker + 仮想スクロール |
| タブがクラッシュ | メモリ使用量 | 全体を読み込まない。ストリーミングに切り替え |
| 深いクローンでデータが失われる | Date、NaN、Map |
structuredClone を使う |
| オブジェクトに突然余分なプロパティ | プロトタイプ汚染 | __proto__ をフィルタし、Map を使う |
| スクリプトがポップアップ | 文字列が HTML に連結されている | < をエスケープし、textContent を使う |
| スタックオーバーフロー | ネストの深さ | 10〜20 階層に制限 |
| ログが埋まる | レスポンス全体がログられる | 切り詰め + 秘匿化 |