JSON 處理中的安全風險清單
結論:JSON.parse 本身很安全,危險的是它之後發生的事:深合併、按路徑賦值、內聯到 HTML、無限制的深度與體積。

結論:先堵住兩個入口——所有深合併與按路徑賦值必須拒絕 proto、prototype、constructor,請求體大小與巢狀深度必須在解析前就有硬限制;只盯著 JSON.parse 本身,等於把最危險的入口留在原地。
攻擊者不需要發明新漏洞,只要在 JSON 裡多寫一個鍵。
一、原型汙染:最常見也最容易被忽視
1.1 JSON.parse 本身是安全的
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse 會把 __proto__ 當成普通自有屬性建立,不會改寫原型鏈。所以「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']);
/**
* 安全深合併:拒絕原型相關鍵,只處理自有屬性。
* @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 避免 setter 觸發原型修改
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 的實現必須校驗每一段令牌。
二、資源耗盡型攻擊
| 攻擊 | 手段 | 防護 |
|---|---|---|
| 深層巢狀 | 十萬層 [[[[...]]]] |
限制巢狀深度(通常 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;
}
三、內聯到 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) 讀取。
四、JSON 劫持與 JSONP
早期瀏覽器允許跨域 <script src="https://api.example.com/user"> 直接執行返回的 JavaScript,
而頂層是陣列的 JSON 恰好也是合法 JS 表示式,攻擊者可以透過重寫 Array 建構函式讀取資料。
現代瀏覽器已有防護,但仍建議:
- 介面頂層使用物件而非陣列(
{"data": [...]}); - 響應頭帶
X-Content-Type-Options: nosniff,並嚴格設定Content-Type: application/json; - 不再使用 JSONP;確有跨域需求用 CORS。
五、反序列化到物件的型別混淆
Java 生態的 Jackson 開啟預設型別(enableDefaultTyping)後,攻擊者可以指定 @class 欄位讓服務端例項化任意類,
配合可利用的 gadget 鏈達成遠端程式碼執行。這類漏洞歷史上多次出現(Fastjson、Jackson 都中過招)。
原則:反序列化的目標型別必須由服務端程式碼靜態指定,絕不允許由報文內容決定。
六、正則與 schema 的拒絕服務
- JSON Schema 的
pattern、JSONPath 的match()都接受正則; (a+)+$這類寫法在長輸入上會指數回溯;- 若正則來自使用者(自定義校驗規則),必須限制長度、禁用回溯引用與巢狀量詞,或改用 RE2 這類線性引擎。
七、日誌與脫敏
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;
}
注意脫敏要在寫日誌之前做,而不是事後清理日誌平臺。
八、上線前檢查清單
- [ ] 所有深合併函式拒絕
__proto__/prototype/constructor; - [ ] JSON Pointer / Patch 的路徑令牌做白名單校驗;
- [ ] 請求體大小與巢狀深度有硬限制,且在解析前生效;
- [ ] 內聯到 HTML 的 JSON 做了
<、>、&、U+2028、U+2029轉義; - [ ] 介面頂層使用物件,響應頭帶
nosniff; - [ ] 反序列化目標型別由程式碼靜態指定;
- [ ] 使用者可控的正則有複雜度限制;
- [ ] 日誌欄位有脫敏規則並做了測試。
常見問題
如何快速確認自己的深合併函式有沒有原型汙染入口?
把 JSON.parse('{"__proto__": {"isAdmin": true}}') 的結果餵給合併函式,隨後檢查 {}.isAdmin 是否變成 true;再把 constructor.prototype 那條也試一遍。用現成庫時,確認它是否拒絕 __proto__、prototype、constructor 三個鍵——很多老版本 lodash 的 merge 曾因此被通報。按路徑賦值的實現要單獨檢查,路徑令牌裡出現 __proto__ 時同樣會中招。
為什麼 JSON.parse 本身不會造成原型汙染?
它會把 __proto__ 當成普通自有屬性建立,不會改寫原型鏈,所以 data.polluted 和 {}.polluted 都是 undefined。汙染發生在解析之後:不安全的深合併把來源物件的鍵直接寫進目標物件,constructor.prototype 是同類入口。把「JSON.parse 導致原型汙染」當成根因,會修錯地方。
內聯 JSON 到 HTML 時是否必須轉義?
必須。字串裡的 </script> 會提前閉合指令碼塊,後面的內容被當作 HTML 解析,這是典型的 XSS;<、>、& 與 U+2028、U+2029 也要一併轉義。更穩的方案是把資料放在 <script type="application/json"> 裡(內容不執行),再用 JSON.parse 讀取 textContent。