JSON Patch 的安全應用:原子性、冪等與併發衝突
結論:JSON Patch 只是變更格式,原子性、冪等和併發控制仍要自己設計。本文從 test 前置條件、路徑轉義、回滾實現到 ETag 協作,給出可落地的安全用法。

結論:先校驗補丁的目標路徑與許可權,再用 test 或 If-Match 做併發斷言;補丁在副本上套用,失敗不能留下半成品。
PATCH請求最怕的不是寫錯欄位,而是「看起來成功了,實際上把別人的修改覆蓋了」。JSON Patch 提供了工具,但安全用法要自己搭。
一、RFC 6902 的操作集回顧
JSON Patch 用一組操作描述變更,媒體型別是 application/json-patch+json。六種操作按陣列順序執行:
| 操作 | 作用 | 關鍵約束 |
|---|---|---|
add |
新增成員或插入陣列元素 | 陣列下標可以等於長度,- 表示末尾 |
remove |
刪除成員或元素 | 目標路徑必須存在 |
replace |
替換已有值 | 目標路徑必須存在 |
move |
移動節點 | 不能移動到自己的子路徑下 |
copy |
複製節點 | 來源路徑必須存在 |
test |
斷言值相等 | 失敗則整個補丁不生效 |
很多人只記住前三項,卻忽略 test 才是把「盲寫」變成「條件寫」的關鍵。完整的操作語意與陣列邊界,在 JSON Pointer、Patch 與 Merge Patch 裡有逐條說明。
二、用 test 把補丁變成條件更新
一個典型的樂觀鎖補丁,第一條不是寫操作,而是版本斷言:
{"op":"test","path":"/version","value":1}
把它放進完整的補丁裡:
[
{ "op": "test", "path": "/version", "value": 1 },
{ "op": "replace", "path": "/version", "value": 2 },
{ "op": "replace", "path": "/status", "value": "paid" }
]
執行邏輯是:先確認文件當前版本確實是 1,再把它改成 2。如果另一個請求已經把版本推進到 2,第一條 test 失敗,整個補丁不生效,伺服器返回衝突。這樣既避免了遺失更新,也不需要分散式鎖。
幾個設計要點:
test要放在所有寫操作之前,否則前面的寫已經生效,斷言就失去意義;- 斷言的欄位應當是伺服器生成、使用者端無法偽造的版本號或摘要,不要用使用者端可隨意改的普通欄位;
- 版本號每次成功寫入都要遞增,否則兩個請求都用同一個舊版本仍然會通過;
test的value必須與文件中的 JSON 值型別完全一致,1與"1"不相等。
三、路徑轉義與原型汙染
JSON Pointer 的轉義規則是:~ 寫成 ~0,/ 寫成 ~1,且必須先替換 ~ 再替換 /,否則會出現二次轉義。
鍵名 "a/b" → /a~1b
鍵名 "m~n" → /m~0n
鍵名 "a~/b" → /a~0~1b
外部補丁最容易出問題的地方不是轉義,而是危險鍵名。__proto__、constructor、prototype 這類鍵在 JavaScript 裡可能觸發原型汙染,即使它只是補丁裡的一個普通 add:
[
{ "op": "add", "path": "/__proto__/polluted", "value": true }
]
安全做法是白名單而不是黑名單:
- 只允許補丁修改業務欄位,例如
/status、/items/0/quantity; - 用正規表示式或路徑前綴校驗每個
path與from,拒絕陣列下標以外的非預期層級; - 在解析 JSON 時就用
Object.create(null)或Map承載資料,降低原型鏈被汙染的影響; - 對補丁條目數、單條
value的位元組數設定上限。
四、原子應用與回滾
RFC 6902 要求補丁原子執行,但「原子」是語意要求,不是所有庫的預設實現。fast-json-patch 這類庫在 mutateDocument 為 true 時會原地修改,失敗後可能留下部分變更。
import { applyPatch, validate } from 'fast-json-patch';
/**
* 以不可變方式套用補丁,失敗時返回原始文件。
* @param document 原始文件,呼叫方不應被修改
* @param patch 待套用的 JSON Patch 操作陣列
* @returns 套用結果,包含成功旗標與新文件
*/
function applySafely(document: unknown, patch: unknown[]) {
const errors = validate(patch, document);
if (errors) {
return { ok: false, document };
}
try {
const result = applyPatch(document, patch, false, false);
return { ok: true, document: result.newDocument };
} catch {
return { ok: false, document };
}
}
實現層面的三條建議:
- 優先深拷貝或結構化複製,在副本上套用,成功後再替換引用;
- 不要依賴庫的自動回滾,不同庫對異常的處理並不一致;
- 先校驗再執行:
validate只能檢查結構,語意衝突仍要靠test,兩者不能互相替代。
如果補丁是跨多個資源的事務(比如同時改訂單和庫存),JSON Patch 的原子性只在單個文件內成立,跨文件要用資料庫事務或補償操作。
五、冪等性:PATCH 不是天然冪等
HTTP 規範裡 PUT 和 DELETE 是冪等的,PATCH 不一定。同一個 JSON Patch 重複提交,結果可能完全不同:
add /items/-每次都在陣列末尾追加,重複執行就多出一條;replace /version把版本從 1 改成 2,第二次執行仍然成功,但語意上已經重複;remove /temp第二次執行會因為路徑不存在而失敗。
想做到冪等,有三種常見方案:
| 方案 | 做法 | 適用場景 |
|---|---|---|
| 版本前置 | 補丁第一條 test /version,成功後遞增 |
資源有明確版本號 |
| 冪等鍵 | 使用者端生成 Idempotency-Key,伺服器快取結果 |
支付、下單等寫操作 |
| 目標態補丁 | 用 replace 而不是 add,讓結果只取決於終值 |
配置、狀態欄位 |
最忌諱的是「補丁裡既有 add 又有 remove,還指望重試安全」。只要補丁包含追加或刪除,就必須配合版本斷言或冪等鍵。
六、併發衝突:ETag 與 If-Match
除了在補丁裡寫 test,HTTP 層還有一套標準做法:伺服器返回 ETag,使用者端下次請求帶 If-Match。
GET /orders/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json
[{"op":"replace","path":"/status","value":"paid"}]
如果資源已經變化,伺服器返回 412 Precondition Failed,使用者端重新拉取後再重試。If-Match: * 表示「只要資源存在就執行」,不適合需要防覆蓋的場景。
test 與 If-Match 的區別:
If-Match在 HTTP 層校驗,早於請求體解析,成本更低;test在補丁語意層校驗,可以斷言任意欄位,不依賴伺服器是否支援 ETag;- 兩者可以疊加,但不要只做其中一件卻宣稱「併發安全」。
七、庫實現差異與選型
| 實現 | 語言 | 原子性 | test 支援 |
備註 |
|---|---|---|---|---|
fast-json-patch |
JavaScript | 可選(預設生成新文件) | 支援 | 生態廣,注意 mutateDocument |
jsonpatch |
Python | 異常時回滾 | 支援 | 與標準貼合,錯誤訊息清晰 |
json-patch |
Go | 基於操作副本 | 支援 | 常與 JSON Merge Patch 一起用 |
zjsonpatch |
Java | 支援 | 支援 | 與 Jackson 整合方便 |
選型時不要只看「是否支援六種操作」,還要確認三件事:失敗是否回滾、test 的比較是否嚴格、陣列 add 越界是否報錯。寬鬆實現會讓同一份補丁在不同環境產生不同結果。
八、上線前的安全檢查清單
- [ ] 端點宣告
application/json-patch+json; - [ ] 補丁在副本上套用,失敗不汙染原文件;
- [ ] 外部補丁的每個
path與from都經過白名單校驗; - [ ] 拒絕
__proto__、constructor、prototype等危險鍵; - [ ] 限制補丁條目數與單個值的體積;
- [ ] 用
test或If-Match做併發控制; - [ ] 補丁寫入稽核日誌,記錄操作者、時間與完整操作陣列;
- [ ] 明確補丁跨文件時的事務邊界。
補丁天然是一份「改了什麼」的完整描述,適合直接作為稽核記錄。介面層的錯誤碼與冪等設計,可以繼續參考 JSON API 設計;如果補丁來自兩個分支的合併,還要處理衝突語意,見 JSON 檔案的 Git 合併。
常見問題
test 操作失敗應該返回什麼狀態碼?
如果衝突來自版本斷言,返回 409 Conflict 或 412 Precondition Failed 都合理;推薦與 If-Match 保持一致用 412,並在回應內文裡帶上當前版本,方便使用者端重試。
補丁裡有 add 和 remove,還能安全重試嗎?
不能直接重試。add 到陣列末尾會重複追加,remove 第二次會因路徑不存在而失敗。必須加版本前置斷言或冪等鍵,讓重複提交被識別為同一次操作。
JSON Patch 能保證跨文件事務嗎?
不能。RFC 6902 的原子性只作用於單個 JSON 文件。涉及訂單、庫存等多個資源時,要用資料庫事務、Saga 補償或訊息佇列來保證一致性。
為什麼不能在補丁裡直接寫 __proto__?
因為多數 JavaScript 實現會用普通物件承載文件,寫入 __proto__ 可能修改物件的原型鏈,影響後續所有物件。路徑白名單是最可靠的防線,不要只依賴庫自身的過濾。
如何讓 PATCH 請求具備冪等性?
常見做法有三種:補丁第一條寫 test /version 並在成功後遞增版本,伺服器快取 Idempotency-Key 對應的結果,或者用 replace 而不是 add 讓結果只取決於終值。只要補丁包含追加或刪除,就必須配合版本斷言或冪等鍵,不能直接重試。
是否必須同時使用 test 和 If-Match?
不必兩個都做,但只做其中一個不能宣稱併發安全。If-Match 在 HTTP 層校驗、早於請求體解析,成本更低;test 在補丁語意層校驗,可以斷言任意欄位,不依賴伺服器是否支援 ETag。