JSON 工具箱

JSON Patch 的安全應用:原子性、冪等與併發衝突

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

實戰約 10 分鐘發佈於 2026-10-31更新於 2026-10-31
JSON Patch 的安全應用封面

結論:先校驗補丁的目標路徑與許可權,再用 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 }
]

安全做法是白名單而不是黑名單:

  1. 只允許補丁修改業務欄位,例如 /status、/items/0/quantity;
  2. 用正規表示式或路徑前綴校驗每個 path 與 from,拒絕陣列下標以外的非預期層級;
  3. 在解析 JSON 時就用 Object.create(null) 或 Map 承載資料,降低原型鏈被汙染的影響;
  4. 對補丁條目數、單條 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。