0. 最快的學習路線
今天:先能用
知道三個標頭各擋什麼;用 Node server 真正送出 response headers;用 DevTools / curl 驗證。
完成標準:你會看 Response Headers。本週:開始變嚴
學 CSP source list、nonce、hash、strict-dynamic、frame-ancestors、Report-Only。
完成標準:你會修 violation,而不是加*。之後:工程化
把安全標頭納入 reverse proxy、CI、staging、Observatory / ZAP 與 production checklist。
完成標準:部署流程會自動發現 header regression。file:// 打開,只能閱讀與使用頁面內互動。真正測 HTTP response headers,必須透過 HTTP server。1. 三個標頭的心智模型
| 防線 | 一句話 | 主要用途 | 最容易搞錯的地方 |
|---|---|---|---|
Content-Security-Policy | 這頁「能載入 / 執行什麼」? | 降低 XSS 影響面、限制資源來源、限制 framing 等 | CSP 是 defense-in-depth,不會代替輸入驗證與輸出編碼。 |
X-Frame-Options: DENY | 別人能把我塞進 iframe 嗎? | 防 Clickjacking | 現代瀏覽器以 CSP frame-ancestors 為主;XFO 可作相容性層。 |
X-Content-Type-Options: nosniff | 照我宣告的 MIME type,不要自己猜。 | 抑制 MIME confusion / content sniffing | 它不會修正錯的 Content-Type;你仍要送對 MIME。 |
2. 第一個 Lab:10 分鐘看到 CSP 真正擋東西
建立 server.mjs
import http from "node:http";
const html = `<!doctype html>
<meta charset="utf-8">
<h1>Security Headers Lab</h1>
<button id="btn">Click me</button>
<script>
document.querySelector("#btn").addEventListener("click", () => {
alert("inline script executed");
});
</script>`;
http.createServer((req, res) => {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
);
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-Content-Type-Options", "nosniff");
res.end(html);
}).listen(3000, () => {
console.log("Open http://127.0.0.1:3000");
});啟動
node server.mjs瀏覽器開 http://127.0.0.1:3000。
觀察 CSP violation
按按鈕不會跳 alert。因為 script-src 'self' 沒允許 inline script。開 DevTools → Console,應看到類似「Refused to execute inline script」的 CSP 訊息。
確認 response header
curl -I http://127.0.0.1:3000Windows PowerShell 如果 curl alias 行為不同,可用:
curl.exe -I http://127.0.0.1:30003. CSP:白名單不是重點,「縮小信任」才是重點
3.1 一條容易理解的 baseline
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
default-src 'self':其他未特別指定的 fetch 類資源,預設只准本站。script-src 'self':script 只准同源;inline script 預設被擋。style-src 'self':CSS 只准同源。img-src 'self' data::圖片允許同源與 data URL。connect-src 'self':限制 fetch/XHR/WebSocket/EventSource 可連線的來源。object-src 'none':禁用 object/embed 等高風險舊式內容。base-uri 'self':限制<base>。frame-ancestors 'none':任何父頁都不能 frame 你。form-action 'self':表單只能送回本站。
3.2 'self' 是同一個 origin,不是「同公司網域」
https://example.com
https://cdn.example.com
http://example.com
https://example.com:8443
scheme、host、port 任一不同,都可能是不同 origin。因此 CDN、API、WebSocket 常需要另外列出。
3.3 不要用 'unsafe-inline' 當萬用修復
script-src 'self' 'unsafe-inline' *這通常會大幅降低 CSP 對 XSS 的價值。正確方向是:移除 inline handler、把 JS 移到外部檔、或導入 nonce/hash。
3.4 Nonce:動態頁面最重要的技巧之一
每個 response 由伺服器產生新的不可預測 nonce,header 與合法 script tag 必須用同一個值:
Content-Security-Policy:
script-src 'nonce-RANDOM_PER_RESPONSE';
object-src 'none';
base-uri 'none';
<script nonce="RANDOM_PER_RESPONSE">
console.log("trusted script");
</script>
import crypto from "node:crypto";
const nonce = crypto.randomBytes(16).toString("base64");
res.setHeader(
"Content-Security-Policy",
`script-src 'nonce-${nonce}'; object-src 'none'; base-uri 'none'`
);
abc123。nonce 要不可預測,而且應每個 response 更新。3.5 Hash:適合內容固定的 inline script
printf 'console.log("hello")' | openssl dgst -sha256 -binary | openssl base64
Content-Security-Policy:
script-src 'sha256-<BASE64_HASH>';
script 內容連空白或換行變化都可能造成 hash 不匹配。靜態站很好用;頻繁變動的 script 維護成本較高。
3.6 strict-dynamic
Content-Security-Policy:
script-src 'nonce-<RANDOM>' 'strict-dynamic';
object-src 'none';
base-uri 'none';
它把「由 nonce/hash 信任的 script」所載入之後續 script 也納入信任鏈。適合某些動態 loader;但如果被信任的 script 本身有 DOM XSS 或不安全載入邏輯,CSP 仍不是魔法。
3.7 frame-ancestors 不會 fallback 到 default-src
即使你有 default-src 'none',仍應明確設定 frame-ancestors。它是獨立的重要控制。
3.8 Report-Only:大型舊站的安全遷移方式
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self';
object-src 'none';
Report-Only 用來觀察違規而不真正阻擋。典型流程:Report-Only → 蒐集 violation → 修合法依賴 → enforcement。
3.9 常用 directive 地圖
| Directive | 控制 | 你會在哪裡踩坑 |
|---|---|---|
script-src | JavaScript | inline script、第三方 analytics、bundler runtime |
style-src | CSS | inline style、CSS-in-JS、第三方字型 CSS |
img-src | 圖片 | CDN、data URL、blob URL |
connect-src | fetch/XHR/WS | API、WebSocket、analytics beacon |
font-src | Web font | Google Fonts / CDN |
frame-src | 你的頁面能載入哪些 frame | YouTube、payment widget |
frame-ancestors | 誰能 frame 你的頁面 | portal / embedded app |
object-src | object/embed | 通常直接 none |
base-uri | base element | 通常 self 或 none |
form-action | form 送去哪裡 | SSO / payment form |
3.10 互動:產生第一條 starter CSP
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; form-action 'self';
這是教學用 baseline,不是所有網站都能原封不動貼上 production。
4. X-Frame-Options:把 Clickjacking 變成你看得見的問題
Clickjacking 的核心:攻擊者把你的敏感頁面放在透明或偽裝 iframe 裡,讓使用者以為自己在點別的 UI,實際點到你的「授權 / 刪除 / 轉帳」等操作。
完全禁止
X-Frame-Options: DENY如果網站根本不需被嵌入,這是簡單清楚的預設。
只准同源
X-Frame-Options: SAMEORIGIN同 origin 頁面仍可嵌入。
4.1 現代做法:frame-ancestors
Content-Security-Policy: frame-ancestors 'none';
只允許自己與指定 portal:
Content-Security-Policy:
frame-ancestors 'self' https://portal.example.com;
<meta http-equiv="X-Frame-Options" content="DENY">X-Frame-Options 要從 HTTP response header 下發;meta 不會提供你以為的防護。
4.2 frame-src vs frame-ancestors
frame-src | 我的頁面「可以 iframe 哪裡」? |
|---|---|
frame-ancestors | 「誰可以 iframe 我」? |
4.3 本機 clickjacking Lab
<!doctype html>
<h1>Attacker page</h1>
<iframe src="http://127.0.0.1:3000"
style="width:900px;height:500px"></iframe>
把 attacker page 放在另一個 origin,例如 python -m http.server 8000。依序測:沒有保護 → XFO DENY → CSP frame-ancestors none。
5. X-Content-Type-Options: nosniff:瀏覽器不要幫你猜
X-Content-Type-Options: nosniff
5.1 MIME type
Content-Type: text/html; charset=utf-8
Content-Type: text/css
Content-Type: application/javascript
Content-Type: application/json
Content-Type: image/png
nosniff 要求瀏覽器尊重伺服器宣告的 MIME type。對 script/style,錯誤 MIME 會被阻擋;在其他情境也能避免內容被重新猜成可執行型別。
nosniff 不會把 text/plain 自動修成 JavaScript。它會讓錯誤更明確地失敗,逼你修 server。5.2 故意做錯 MIME
if (req.url === "/wrong.js") {
res.setHeader("Content-Type", "text/plain");
res.setHeader("X-Content-Type-Options", "nosniff");
res.end('console.log("should be blocked as script")');
return;
}
HTML 載入:
<script src="/wrong.js"></script>
再修成:
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
6. Nginx / Apache / Express / Next.js / Vercel
6.1 Nginx
server {
listen 443 ssl;
server_name example.com;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
# ...
}
6.2 Apache
<IfModule mod_headers.c>
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
</IfModule>
6.3 Express:先手動理解
import express from "express";
const app = express();
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
);
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-Content-Type-Options", "nosniff");
next();
});
app.use(express.static("public"));
app.listen(3000);
6.4 Express:Helmet baseline
import express from "express";
import helmet from "helmet";
const app = express();
app.use(helmet());
app.listen(3000);
Helmet 很適合做 baseline,但 CSP 仍必須按你的資源來源、前端框架、API、WebSocket 和第三方 script 調整。
6.5 Next.js:固定 header
// next.config.mjs
const csp = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data: https:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
].join("; ");
export default {
async headers() {
return [{
source: "/:path*",
headers: [
{ key: "Content-Security-Policy", value: csp },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
],
}];
},
};
6.6 Vercel:vercel.json
{
"headers": [{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
},
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" }
]
}]
}
7. 驗證與除錯:這才是工程實務
7.1 DevTools → Network
- Reload。
- 點 Document request。
- 看 Response Headers。
- 確認三個 header 真正存在。
- 再檢查 JS/CSS/image 的 status 與 Content-Type。
7.2 DevTools → Console
CSP violation 通常直接告訴你:哪個 directive 阻擋、哪個 URL / inline code 被擋、nonce/hash 等提示。
* / https: → 好了」。每新增一個來源,都應問:這個來源真的需要嗎?若它被攻陷,能在我的頁面做什麼?7.3 curl
curl -I https://example.com
curl -sI https://example.com |
grep -iE 'content-security-policy|x-frame-options|x-content-type-options|content-type'
7.4 PowerShell
(Invoke-WebRequest https://example.com -Method Head).Headers
7.5 常見故障
| 症狀 | 原因 | 修法 |
|---|---|---|
| inline JS 全壞 | script-src 禁 inline | 外部 JS 或 nonce/hash。 |
| CDN / Fonts 壞 | origin 不在 policy | 只加入必要來源。 |
| fetch 被擋 | connect-src 太嚴 | 加入實際 API origin。 |
| WebSocket 被擋 | connect-src 未允許 WS endpoint | 加入正確 WS/WSS 來源。 |
| 合法 portal 不能 iframe | DENY / frame-ancestors none | 用 frame-ancestors 精確放行。 |
| JS MIME error | rewrite/404 把 JS 回成 HTML 或 text/plain | 先修 routing / static hosting 的 Content-Type。 |
| CSP 好像沒作用 | 設錯層、proxy 覆蓋、只看原始設定沒看最終 response | 以瀏覽器實收 header 為準。 |
8. 深耕:從「有 header」到 strict CSP
onclick= 等改成 addEventListener()。*、不必要的 scheme-wide allowlist。object-src 'none'、base-uri、frame-ancestors、form-action。8.1 Strict CSP 的典型形狀
Content-Security-Policy:
script-src 'nonce-<random-per-response>' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
這種思路比維護超長的 host allowlist 更接近「只允許我對這次 response 明確認證的 script」。
8.2 CSP 不是修 XSS 根因
element.innerHTML = userControlledInput;
這種資料流仍要修。應優先使用安全 DOM API、正確 output encoding,進一步再研究 Trusted Types。CSP 是額外防線,不是「有 CSP 就可以不修 XSS」。
8.3 下一批該學的 web security
Strict-Transport-Security(HSTS)- Cookie:
Secure/HttpOnly/SameSite Referrer-PolicyPermissions-Policy- SOP 與 CORS
- CSRF、XSS、DOM XSS、output encoding
- SRI 與第三方 script supply-chain risk
9. 練習:故意弄壞,再修好
練習 1:inline script → external script
先讓 script-src 'self' 擋 inline script,再把程式搬到 /app.js 並送正確 JavaScript MIME。
練習 2:MIME mismatch
讓 /app.js 故意以 text/plain 回傳,加 nosniff 觀察阻擋,再修成正確 MIME。
練習 3:Clickjacking attacker page
第二個 origin 用 iframe 包你的敏感頁。依序測無保護、XFO DENY、frame-ancestors none。
練習 4:connect-src
設定 connect-src 'self' 後 fetch 另一個 API;看它被擋,再只加入真正需要的 API origin。
練習 5:Report-Only → Enforcement
對你自己的小專案先部署 Report-Only,整理所有 violation,逐一修正,最後換正式 CSP。
10 秒小測驗
網站完全不需要被 iframe 嵌入,哪一組最好?
9.1 最終小專題
做三個 route:
/:外部 JS/CSS。/upload/<file>:模擬 user upload,正確 Content-Type + nosniff。/admin:禁止任何 framing。
- 寫 baseline CSP。
- 移除 inline event handler。
- Report-Only 遷移到 enforcement。
- 用 curl 寫可重複驗證命令。
- 故意造一個 CSP violation + MIME mismatch,再修掉。
10. Production Cheat Sheet
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
上線前逐項問
- script 實際來自哪些 origin?可以改 nonce/hash 嗎?
- 為什麼需要
'unsafe-inline'/'unsafe-eval'?可以拿掉嗎? connect-src是否只包含實際 API / WebSocket?- 網站真的需要被 iframe 嗎?
- JS/CSS/upload response 的 Content-Type 是否全部正確?
- 404 / 500 是否也帶 security headers?
- CDN / proxy 是否覆寫或重複 CSP?