JSON 処理のセキュリティリスクチェックリスト
要点:JSON.parse 自体は安全です。危険なのはその後に起きることです。深いマージ、パスによる代入、HTML へのインライン化、無制限の深さとサイズ。

要点:まず二つの入口を閉じましょう。すべての深いマージとパス代入は proto、prototype、constructor を拒否しなければならず、リクエストボディのサイズとネストの深さにはパース前に効く厳格な上限が必要です。JSON.parse 自体を見張っていても、危険な扉は大きく開いたままです。
攻撃者は新しい脆弱性を発明する必要はありません。JSON にキーを一つ足すだけです。
1. プロトタイプ汚染:最も一般的で最も見落とされる
1.1 JSON.parse 自体は安全
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse は __proto__ を普通の own プロパティとして作り、プロトタイプチェーンを書き換えません。ですから「JSON.parse がプロトタイプ汚染を引き起こす」という主張は不正確です。
1.2 その後のマージが危険
/**
* 安全でない深いマージ:ソースオブジェクトのキーを対象オブジェクトへ直接書く。
* @param target 対象オブジェクト
* @param source ソースオブジェクト(ユーザーの JSON 由来かもしれない)
* @returns マージ後のオブジェクト
*/
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] ?? {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true — サイト全体が汚染された
constructor.prototype も同種の入口です。
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 修正方法
/** 拒否しなければならないキー名 */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* 安全な深いマージ:プロトタイプ関連のキーを拒否し、own プロパティだけを扱う。
* @param target 対象オブジェクト
* @param source ソースオブジェクト
* @returns マージ後の新しいオブジェクト
*/
function safeMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) {
continue;
}
const sourceValue = source[key];
const isPlainObject =
typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue);
if (isPlainObject) {
result[key] = safeMerge(target[key] ?? {}, sourceValue);
continue;
}
// defineProperty を使って、セッターがプロトタイプ変異を引き起こさないようにする
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
より徹底した方法は、Object.create(null) をコンテナに使う(汚染できるプロトタイプチェーンが存在しない)か、単に Map を使うことです。
既製ライブラリを使うときは、この三つのキーをすでに処理しているか確認してください。lodash の merge の多くの古いバージョンがまさにこれで報告されました。
1.4 パスによる代入も同じくらい危険
// アンチパターン:パスがユーザー入力由来
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
pathSegments に __proto__ が現れると、上の汚染と等価です。JSON Pointer / JSON Patch の実装はすべてのトークンセグメントを検証しなければなりません。
2. リソース枯渇攻撃
| 攻撃 | 手口 | 緩和 |
|---|---|---|
| 深いネスト | 10 万階層の [[[[...]]]] |
ネストの深さを制限(通常 32〜64)し、パース前に事前走査する |
| 巨大な配列 | 数千万要素の配列 | リクエストボディのサイズと配列長を制限する |
| 極端に長い文字列 | 数百 MB の単一文字列 | フィールド長を制限する |
| 重複キーの爆発 | 膨大な数の重複キー | オブジェクトのキー数を制限する |
サーバーで最も効果的な二つの防御はリクエストボディのサイズ制限と深さ制限で、どちらもあらゆるパースの前に効かなければなりません。
/**
* パース前にネストの深さを安価に確認し、パース中のスタックオーバーフローを防ぐ。
* @param text JSON テキスト
* @param maxDepth 許容する最大の深さ
* @returns 深さが許容範囲内か
*/
function isDepthAcceptable(text: string, maxDepth = 64): boolean {
let currentDepth = 0;
let isInsideString = false;
let isEscaped = false;
for (const character of text) {
if (isInsideString) {
if (isEscaped) {
isEscaped = false;
} else if (character === '\\') {
isEscaped = true;
} else if (character === '"') {
isInsideString = false;
}
continue;
}
if (character === '"') {
isInsideString = true;
} else if (character === '{' || character === '[') {
currentDepth += 1;
if (currentDepth > maxDepth) {
return false;
}
} else if (character === '}' || character === ']') {
currentDepth -= 1;
}
}
return true;
}
3. HTML へのインライン化におけるインジェクション
JSON をページに直接書くのはよくある慣行ですが、三種類の問題を持ち込みます。
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
文字列内の </script> がスクリプトブロックを早く閉じ、残りが HTML としてパースされます。古典的な XSS です。
さらに二つの文字が同じように振る舞います。\u2028(行区切り)と \u2029(段落区切り)は JSON では合法ですが、JS ソースに埋め込むと構文エラーを引き起こします。
修正:
/**
* インラインスクリプト向けに JSON を安全にシリアライズする。
* @param value データオブジェクト
* @returns script タグ内に安全に置ける文字列
*/
function serializeForInlineScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
より堅牢な方法は、データを <script type="application/json">(内容が実行されない)の中に置き、JSON.parse(document.getElementById('data').textContent) で読むことです。
4. JSON ハイジャックと JSONP
初期のブラウザは、クロスオリジンの <script src="https://api.example.com/user"> が返された JavaScript を直接実行することを許していました。
トップレベルが配列の JSON はたまたま有効な JS 式でもあるので、攻撃者は Array コンストラクタを上書きしてデータを読み取れました。
現代のブラウザには保護がありますが、それでも推奨します。
- API のトップレベルは配列ではなくオブジェクトにする(
{"data": [...]})。 X-Content-Type-Options: nosniffを送り、Content-Type: application/jsonを厳格に設定する。- JSONP は使わない。クロスオリジンアクセスが本当に必要なときは CORS を使う。
5. オブジェクトへのデシリアライズにおける型混同
Jackson で既定の型付けを有効にすると(enableDefaultTyping)、攻撃者は @class フィールドを指定してサーバーに任意のクラスをインスタンス化させ、
悪用可能なガジェットチェーンと組み合わせてリモートコード実行を達成できます。この種の脆弱性は何度も現れました(Fastjson も Jackson も被害を受けました)。
原則:デシリアライズの対象型はサーバーコードが静的に指定しなければならず、ペイロードの内容で決まってはなりません。
6. 正規表現とスキーマにおけるサービス拒否
- JSON Schema の
patternと JSONPath のmatch()はどちらも正規表現を受け付けます。 (a+)+$のようなパターンは長い入力で指数的にバックトラックします。- 正規表現がユーザー由来(カスタム検証ルール)なら、長さを制限し、後方参照とネストした量指定子を禁止するか、RE2 のような線形エンジンに切り替えなければなりません。
7. ログとマスキング
JSON ログは機密情報を漏らす最も簡単な方法です。
/** ログでマスクしなければならないフィールド名(小文字で照合) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* ログ出力のためにオブジェクト内の機密フィールドを再帰的にマスクする。
* @param value 元の値
* @returns マスクされたコピー
*/
function maskSensitive(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => maskSensitive(item));
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => {
if (SENSITIVE_KEYS.has(entryKey.toLowerCase())) {
return [entryKey, '***'];
}
return [entryKey, maskSensitive(entryValue)];
}),
);
}
return value;
}
マスキングはログが書かれる前に行わなければならず、ログ基盤の後片付けで行うものではないことに注意してください。
8. リリース前チェックリスト
- [ ] すべての深いマージ関数が
__proto__/prototype/constructorを拒否する。 - [ ] JSON Pointer / Patch のパストークンがホワイトリストで検証される。
- [ ] リクエストボディのサイズとネストの深さに厳格な上限があり、その上限がパース前に効く。
- [ ] HTML にインライン化する JSON が
<、>、&、U+2028、U+2029をエスケープする。 - [ ] API はトップレベルにオブジェクトを使い、レスポンスで
nosniffを送る。 - [ ] デシリアライズの対象型がコードで静的に指定されている。
- [ ] ユーザー制御の正規表現に複雑さの制限がある。
- [ ] ログのフィールドにマスキング規則があり、その規則がテストされている。
FAQ
深いマージがプロトタイプ汚染に脆弱かどうかを確認するには?
JSON.parse('{"__proto__": {"isAdmin": true}}') をマージ関数に渡し、{}.isAdmin が true になったか確認します。constructor.prototype のペイロードでも繰り返します。サードパーティライブラリなら __proto__、prototype、constructor を拒否するか確認してください。lodash merge の多くの古いバージョンがまさにこれで報告されました。パスによる代入も独自に確認が必要です。パス内の __proto__ トークンは同じように汚染します。
JSON.parse 自体がプロトタイプ汚染の原因でないのはなぜですか?
__proto__ を普通の own プロパティとして作り、プロトタイプチェーンを決して書き換えないので、data.polluted も {}.polluted も undefined のままです。汚染はその後、安全でない深いマージがソースオブジェクトのキーを対象へ直接書くときに起こり、constructor.prototype も同種の入口です。JSON.parse を根本原因とみなすのは、誤った場所を直すことになります。
HTML にインライン化する JSON は常にエスケープすべきですか?
はい。文字列内の </script> がスクリプトブロックを早く閉じ、残りが HTML としてパースされます。古典的な XSS です。<、>、&、U+2028、U+2029 もエスケープが必要です。より安全な選択は、データを <script type="application/json"> の中に置き、内容が実行されないようにし、textContent で JSON.parse して読むことです。