三筆從第一階段留下的安全債,今天一起結掉:
frame-ancestors 放在 <meta> 裡無效(header-only 指令),每頁 console 噴 SEVERE,我當時只能把它移除。<meta> 與 _headers 各有一份 CSP,兩份取交集,害我上線第一天公式沒渲染。X-Content-Type-Options、Referrer-Policy、Permissions-Policy。現在有了能自訂 header 的平台(Day 21 選 Cloudflare Pages 的主因),可以做對。
Day 23 的事故根源是兩個真相來源。這跟 Day 3 的「單一事實來源」是同一條原則,只是換到了 HTTP 層。
決定:移除全部 <meta> CSP,只留 HTTP header。
# 三個(現在是七個)HTML 檔的 meta CSP 全部刪掉
grep -l 'http-equiv="Content-Security-Policy"' ./*.html
# 手動刪除那一行
grep -c 'Content-Security-Policy' ./*.html # 全部應為 0
代價:file:// 直開時完全沒有 CSP 保護。我接受——file:// 只是開發便利(Day 16 之後 CSP 是 script-src 'self',本來就已經擋掉 file:// 的本地 js 了),而正式站的保護不該被開發便利稀釋。
順手也解決了 Day 10 的另一個症狀:meta CSP 不存在了,frame-ancestors 的 SEVERE 警告自然消失。
_headers 完整版# ══════════════════════════════════════════════
# 全站安全 header
# ══════════════════════════════════════════════
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests; report-uri /csp-report
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
X-Frame-Options: DENY
# ══════════════════════════════════════════════
# 快取(Day 24)
# ══════════════════════════════════════════════
/js/*
Cache-Control: public, max-age=31536000, immutable
/css/*
Cache-Control: public, max-age=31536000, immutable
/vendor/*
Cache-Control: public, max-age=31536000, immutable
/icons/*
Cache-Control: public, max-age=31536000, immutable
/
Cache-Control: public, max-age=0, must-revalidate
/*.html
Cache-Control: public, max-age=0, must-revalidate
/sw.js
Cache-Control: no-cache, no-store, must-revalidate
# ══════════════════════════════════════════════
# preview 環境不得被索引(Day 23)
# ══════════════════════════════════════════════
https://:project.pages.dev/*
X-Robots-Tag: noindex, nofollow
| 指令 | 值 | 為什麼 |
|---|---|---|
default-src |
'self' |
兜底 |
script-src |
'self' |
Day 16 自架 MathJax 之後不需要任何 CDN |
style-src |
'self' 'unsafe-inline' |
⚠️ 唯一的妥協,見下 |
font-src |
'self' |
Day 16 自架字型 |
connect-src |
'self' |
目前沒有任何 XHR/fetch。Day 27 加雲端同步時要放寬到 API 網域 |
worker-src |
'self' |
Service Worker(Day 16) |
object-src |
'none' |
沒有 Flash/embed,一律禁 |
base-uri |
'self' |
防 <base> 標籤劫持所有相對路徑 |
form-action |
'none' |
網站沒有任何表單,全禁。攻擊者注入的表單無處可送 |
frame-ancestors |
'none' |
← Day 10 的債,終於能設 |
upgrade-insecure-requests |
— | 任何漏寫的 http:// 自動升級 |
report-uri |
/csp-report |
違規回報,見下面 |
form-action 'none' 值得特別說:這網站沒有表單(進度存 localStorage),所以可以全禁。萬一有 XSS 注入了 <form action="https://evil.com">,資料也送不出去。「用不到的功能一律禁掉」是最便宜的防禦——這條指令的成本是零。
frame-ancestors 'none' 加上 X-Frame-Options: DENY 是故意重複:前者是現代標準,後者是給老瀏覽器的 fallback。兩者衝突時 frame-ancestors 優先。
style-src 'unsafe-inline':唯一的妥協Day 16 說過原因:View 大量使用 style="width:${pct}%" 這種行內樣式(進度條、類別色、SVG 節點)。
我重新評估過三個選項:
| 方案 | 成本 | 結果 |
|---|---|---|
全部改成 element.style.setProperty() |
六個 View 從「回傳 HTML 字串」改成「操作 DOM」,架構級改動 | 可拿掉 unsafe-inline |
| 用 nonce | 靜態託管無法產生 per-request nonce | 不可行 |
保留 unsafe-inline |
0 | 風險有限 |
判斷依據:style-src 'unsafe-inline' 的實際攻擊面是「CSS 注入」——可以做視覺欺騙(假的登入框外觀)、有限的資料外洩(用 background-image: url(...) 配合屬性選擇器竊取表單值)。但我沒有表單、沒有敏感 DOM 屬性,而 script-src 是嚴格的(真正的代碼執行完全被擋)。
寫進文件備查,並訂一個複查條件:如果將來加了表單或使用者輸入的持久化顯示,就要回來重新評估。有意識的妥協 + 明確的複查條件,比假裝沒有這個問題好。
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
⚠️ includeSubDomains 與 preload 都有嚴重的不可逆性。
max-age=63072000(兩年):瀏覽器在這兩年內只會用 HTTPS 訪問你的網域。如果你的憑證出問題,使用者沒有「繼續前往」的選項——網站就是打不開。includeSubDomains:所有子網域都必須 HTTPS。如果你有一個 legacy.example.com 只支援 HTTP,它會立刻無法訪問。preload:提交到瀏覽器內建清單後,移除需要數個月,而且新使用者第一次訪問就強制 HTTPS。我的部署步驟是漸進的:
第 1 天:max-age=300(5 分鐘),觀察有無異常
第 2 天:max-age=86400(1 天)
第 3 天:max-age=63072000; includeSubDomains
第 7 天:確認所有子網域都是 HTTPS 之後,才加 preload 並提交 hstspreload.org
不要一開始就抄兩年 + preload。 這是我看過最多人踩的坑之一——設定當天沒事,兩週後架個測試子網域才發現全站被鎖進 HTTPS。
X-Content-Type-Options: nosniff
禁止 MIME sniffing。如果我的 .js 被錯設成 text/plain,瀏覽器不會「聰明地」把它當 script 執行。成本零,一律要加。
Referrer-Policy: strict-origin-when-cross-origin
跨站時只送 origin 不送完整路徑。為什麼重要:我的課文頁網址是 chapter.html?ch=ch09&l=2,那洩漏了「這個使用者在讀假設檢定」。同源導覽仍送完整 referrer(我自己的分析需要),跨站只送網域。
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()
明確關閉全部不需要的瀏覽器功能。interest-cohort=() 是拒絕 FLoC/Topics 類的興趣分群——這是隱私立場,也符合 Day 29 「不放第三方追蹤」的原則。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
COOP 切斷 window.opener 關聯(防 tabnabbing),CORP 禁止其他站嵌入我的資源。兩者對一個不需要跨站互動的網站是零成本。
report-uri /csp-report 需要一個接收端。純靜態站沒有後端——用 Cloudflare Pages Functions(一個檔案就是一個端點):
/* functions/csp-report.js */
export async function onRequestPost(context) {
try {
const body = await context.request.text();
/* 只記必要欄位,避免記錄過多資訊 */
const r = JSON.parse(body)["csp-report"] || {};
console.log(JSON.stringify({
t: "csp",
d: r["violated-directive"],
b: r["blocked-uri"],
doc: r["document-uri"],
ua: context.request.headers.get("user-agent")?.slice(0, 120),
}));
} catch { /* 格式不對就丟掉,不要因為回報而出錯 */ }
return new Response(null, { status: 204 });
}
回 204 而不是 200:沒有內容要回,而且避免瀏覽器對回報端點做任何處理。
console.log 的輸出會進 Cloudflare 的 Workers Logs,可以用 wrangler tail 即時看:
npx wrangler pages deployment tail --project-name=learnpath
實際上會收到什麼:大量雜訊。瀏覽器擴充套件注入的 script、廣告阻擋器改寫的樣式,都會產生 CSP 違規回報。所以:
我第一週就靠這個抓到一個真問題:某個課文裡有一段我忘記外部化的 onclick 屬性(Day 10 應該清乾淨的,漏了一處),它在 script-src 'self' 下被擋,但那段功能是可選的所以沒人察覺。
擴充 Day 23 的 check-headers.sh:
#!/bin/bash
# scripts/check-headers.sh <base-url>
set -u
BASE="${1:?用法: check-headers.sh https://example.com}"
fail=0
H=$(curl -sI "$BASE/index.html")
need() { # need <regex> <說明>
if echo "$H" | grep -iqE "$1"; then echo " ✓ $2"
else echo " ✗ 缺少或不符:$2"; fail=1; fi
}
deny() { # deny <regex> <說明>
if echo "$H" | grep -iqE "$1"; then echo " ✗ 不應出現:$2"; fail=1
else echo " ✓ $2"; fi
}
echo "== 安全 header:$BASE =="
need 'strict-transport-security:.*max-age=[0-9]{7,}' "HSTS(max-age ≥ 10^7)"
need 'x-content-type-options:\s*nosniff' "nosniff"
need 'referrer-policy:\s*strict-origin' "Referrer-Policy"
need 'permissions-policy:' "Permissions-Policy"
need 'content-security-policy:' "CSP 存在"
need 'content-security-policy:.*frame-ancestors' "frame-ancestors(header-only 指令)"
need 'content-security-policy:.*object-src .none.' "object-src 'none'"
need 'content-security-policy:.*base-uri' "base-uri"
need 'content-security-policy:.*form-action' "form-action"
need 'cross-origin-opener-policy:\s*same-origin' "COOP"
echo "== CSP 不得有危險值 =="
csp=$(echo "$H" | grep -i '^content-security-policy:' | tr -d '\r')
case "$csp" in
*"script-src"*"'unsafe-inline'"*) echo " ✗ script-src 含 unsafe-inline"; fail=1;;
*) echo " ✓ script-src 無 unsafe-inline";;
esac
case "$csp" in
*"script-src"*"'unsafe-eval'"*) echo " ✗ script-src 含 unsafe-eval"; fail=1;;
*) echo " ✓ script-src 無 unsafe-eval";;
esac
case "$csp" in
*"'self' *"*|*"script-src *"*) echo " ✗ CSP 含通用萬用字元"; fail=1;;
*) echo " ✓ CSP 無萬用字元";;
esac
echo "== 不該存在的 header =="
deny 'server:\s*(apache|nginx|iis)/' "未洩漏伺服器版本"
deny 'x-powered-by:' "無 X-Powered-By"
echo "== meta CSP 應已移除(單一來源) =="
if curl -s "$BASE/index.html" | grep -qi 'http-equiv="Content-Security-Policy"'; then
echo " ✗ HTML 仍有 meta CSP(會與 header 取交集)"; fail=1
else echo " ✓ 無 meta CSP"; fi
exit $fail
「不該存在的 header」那兩條:Server: nginx/1.18.0 這種版本洩漏會讓攻擊者直接查對應的 CVE。Cloudflare 預設不洩漏,但自己架的來源要檢查。
接進 Day 23 的部署後驗證:
- run: bash scripts/check-headers.sh https://learnpath.example.com
外部工具再驗一次(人工,每次改 CSP 時做):
securityheaders.com → 目標 A+
csp-evaluator.withgoogle.com)→ 目標「無高風險發現」hstspreload.org → 檢查 preload 資格(確認想清楚了再提交)Cloudflare Pages 的 _headers 語法極度挑剔。 這是 Day 21 提過的坑,實際規則是:
/* ← 路徑模式:行首無空白
Header-Name: value ← header:正好兩個空白縮排
用 tab、用四個空白、路徑行有前導空白 —— 全部靜默失敗(規則被忽略、部署成功、header 沒有)。
所以 check-headers.sh 不是可選的驗證,是唯一能知道設定生效了沒的方法。
CSP 的 report-uri 已被標為 deprecated,但 report-to 支援度還不齊。 現況(2026):Chrome 支援 report-to、Firefox 仍靠 report-uri。我兩個都寫:
Content-Security-Policy: …; report-uri /csp-report; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="/csp-report"
先用 Report-Only 模式試跑。 改 CSP 之前,先加一個只回報不阻擋的版本:
Content-Security-Policy-Report-Only: <新的更嚴格的 policy>; report-uri /csp-report
兩份可以並存:舊的 Content-Security-Policy 繼續執行、新的 -Report-Only 只回報。跑一週看回報,確認沒有誤擋才切換。這是修改 CSP 的正確流程——直接改上去等於拿使用者當測試。
upgrade-insecure-requests 不能取代修正程式碼。 它會把 http:// 自動升級,很方便,但那掩蓋了「我的程式裡有寫死的 http URL」這個真問題。我用它當安全網,同時保留一個 grep 檢查:
grep -rn 'http://' --include='*.html' --include='*.js' --include='*.css' . \
| grep -v 'localhost\|127.0.0.1\|http://www.w3.org' && exit 1 || true
(http://www.w3.org/2000/svg 是 SVG 命名空間,不是真的要連線,要排除。)
$ bash scripts/check-headers.sh https://learnpath.example.com
== 安全 header:https://learnpath.example.com ==
✓ HSTS(max-age ≥ 10^7)
✓ nosniff
✓ Referrer-Policy
✓ Permissions-Policy
✓ CSP 存在
✓ frame-ancestors(header-only 指令)
✓ object-src 'none'
✓ base-uri
✓ form-action
✓ COOP
== CSP 不得有危險值 ==
✓ script-src 無 unsafe-inline
✓ script-src 無 unsafe-eval
✓ CSP 無萬用字元
== 不該存在的 header ==
✓ 未洩漏伺服器版本
✓ 無 X-Powered-By
== meta CSP 應已移除(單一來源) ==
✓ 無 meta CSP
securityheaders.com:A+。
三筆債結清:
frame-ancestors → 現在是 header,有效。今天的重點:
max-age 從 300 秒開始漸進,includeSubDomains 與 preload 確認清楚再加。form-action 'none'、object-src 'none'、Permissions-Policy 全關):成本零的防禦。style-src 'unsafe-inline')。Report-Only 跑一週,不要拿使用者當測試。_headers 靜默失敗,所以 check-headers.sh 是唯一的確認手段。明天做第二套部署:S3 + CloudFront + OAC,全部用 Terraform 描述,不點 console。重點是最小權限 IAM 與 invalidation 策略——這些是 Cloudflare Pages 幫你隱藏掉、但真正值得學的東西。