'unsafe-inline' 的關鍵在於分階段:先掛 Report-Only 收集違規 2 週、再抽 inline 事件處理改 event delegation、最後算 SHA-256 hash 切 enforce。凱茂自家網站從 baseline 走到 Mozilla Observatory A+ 用了 25 個 commit、24 小時,砍掉 358 個 inline event handler 跟 73% 的 inline style 屬性。
為什麼 CSP 是企業網站第一道、也是最便宜的 XSS 防線
網站被駭往往不是後端 SQL injection,而是某個第三方 JS 套件被供應鏈攻擊(如 polyfill.io 事件)、或某段使用者輸入沒過濾被注入 inline <script>。傳統做法是逐一過濾輸入、定期掃描,但 瀏覽器層級的 CSP 攔截等於多了一道防線:即使攻擊腳本確實被注入頁面,只要該腳本不在 CSP 白名單裡,瀏覽器拒絕執行。
CSP 的成本特別低:不買產品、不改後端架構,只需要在 HTTP response 加一條 header。但前提是網站不能用 'unsafe-inline' ——而幾乎所有沒做過 CSP 加固的網站都用了 'unsafe-inline',等於把這道防線開洞給整片開放。
一、為什麼移除 'unsafe-inline' 這麼難?
傳統網站把 onclick、onload、onsubmit、JSON-LD、critical CSS 都直接寫在 HTML 裡。每段 inline 內容不一樣,瀏覽器無法用一條規則統一信任。CSP 規範給三條路:
- Hash:對 inline body 算 SHA-256,把 hash 列進
script-src。每改一次內容,hash 就要重算。 - Nonce:每次 response 動態生成 nonce,inline 標籤帶
nonce="..."。需要動態 backend 處理 response。 - 抽出:把 inline 改成
<script src="...">,由 host 白名單覆蓋。
純靜態 HTML + IIS 部署無法用 nonce(要動態 response 寫入),所以只剩 hash 跟抽出兩條路。我們網站的工程量是這樣分布的:
- 358 個 inline event handler(onclick / onload / onsubmit / onmouseover)→ 全部抽到 event delegation
- 49 個 unique inline
<style>區塊 → SHA-256 hash 寫進style-src - 285 個
<link rel="stylesheet" media="print" onload="this.media='all'">→ 改data-async-css由共用 .js 接管 - 8,600 個
style="..."屬性 → 抽到 BEM-style class,目前清到 2,286(-73%) - 667 段 inline
<script>→ 抽到 shared-events.js / petite-init.min.js / 算 hash
二、階段化導入路徑(6 個 Phase)
Phase 1:Report-Only baseline,不阻擋頁面
先掛 Content-Security-Policy-Report-Only 描述目前現況(含 'unsafe-inline'、'unsafe-eval'、所有第三方 CDN domain)。違規不會被阻擋,但會 POST 到 report-uri 收集。跑 2 週後檢查違規 log——任何違規都是「意料外行為」(攻擊嘗試、瀏覽器擴充注入、遺漏的 domain)。
我們網站跑 2 週只收到 3 個違規,全部是漏報的 GTM domain,補進白名單後即清空。這個階段的目的是確認沒有未知第三方——很多企業網站業務團隊偷加廣告 pixel / 行銷 iframe 沒通知 IT,CSP-RO 階段一次抓出來。
Phase 2-3:抽 inline 事件處理
把 onclick、onload、onsubmit 改成 event delegation。在共用 shared-events.js 裡 document.addEventListener('click', e => ...),按 data-track、data-hide-persist、data-remove 等屬性分發。這樣 onclick attribute 從 1,030 個收到 0 個。
GA4 lazy loader(每頁頭部 inline <script> 算 100+ 行)也從 119 個檔案抽出統一 source。
Phase 4:onload race-safe + inline style hash
原本的 <link rel="stylesheet" media="print" onload="this.media='all'">(critical CSS 的 print-then-apply 技巧)改成 <link data-async-css>,由 shared-events.js 在 DOMContentLoaded 統一 swap。<noscript><link> 仍是 no-JS fallback。
剩餘 inline <style> 算 SHA-256 hash 寫進 style-src。49 個 unique hash 涵蓋全站。
Phase 5:切 enforce + 分目錄 web.config
把 header 從 Content-Security-Policy-Report-Only 換成 Content-Security-Policy,移除 script-src 'unsafe-inline'。這時候 hash 必須完全對應,否則該 inline 區塊整段被擋。
我們網站 inline 集合算出 315 個 unique hash,全部寫進 CSP value 是 17.7 KB,超過 Cloudflare 16 KB header 上限。解法是按目錄拆 bundle:
| 路徑 | web.config | hashes | 大小 |
|---|---|---|---|
| root + lp + solutions | /web.config | 111 | 6.7 KB |
| articles/* | /articles/web.config | 178 | 10.3 KB |
| case-studies/* | /case-studies/web.config | 26 | 2.1 KB |
| admin/*(Express serve) | server.js middleware | 5 | 1 KB |
Phase 6:admin / 後台路徑獨立 enforce
後台是真正攻擊面(攻破即可改全站、看客戶資料),CSP 要單獨更嚴。我們的 admin 走 IIS rewrite 至 Express,CSP 由 server.js middleware 設置。但有一個雷:IIS web.config 的 customHeaders 會把 root CSP append 到 Express response,把 admin bucket CSP shadow 掉。修法是 web.config 加 <location path="admin"><customHeaders><remove name="Content-Security-Policy" /> 讓 Express 的 header pass through。
三、實作三大技術細節(踩坑紀錄)
坑 1:CRLF normalization
CSP hash 必須對 normalized body(HTML parser 會把 CRLF / CR 一律轉成 LF)算 sha256。Windows 開發環境的 .html file 多半是 CRLF,build script 必須先 body.replace(/\r\n?/g, '\n') 才算 hash,否則所有 hash 都對不上瀏覽器算的,整片頁面被擋。
這個我們花了一小時 debug 才發現——dev 看 OK 但 prod 全爆。
坑 2:Runtime-injected style
有些 JS 套件動態 document.createElement('style') + style.textContent = '...' + appendChild。這段 CSS 不在任何 .html source 裡,build script 掃不到。我們的 chat-widget 套件就是這樣——lazy load 後注入 50 行 CSS。
修法是 build script 直接 parse minified JS 字串字面量,把 textContent 那段算 hash 加進所有 bucket:
const src = fs.readFileSync('js/chat-widget.min.js', 'utf8');
const m = src.match(/\.textContent\s*=\s*("(?:\\.|[^"\\])*")/);
const css = JSON.parse(m[1]);
const hash = crypto.createHash('sha256').update(css, 'utf8').digest('base64');
坑 3:Pre-commit 自動化
CSP enforce 上線後,每次改 .html 都要重算 hash 並更新 web.config——手動跑非常容易忘記,忘記就是該頁上線壞掉。我們在 scripts/githooks/pre-commit 做:
- 偵測 staged
*.html或js/chat-widget.min.js - 自動跑
node scripts/build-csp-config.js - 把更新後的 4 個 config 檔
git add進此 commit - build 失敗就 abort commit
git config core.hooksPath scripts/githooks 讓 hook 跟 repo 同步,不只是個人 .git/hooks/。換機後跑 bash scripts/setup-githooks.sh 即可。
四、最終 CSP value(公開頁)
default-src 'self';
script-src 'self' 'unsafe-eval' <CDN whitelist> <sha256-... × 111>;
style-src 'self' <sha256-... × 44>;
style-src-attr 'unsafe-inline'; ← 暫保(8,600 個 style="..." 重構長期)
font-src 'self' data:;
img-src 'self' data: https: blob:;
connect-src 'self' <CDN whitelist>;
frame-src https://www.google.com https://www.googletagmanager.com;
object-src 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
report-uri /api/csp-report
唯一保留的 'unsafe-eval' 是 PetiteVue(公開頁)跟 Vue 3(admin)的框架限制——兩者都用 new Function() 編譯模板,不能移除。但這個是 需先有 XSS 才能利用的攻擊路徑,inline 防線清乾淨後實質風險已大幅降低。
五、Mozilla Observatory 評分結果
動工前的 baseline 約 D 級(CSP 充滿 unsafe-inline,多項 header 沒掛)。動完後跑 Mozilla Observatory v2:
| 項目 | 結果 | 分數 |
|---|---|---|
| redirection | ✓ pass | 0 |
| referrer-policy | ✓ pass | +5 |
| strict-transport-security | ✓ pass | 0 |
| subresource-integrity | ✓ pass | +5 |
| x-content-type-options | ✓ pass | 0 |
| x-frame-options | ✓ pass | +5 |
| cross-origin-resource-sharing | ✓ pass | 0 |
| cookies | ✓ pass | 0 |
| content-security-policy | fail('unsafe-eval') | -10 |
總分 105 / 100,Grade A+。唯一失分是 'unsafe-eval',框架限制不可移除。
六、給其他企業 IT 主管的 5 個 takeaway
重點摘要
- 先 Report-Only 跑 2 週:成本零,但會抓出業務團隊偷加的第三方 pixel、瀏覽器 extension、遺漏 domain。
- Cloudflare 16 KB header 限制:純靜態網站全站 hash 通常超 16 KB,按目錄分桶寫多個 web.config 是務實解。
- CRLF / runtime-injected style 是必踩的坑:dev 看 OK 但 prod 整片爆,先在 build script 內 normalize、再驗證 prod。
- Pre-commit hook 是維運必備:CSP 上線後每改 HTML 就要重算 hash,沒自動化就是定時炸彈。
- 'unsafe-eval' 通常移不掉:Vue / React / Angular 等框架都用
new Function(),能移除的代價是換框架,跟收益不成比例。Mozilla A+ 最多扣 -10 仍可達標。
需要協助為自家網站做 CSP 強化、或想瞭解資安標頭如何整合進 ISO 27001 / 銀行 RFP 應對?
預約 30 分鐘免費資安架構諮詢 →