CSP 擋 XSS,靠的是禁止執行 inline script
先把一件事講清楚,後面整篇都建立在這上面:Content Security Policy 能擋跨站腳本(XSS),核心機制就是禁止頁面執行行內(inline)的 script。
攻擊者注入 XSS 時,塞進 DOM 的往往是 <script>alert(document.cookie)</script> 這種直接寫在 HTML 裡的程式碼,或是 onclick="..." 這類行內事件處理器。瀏覽器一旦套上嚴格的 script-src,這些東西就跑不起來——這才是 CSP 的價值所在。
所以當你在 script-src 加上 'unsafe-inline',等於告訴瀏覽器「行內 script 全部放行」。攻擊者注入的那段也一起放行了。這時候 CSP 對 XSS 的防護等於拆掉,剩下的 default-src 'self'、白名單域名都救不回來。
配置 CSP 的目標因此很單純:讓網站不靠 'unsafe-inline' 也能正常運作。 不是「平衡」,是先試著拿掉,拿不掉再談退路。
正解是 nonce 或 hash
那自家確實需要的 inline script 怎麼辦?答案是 nonce 或 hash,兩個都不是 unsafe-inline。
nonce(number used once)是伺服器每次回應產生的一次性 token。HTTP header 裡寫進這次的 nonce,HTML 裡每個合法的 inline script 標籤帶上同一個值,瀏覽器只執行對得上的那些:
1 | Content-Security-Policy: script-src 'nonce-r4nd0m2024'; |
1 | <script nonce="r4nd0m2024"> |
攻擊者注入的 script 不知道這次的 nonce 是多少(每個請求都不同),所以執行不了。
hash 適合內容固定、不常變動的 inline script。你算出那段程式碼的 SHA-256 雜湊,放進 policy:
1 | Content-Security-Policy: script-src 'sha256-<base64-hash>'; |
只有雜湊一致的 inline script 會被執行,連空白差一格都不行。靜態頁面或內容不會變的腳本用 hash,動態渲染的頁面用 nonce。
這裡有個容易誤會的細節:依照 CSP Level 2 以上規範,當 script-src 裡同時存在 nonce 或 hash 時,現代瀏覽器會忽略同一指令裡的 'unsafe-inline'。Google 的 strict CSP 範例會故意保留 'unsafe-inline' 當作對舊瀏覽器(只懂 CSP Level 1)的退路——舊瀏覽器看不懂 nonce,就靠 'unsafe-inline' 讓頁面別整個掛掉,新瀏覽器則直接無視它走 nonce。所以「'unsafe-inline' 危險」精確的講法是:單獨使用、或在沒有 nonce/hash 陪襯的指令裡使用才危險。 原文範本的錯就錯在 script-src 'self' 'unsafe-inline' 沒有任何 nonce,那個 'unsafe-inline' 是真的生效的。
strict-dynamic 省掉維護白名單的痛
只用 nonce 還有個現實問題:很多第三方腳本(Google Analytics、各種 SDK)載入後又會自己再去載別的腳本。那些被動態載入的腳本沒有 nonce,會被擋下來。逐一把域名加進白名單既煩又脆弱——研究早就指出,大多數 script-src 白名單能被有 XSS 漏洞的攻擊者繞過。
'strict-dynamic' 解決這件事。它的語義是:把你用 nonce 或 hash 明確信任的「根腳本」所獲得的信任,傳遞給它後續動態載入的所有腳本。 你只要替自己放在頁面上的進入點腳本掛 nonce,它生出來的子孫腳本就自動被信任,域名白名單可以整個拿掉。
Google 官方建議的嚴格 CSP 長這樣:
1 | Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; |
讀法是:支援 'strict-dynamic' 的新瀏覽器只認 nonce,後面的 https: 和 'unsafe-inline' 都被忽略;舊瀏覽器不懂 'strict-dynamic',就退回用 https: 和 'unsafe-inline'。新瀏覽器拿到嚴格防護,舊瀏覽器至少不會壞掉。這跟「為了方便而開 'unsafe-inline'」是兩回事——這裡的 'unsafe-inline' 在會生效的瀏覽器上根本不會生效。
導入順序:Report-Only 觀察,收白名單,上 nonce
直接把嚴格 policy 推到生產環境,大概率把自家功能擋一片。正確順序是三步走。
第一步,Report-Only 模式觀察。 用 Content-Security-Policy-Report-Only 取代正式的 header,瀏覽器只回報違規、不真的擋。先看看現況到底違反了哪些規則:
1 | Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report-endpoint; |
跑個幾天到一兩週,收集真實流量產生的違規報告。
report-uri目前相容性最廣,但它已被規範標記為過時,新標準是搭配Report-Toheader 的report-to指令(Reporting API)。現階段兩個一起寫最保險——支援的瀏覽器走report-to,舊的退回report-uri。
第二步,依報告收斂。 看報告裡冒出哪些合法的外部域名、哪些自家 inline script 被點名,逐一處理:能搬到外部檔案的 inline script 就搬出去,必要的外部域名加進對應指令。這一步的產出是一份「不含 'unsafe-inline'」的草稿 policy。
第三步,上 nonce 強化並切正式 header。 把確實要保留的 inline script 改掛 nonce,policy 從 Report-Only 換成正式的 Content-Security-Policy,開始真正執行。
順序反過來——先上嚴格 policy 再修——只會在生產環境炸一輪,被迫回頭加 'unsafe-inline' 滅火,然後就再也拿不掉了。
meta 標籤設 CSP 有功能限制
CSP 可以走 HTTP header,也可以用 <meta> 標籤:
1 | <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'nonce-{random}';"> |
但 meta 標籤設的 CSP 不支援 report-uri、frame-ancestors、sandbox 這幾個指令,寫了也會被忽略。原因是 frame-ancestors 和 sandbox 必須在文件開始解析前就生效,而 meta 是解析到一半才讀到;report-uri 則是刻意禁掉,避免被注入的 CSP 把違規報告回傳到攻擊者的伺服器。
所以要用回報機制、防點擊劫持(frame-ancestors),就只能走 HTTP header。meta 標籤適合沒有後端控制 header 的靜態託管場景,當作備案。
載外部腳本配 SRI
放行了外部腳本來源,還有一層風險:CDN 被入侵、檔案被掉包,你的白名單照樣放行那個被竄改的檔。Subresource Integrity(SRI)擋這個。
做法是在 tag 上同時放 integrity(內容雜湊)和 crossorigin="anonymous":
1 | <script src="https://cdn.example.com/lib.js" |
瀏覽器抓到檔案後算雜湊,對不上就拒絕執行。crossorigin="anonymous" 是跨來源資源做 SRI 的必要條件,它要求 CORS 並讓瀏覽器不帶該域名的 cookie。
提醒一個過時資訊:曾經有個 require-sri-for script style 的 CSP 指令可以強制所有腳本都得帶 integrity,但它是實驗性的、已經從主流瀏覽器移除。現在的做法是直接在每個 tag 掛 SRI 屬性,靠 build 流程或 lint 規則確保沒漏掉,不要依賴那個指令。
真的拿不掉 unsafe-inline 的時候
理想很豐滿,現實是有些第三方函式庫就是在執行期硬塞 inline style 或 inline script,你動不了它的原始碼。這種情況確實存在——我自己遇過 ArcGIS 地圖元件在執行時注入 inline style,逼得 style-src 不得不留 'unsafe-inline',完整的追查過程寫在另一篇:AI 篤定說 CSP 的 style-src unsafe-inline 拿不掉,我追問一句後它道歉了。那篇是一條具體中風險案例的實戰,這篇是通論配置,兩篇分工看。
真的拿不掉時,按這幾條走,而不是拿話術帶過:
優先只在 style-src 妥協,不要動 script-src。 inline script 是 XSS 的主要載體,style-src 的 'unsafe-inline' 風險明顯低於 script-src——CSS 注入頂多做到資料側錄或版面破壞,搶不到 script-src 那種任意執行的程度。所以真要留一個,留在 style-src,死守 script-src 乾淨。
限縮到最小範圍。 別整站都掛 'unsafe-inline'。只在真的需要的那幾頁、那一個指令上開,其他頁面維持嚴格。
誠實記錄成已知風險。 寫進你的安全文件:哪個指令開了 'unsafe-inline'、為什麼(哪個函式庫、無法移除的具體原因)、補償措施是什麼(例如 script-src 仍然嚴格、有 frame-ancestors 'none'、輸入做了 XSS 過濾)。對稽核就照實講「這條是已知缺口,原因與補償如下」,不要包裝成「業界都這樣所以沒問題」。誠實標註的已知風險可以被接受、被追蹤、未來被修掉;用話術蓋過去的風險,下一個接手的人不會知道它在那裡。
Node.js 產 nonce
實作 nonce 的關鍵是每個請求生一個新的、密碼學安全的隨機值。Node.js 用內建 crypto:
1 | const crypto = require('crypto') |
crypto.randomBytes(16) 取 16 bytes(128 bits)的密碼學亂數,toString('base64') 轉成可放進 header 的字串。存進 res.locals.nonce 是慣例,這樣模板渲染時能拿到同一個值塞進 script 標籤。重點是每個請求都重新生成——nonce 重複使用就失去意義。
模板端把它放進 inline script:
1 | <script nonce="<%= nonce %>"> |
用 Express 的話,Helmet 的 CSP 中介層也支援把 scriptSrc 設成一個函式,回傳 `'nonce-${res.locals.nonce}'`,效果一樣,省去手寫 header 字串。
收尾
這套配置沒有「安全與實用的平衡點」這種說法,因為 nonce 路線同時拿到嚴格防護和正常運作,本來就不必妥協。真正的順序是:先用 Report-Only 看清楚現況,把 inline script 搬出去或改掛 nonce,script-src 死守乾淨,'strict-dynamic' 省掉維護白名單;只有在第三方函式庫硬塞、改不動的地方,才退到 style-src 留一個 'unsafe-inline',並且把它誠實記成已知風險。
判斷一份 CSP 好不好,就看一個地方:script-src 裡有沒有生效的 'unsafe-inline'。有,這份 CSP 對 XSS 基本沒在防;沒有,才開始有意義。





