JavaScript 安全與防護 – 原型污染(Prototype Pollution)
簡介
在 Node.js 與前端 JavaScript 應用程式中,物件的原型(prototype)是語言最核心的機制之一。它讓我們可以在所有同類型的物件上共享方法與屬性,提升開發效率與程式碼可重用性。
然而,正因為原型是 全域共享 的結構,若被惡意或不小心 修改,就會產生 原型污染(Prototype Pollution) 的安全漏洞。
原型污染不僅會導致 資料完整性 被破壞,還可能被利用執行 任意程式碼、繞過權限驗證,甚至造成 Denial‑of‑Service(DoS)。因此,了解其原理、辨識攻擊向量、以及落實防護措施,是每位 JavaScript 開發者在 安全與防護 這一單元必須掌握的能力。
核心概念
1. 什麼是原型污染?
JavaScript 中每個物件都有一條隱藏的鏈([[Prototype]]),指向其 原型物件。當我們讀取或寫入一個不存在於本身的屬性時,JavaScript 會沿著原型鏈向上查找。如果透過 外部輸入(例如 JSON.parse、req.body)直接寫入 __proto__、prototype 或 constructor 等特殊屬性,就會 改變整個原型鏈,進而影響所有同類型的物件。
簡單比喻:想像所有汽車都有一個共用的「說明書」物件作為原型。若有人在說明書上寫下「所有汽車的速度上限改為 0」,那麼所有汽車都會受到影響。這就是原型污染的概念。
2. 攻擊向量
| 來源 | 常見情境 | 可能的污染屬性 |
|---|---|---|
JSON.parse |
直接解析外部 JSON | {"__proto__":{"admin":true}} |
Object.assign / _.merge(lodash) |
合併使用者提供的物件 | {"constructor":{"prototype":{"isAdmin":true}}} |
| 表單/URL 參數 | Express、Koa 等框架自動解析 | ?__proto__[isAdmin]=true |
| npm 套件 | 如 config, mongoose 等會深層合併設定 |
內部使用 merge、extend 等函式 |
重點:只要程式碼在 深度合併(deep merge)或 遞迴遍歷 物件時,沒有對
__proto__、prototype、constructor進行過濾,就有可能被污染。
3. 為什麼會影響整個系統?
一旦原型被污染,所有 之後新建立的物件(只要與該原型相同)都會自動擁有被污染的屬性。例如:
// 假設全域 Object.prototype 已被污染
console.log({}.isAdmin); // true
這會導致:
- 授權繞過:檢查
if (user.isAdmin)變成永遠成立。 - 資料錯誤:程式依賴
hasOwnProperty判斷屬性是否自有,卻被偽造。 - DoS:在原型上加入巨大的陣列或函式,導致記憶體耗盡。
4. 程式碼範例
以下提供 5 個實用範例,說明原型污染的產生、危害與防禦方式。
範例 1️⃣:最簡單的污染示範
// 直接透過物件字面量寫入 __proto__
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');
// 觸發污染
Object.assign({}, payload);
// 之後任何新物件都會繼承 isAdmin
console.log({}.isAdmin); // => true
說明:
Object.assign會把payload的所有可列舉屬性(包括__proto__)複製到目標物件,導致原型被改寫。
範例 2️⃣:利用深層合併(lodash merge)的漏洞
const _ = require('lodash');
// 攻擊者送出的深層物件
const malicious = {
constructor: {
prototype: {
isSuperUser: true
}
}
};
// lodash 深度合併
const config = _.merge({}, malicious);
console.log({}.isSuperUser); // => true
說明:
lodash.merge會遞迴遍歷所有屬性,若不過濾constructor.prototype,同樣會污染原型。
範例 3️⃣:Express 中的表單參數污染
// 假設使用 body-parser 解析 JSON
app.post('/login', (req, res) => {
// req.body 可能被污染
const user = Object.assign({}, req.body);
// 授權檢查
if (user.isAdmin) {
// 原本只有管理員才會進到這裡
res.send('Welcome, admin!');
} else {
res.send('Access denied');
}
});
若客戶端送出:
POST /login
Content-Type: application/json
{
"__proto__": { "isAdmin": true }
}
則 所有 user 物件都會有 isAdmin: true,攻擊者即可繞過授權。
範例 4️⃣:防禦 – 透過 Object.create(null) 建立「乾淨」物件
function safeAssign(target, source) {
// 建立沒有原型的乾淨物件
const clean = Object.create(null);
for (const key of Object.keys(source)) {
// 忽略危險屬性
if (['__proto__', 'prototype', 'constructor'].includes(key)) continue;
clean[key] = source[key];
}
return Object.assign(target, clean);
}
// 使用方式
const safeUser = safeAssign({}, req.body);
說明:
Object.create(null)產生的物件 沒有原型,即使不小心寫入__proto__也不會影響全域Object.prototype。
範例 5️⃣:使用 proxy 監控並阻擋危險屬性
function createSafeObject(obj) {
return new Proxy(obj, {
set(target, prop, value) {
if (['__proto__', 'prototype', 'constructor'].includes(prop)) {
throw new Error(`Attempt to modify protected property: ${prop}`);
}
return Reflect.set(target, prop, value);
}
});
}
// 範例
const safeConfig = createSafeObject({});
safeConfig.foo = 'bar'; // ✅ 正常
safeConfig.__proto__ = {}; // ❌ 會拋出錯誤
說明:
Proxy可以在屬性寫入時即時檢查,對於大型專案或第三方套件不易改寫的情況特別有用。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 建議的防禦措施 |
|---|---|---|
直接使用 Object.assign、_.merge、deepmerge |
原型被不經意污染 | 過濾 __proto__、prototype、constructor;或使用 Object.create(null) |
依賴 hasOwnProperty 判斷屬性 |
被偽造的屬性繞過檢查 | 使用 Object.prototype.hasOwnProperty.call(obj, key),或在 安全的物件 上檢查 |
| 將使用者輸入直接序列化成物件 | JSON 解析時自動污染 | 禁用 JSON.parse 的 reviver,或在解析後 深度遍歷 檢查危險鍵 |
| 使用舊版第三方套件(如 lodash < 4.17.21) | 已知 merge 漏洞 |
升級 至最新版本;或自行 實作安全合併 |
| 在全域範圍(global、window)掛載自訂屬性 | 可能被污染的原型影響全局 | 儘量 封裝 在模組或 IIFE,避免直接修改全域原型 |
具體最佳實踐
- 輸入驗證:對所有外部輸入(HTTP、檔案、CLI)使用 白名單 檢查,絕不允許
__proto__、prototype、constructor等關鍵字。 - 安全合併:使用
Object.assign前先 剔除危險屬性,或改用deepmerge的isMergeableObject回呼過濾。 - 最小權限原則:不要在 全域(
Object.prototype)上掛載自訂屬性,改為 專屬類別 或 Symbol。 - 升級依賴:定期檢查 npm 套件安全公告,特別是常用的 深度合併、設定管理 套件。
- 自動化測試:加入 原型污染測試案例(例如使用
npm audit、snyk)於 CI/CD 流程。
實際應用場景
| 場景 | 為何會出現原型污染 | 防護要點 |
|---|---|---|
設定檔合併(config、dotenv) |
多層設定檔使用 merge 合併使用者自訂檔 |
只允許 白名單 鍵;在合併前使用 Object.create(null) |
ORM / ODM(如 mongoose) |
讀取資料庫文件時自動映射為 POJO,若文件包含 __proto__ 會污染模型 |
在模型層過濾危險屬性;使用 lean() 取得純資料後自行清理 |
| API Gateway(Node.js + Express) | 代理多個微服務,將請求體直接轉發或合併 | 深度檢查 請求體;使用 JSON Schema 驗證 |
| 前端狀態管理(Redux、MobX) | 從伺服器載入初始狀態,直接 Object.assign(state, payload) |
在 reducer 中 clone 並 過濾,或使用 immutable.js |
第三方套件(如 lodash.merge) |
套件內部未處理危險屬性 | 自行 fork 或 包裝 該函式,加入過濾機制 |
總結
原型污染是 JavaScript 生態系統 中一個隱蔽但極具危害性的漏洞。只要程式碼在 深度合併、解析外部物件 時未對 __proto__、prototype、constructor 進行嚴格過濾,就可能讓整個應用程式的安全基礎被顛覆。
透過本文的 概念說明、實作範例、以及 最佳實踐,讀者應該能:
- 清楚辨識出可能的 原型污染入口;
- 掌握 防禦技巧(過濾危險鍵、使用
Object.create(null)、Proxy 監控等); - 在 實務專案 中落實 安全合併與驗證,降低被利用的風險。
最後提醒:安全不是一次性的設定,而是 持續的檢視與改進。在每一次引入新套件、改寫資料流或擴充 API 時,都請重新檢查是否有可能產生原型污染,並將相應的防護機制寫入程式碼與測試中。只有這樣,我們才能在 JavaScript 的自由與強大之間,維持應用程式的 可信任 與 穩定。