JSON 裡的 XSS 是怎麼發生的
結論:JSON 本身不會執行,XSS 發生在「把 JSON 當 HTML 用」的那一步。伺服器拼頁面、前端用 innerHTML、把 JSON 直接塞進 `<script>` 標籤,是三個最常見的入口。

結論:JSON 本身不會執行,防護要先堵住三個輸出入口——伺服器按上下文轉義、前端用 textContent 取代 innerHTML、內嵌 JSON 時把 < 轉成 \u003c。
{"name": "<img src=x onerror=alert(1)>"} 這段資料是合法的。它的危險性取決於你把它放到哪裡。
入口一:伺服器拼接 HTML
res.send(`<div>歡迎,${data.name}</div>`);
data.name 裡的標籤被瀏覽器當成 HTML 解析,腳本執行。JSON 在這裡只是「裝著攻擊載荷的容器」,真正的問題是沒有按 HTML 上下文轉義。
修法:範本引擎預設轉義(EJS 的 <%= %>、Vue 的 {{ }}、Django 的 {{ }}),別用 <%- %>、v-html、|safe 這類捷徑去渲染使用者資料。
入口二:前端 innerHTML
el.innerHTML = user.bio; // 危險
el.textContent = user.bio; // 安全
textContent 把內容當純文字插入;innerHTML 會解析標籤。
React 的 {user.bio}、Vue 的 {{ user.bio }} 預設走文字節點,是安全的;一旦用 dangerouslySetInnerHTML 或 v-html,就等於主動開了這個口子。
確實要渲染富文本時,必須過一遍白名單淨化(DOMPurify 之類),而不是直接信任後端。
入口三:內嵌進 <script>
<script>
window.__DATA__ = {"name": "x</script><script>alert(1)</script>"};
</script>
JSON 裡的 </script> 會提前閉合標籤,後面的內容被當成新腳本執行。這是「JSON 內嵌」最容易忽略的一處。
修法:序列化後把危險字元轉成 Unicode 轉義:
JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
仍然是合法 JSON,解析出來的內容一字不差,但不會再閉合標籤。Next.js、Nuxt 這類框架的 __NEXT_DATA__、__NUXT__ 內嵌就做了類似處理。
順帶兩個相鄰風險
JSON 劫持(老瀏覽器)。 早期瀏覽器允許 <script src="api/user.json"> 載入並執行 JSON,攻擊者重定義 Array 建構子就能讀到資料。現代瀏覽器已修,加上 Content-Type: application/json 與 X-Content-Type-Options: nosniff 就基本免疫。
JSONP 的遺留。 JSONP 本質是「用 script 標籤跨域取資料」,回呼函式名可控就意味著可以注入任意程式碼。新專案不要再用。
一份檢查清單
- 所有把 JSON 欄位寫進 HTML 的地方,是否按上下文(HTML/屬性/JS/URL)轉義
- 前端是否還有
innerHTML、v-html、dangerouslySetInnerHTML直接吃介面資料 - 內嵌 JSON 是否做了
<>&轉義 - 富文本是否有白名單淨化,而不是黑名單過濾
script - 回應標頭是否有
Content-Type: application/json與nosniff
常見問題
為什麼合法的 JSON 資料也能觸發 XSS?
JSON 本身只是文字,不會執行,合法性只說明它能被解析。危險發生在把它寫進 HTML 或腳本上下文的那一步:標籤被瀏覽器解析、</script> 提前閉合標籤,腳本就會執行。問題不在資料格式,而在輸出時沒有按上下文轉義。
前端渲染介面欄位時該如何選擇 textContent 與 innerHTML?
優先使用 textContent,它把內容當純文字插入,標籤會原樣顯示。innerHTML 會解析標籤,介面欄位裡帶 <img onerror=...> 就會執行。React 的 {user.bio}、Vue 的 {{ user.bio }} 預設走文字節點,dangerouslySetInnerHTML 與 v-html 則是主動開這個口子。
內嵌 JSON 到 <script> 時應該如何處理轉義?
需要。資料裡的 </script> 會提前閉合標籤,後面的內容被當成新腳本執行。序列化後把 <、>、& 分別替換成 \u003c、\u003e、\u0026,結果仍是合法 JSON,解析出來一字不差,但不會再閉合標籤。