iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 25

Day 25 — 安全 header 上線版:CSP 從 meta 升級成 header

  • 分享至 

  • xImage
  •  

今天要解的問題

三筆從第一階段留下的安全債,今天一起結掉:

  1. Day 10frame-ancestors 放在 <meta> 裡無效(header-only 指令),每頁 console 噴 SEVERE,我當時只能把它移除。
  2. Day 23<meta>_headers 各有一份 CSP,兩份取交集,害我上線第一天公式沒渲染。
  3. 完全沒有 HSTS、X-Content-Type-OptionsReferrer-PolicyPermissions-Policy

現在有了能自訂 header 的平台(Day 21 選 Cloudflare Pages 的主因),可以做對。

原則:CSP 只能有一份

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

逐條說明

CSP

指令 為什麼
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 是嚴格的(真正的代碼執行完全被擋)。

寫進文件備查,並訂一個複查條件:如果將來加了表單或使用者輸入的持久化顯示,就要回來重新評估。有意識的妥協 + 明確的複查條件,比假裝沒有這個問題好。

HSTS:這是不可逆操作

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

⚠️ includeSubDomainspreload 都有嚴重的不可逆性。

  • 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 禁止其他站嵌入我的資源。兩者對一個不需要跨站互動的網站是零成本。

CSP violation 回報

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 違規回報。所以:

  • 不要對 CSP 回報設告警(會被雜訊淹沒)。
  • 每週看一次,找重複出現且來自我自己網域的違規——那才是真 bug。

我第一週就靠這個抓到一個真問題:某個課文裡有一段我忘記外部化的 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+
  • Google CSP Evaluator(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.comA+

三筆債結清:

  • Day 10 的 frame-ancestors → 現在是 header,有效。
  • Day 23 的雙份 CSP → 單一來源。
  • 缺失的四個 header → 補齊,另加 COOP/CORP。

小結與明天預告

今天的重點:

  1. CSP 只能有一份。 多個來源取交集,debug 時看不出來。移除 meta,只留 header。
  2. HSTS 是不可逆的——max-age 從 300 秒開始漸進,includeSubDomainspreload 確認清楚再加。
  3. 用不到的功能一律禁掉form-action 'none'object-src 'none'Permissions-Policy 全關):成本零的防禦。
  4. 有意識的妥協要寫下來並訂複查條件style-src 'unsafe-inline')。
  5. 改 CSP 先用 Report-Only 跑一週,不要拿使用者當測試。
  6. Pages 的 _headers 靜默失敗,所以 check-headers.sh 是唯一的確認手段。

明天做第二套部署:S3 + CloudFront + OAC,全部用 Terraform 描述,不點 console。重點是最小權限 IAM 與 invalidation 策略——這些是 Cloudflare Pages 幫你隱藏掉、但真正值得學的東西。


上一篇
Day 24 — 快取破壞:把手寫 `?v=` 換成內容哈希
系列文
知識圖譜 : 技能樹式學習歷程25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言