跳至內容

在 JavaScript 中安全解析 JSON

JSON.parse 看似簡單卻有不少陷阱——原型污染、BigInt、不可信輸入。本文整理生產環境的解析檢查清單。

JSON.parse(text) 只有一行,這讓人誤以為 JSON 解析是個已解決的問題。 但只要輸入來自網路、檔案或使用者,它就不算。本文是 JavaScript 與 TypeScript 在生產環境安全解析 JSON 的實務檢查清單:JSON.parse 會 拋出什麼、如何在不撒謊的前提下加上型別,以及曾在真實程式碼造成漏洞 的原型污染陷阱。

JSON.parse 基本行為

JSON.parse(text, reviver?) 接受字串,回傳值;解析失敗時 拋出 SyntaxError——不會回傳 nullundefined。任何生產環境的呼叫 端都必須準備好接住。

const value = JSON.parse('{"a":1}'); // { a: 1 }
JSON.parse('{"a":'); // SyntaxError: Unexpected end of JSON input

拋出的錯誤包含 messagename,並會在訊息中附上解析器猜測的位置。 詳細解讀請參考 常見 JSON 語法錯誤

永遠以 try/catch 包起來

不可信輸入意味著一定會出現解析失敗。任何一次未捕捉的拋出,足以讓 請求處理器崩潰、讓 React 樹掛掉。

function safeParse(text) {
  try {
    return { ok: true, value: JSON.parse(text) };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

失敗時應該記錄的兩件事:

  • 錯誤訊息與位置。
  • 輸入的 截短前綴(例如前 200 個字元)——絕不要記錄完整內文, 那可能含有機密,也可能是好幾 MB。
if (!result.ok) {
  console.warn("JSON parse failed", {
    error: result.error,
    preview: text.slice(0, 200),
    length: text.length,
  });
}

reviver 函式

JSON.parse 第二個參數是 reviver——解析過程中每個 key/value 都會 呼叫一次,可用來轉換值。典型用法是把日期字串還原為 Date 物件:

const ISO_DATE = /^\d{4}-\d{2}-\d{2}T/;
function reviver(key, value) {
  if (typeof value === "string" && ISO_DATE.test(value)) {
    return new Date(value);
  }
  return value;
}

const data = JSON.parse('{"createdAt":"2026-05-13T10:00:00Z"}', reviver);
data.createdAt instanceof Date; // true

對於 BigInt 需要使用 sentinel:JSON 數字到了 reviver 時已經是雙精度 浮點數,精度損失發生在 reviver 之前。兩種解法:

  • 大整數以字串序列化,在 reviver 中 BigInt() 還原。
  • 使用 json-bigint 等在解析器更早介入的函式庫。

原型污染陷阱

這是多數開發者不知道的問題。JSON 規格允許 "__proto__" 作為物件鍵, JSON.parse 會老實還原這個鍵——但若你接著用 不安全的 merge 把 解析結果合進另一個物件,某些 merge 實作會走 prototype chain,污染整 個 process 的 Object.prototype

const evil = JSON.parse('{"__proto__":{"polluted":true}}');
// evil 本身沒事——但是…
const target = {};
naiveMerge(target, evil);
console.log({}.polluted); // true——所有物件都被加上這個屬性

防禦方式:

  • 收容不可信鍵的 map,請用 Object.create(null)
  • 使用會拒絕 __proto__constructorprototype 鍵的 merge 函式庫(Lodash 4.17.11+、defu 或自己寫的)。
  • 若只是讀取欄位、不做 merge,則安全。

同理:把解析結果當作 Map 的 key、或當作屬性名稱寫入 target[parsed.key] = value,攻擊者控制的 key 就是漏洞。

對不可信輸入做 schema 驗證

JSON.parse 證明的是輸入 語法 正確, 證明輸入符合你預期的 形狀。安全模式是兩階段:先解析,再對 schema 驗證。

Zod 為例:

import { z } from "zod";

const User = z.object({
  id: z.string(),
  email: z.string().email(),
  age: z.number().int().nonnegative(),
});

function parseUser(text: string) {
  const json = JSON.parse(text); // 語法錯誤會拋出
  return User.parse(json); // 形狀錯誤會拋出
}

對外暴露的伺服端 endpoint,建議在解析前先限制 輸入大小——一份 100 MB 全是 [[[[[… 的合法前綴會在出錯前先把記憶體耗光。超過預期 封包大小一律拒絕。

可使用 JSON 驗證工具 隨手以 schema 驗證 資料,也可用 JSON 轉 TypeScript 由 範本產生型別宣告。

串流與大型 payload

JSON.parse 是一次到位、整份載入記憶體的解析器。當資料量大到放不下:

  • NDJSON——一行一筆 JSON。逐行讀取後 JSON.parse 每行。雲端日誌都這樣輸出。
  • 串流解析器——clarinetstream-json、或瀏覽器的 TextDecoderStream 配上自寫狀態機。適合單一巨大陣列、希望邊接收邊處理。

深入內容請參考 處理大型 JSON 檔案

給結果加上型別(不撒謊)

JSON.parse 在 TypeScript 中回傳 any。誘惑是直接 cast:

const user = JSON.parse(text) as User; // 一個謊言——編譯器無從檢查

這在執行期會炸出難解的錯誤。誠實的做法是 解析成 unknown,再以 驗證器收斂

const raw: unknown = JSON.parse(text);
const user = User.parse(raw); // zod 在形狀不符時拋出;結果有型別

如果你同時控制兩端、來源可信(自家後端、自家設定檔),cast 是務實的 選擇。除此之外,請驗證。

檢查清單

在把 JSON 解析交付生產前:

  • 用 try/catch 包起來。
  • 記錄錯誤與截短前綴,不要記錄完整內文。
  • 對輸入加上大小限制。
  • 對形狀做驗證(Zod、Yup、ajv、io-ts)——不要倚賴 cast。
  • 在解析結果上避免會走 __proto__ 的 merge。
  • 日期、BigInt 改用 reviver 或字串編碼。
  • 資料量可能超過幾 MB 時改用串流解析器。

工具:

延伸閱讀