WEB SECURITY · 實作優先

CSP、X-Frame-Options、nosniff
從新手到能上線

這份教材不是叫你背安全標頭,而是讓你先親手看到瀏覽器「阻擋了什麼」,再一路學到 CSP nonce/hash、Report-Only、clickjacking、MIME confusion、Nginx / Express / Next.js / Vercel 部署與 production 驗證。

單檔 HTML離線可讀實作優先附練習與除錯

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。
一句話記憶:CSP 管「我信任什麼」;frame-ancestors / XFO 管「誰能包我」;nosniff 管「瀏覽器不要亂猜你是什麼內容」。

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:3000

Windows PowerShell 如果 curl alias 行為不同,可用:

curl.exe -I http://127.0.0.1:3000
Checkpoint:你現在已經真的看過 CSP 在瀏覽器裡執行,而不只是讀定義。

3. 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'`
);
不要:把 nonce 固定寫成 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-srcJavaScriptinline script、第三方 analytics、bundler runtime
style-srcCSSinline style、CSS-in-JS、第三方字型 CSS
img-src圖片CDN、data URL、blob URL
connect-srcfetch/XHR/WSAPI、WebSocket、analytics beacon
font-srcWeb fontGoogle Fonts / CDN
frame-src你的頁面能載入哪些 frameYouTube、payment widget
frame-ancestors誰能 frame 你的頁面portal / embedded app
object-srcobject/embed通常直接 none
base-uribase element通常 self 或 none
form-actionform 送去哪裡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");
Checkpoint:正確目標是「Content-Type 本來就正確 + nosniff 不允許瀏覽器偷偷容忍錯誤」。

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" },
      ],
    }];
  },
};
如果你要真正的「每 response nonce」,不能只靠固定的靜態 header。nonce 必須在 request/response 流程動態產生,並注入對應合法 script。框架版本與渲染模式會影響實作方式。

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" }
    ]
  }]
}
反向代理注意:CDN、Cloudflare、Load Balancer、Nginx、應用程式可能同時改 header。你真正要驗證的是「使用者最後收到的 response」。

7. 驗證與除錯:這才是工程實務

7.1 DevTools → Network

  1. Reload。
  2. 點 Document request。
  3. 看 Response Headers。
  4. 確認三個 header 真正存在。
  5. 再檢查 JS/CSS/image 的 status 與 Content-Type。

7.2 DevTools → Console

CSP violation 通常直接告訴你:哪個 directive 阻擋、哪個 URL / inline code 被擋、nonce/hash 等提示。

不要這樣 debug:「被擋 → 加 * / 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 不能 iframeDENY / frame-ancestors none用 frame-ancestors 精確放行。
JS MIME errorrewrite/404 把 JS 回成 HTML 或 text/plain先修 routing / static hosting 的 Content-Type。
CSP 好像沒作用設錯層、proxy 覆蓋、只看原始設定沒看最終 response以瀏覽器實收 header 為準。

8. 深耕:從「有 header」到 strict CSP

Inventory:列出 script / style / image / font / API / WS / iframe / form destination。
Report-Only:先觀察,不先把 production 打爆。
移除 inline handler:onclick= 等改成 addEventListener()
導入 nonce/hash:動態頁面偏 nonce;固定靜態內容可用 hash。
收緊來源:避免 *、不必要的 scheme-wide allowlist。
鎖高價值 directive:object-src 'none'base-uriframe-ancestorsform-action
CI 驗證:對 staging / production URL 自動檢查 header regression。

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

  1. Strict-Transport-Security (HSTS)
  2. Cookie:Secure / HttpOnly / SameSite
  3. Referrer-Policy
  4. Permissions-Policy
  5. SOP 與 CORS
  6. CSRF、XSS、DOM XSS、output encoding
  7. SRI 與第三方 script supply-chain risk
不要混淆:CORS 解決「另一個 origin 能不能讀我的 response」;CSP 解決「我的頁面能不能載入 / 執行某些資源」。

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。
  1. 寫 baseline CSP。
  2. 移除 inline event handler。
  3. Report-Only 遷移到 enforcement。
  4. 用 curl 寫可重複驗證命令。
  5. 故意造一個 CSP violation + MIME mismatch,再修掉。
做完這個,你已經不是「只知道三個 header 名稱」。

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
這是 baseline,不是萬用 production policy。真正上線時應根據資源清單與 threat model 收斂。

上線前逐項問

  • 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?

11. 權威延伸閱讀