今天要解的問題
kiwi-walk 是我用 Hugo 架、部署在 Cloudflare Pages 的部落格。
做完一輪 網站檢測之後,資安那一塊最難看:Mozilla HTTP Observatory 給 D(30/100),securityheaders.com 也是 D,都指向同樣的缺口:沒有 HSTS、沒有 CSP、沒有 X-Frame-Options。

當時我最好奇的是:「若是我這篇發出去,是否會導致我網站被攻擊?」但評估下來,kiwi-walk 是純靜態網站,沒有登入、沒有資料庫、沒有 cookie,這些標頭防的攻擊幾乎沒有施力點,缺它們的實際風險很低。
技術背景
Cloudflare Pages 怎麼加回應標頭
Pages 讀網站根目錄的 _headers 檔:一行路徑規則,底下縮排的是要加的標頭(Cloudflare Pages: Headers)。Hugo 的 static/_headers 會原樣複製到輸出根目錄。
Pages 自己也會加幾個標頭。用一個分支的預覽網址實測,repo 裡沒設的三個都在:
$ curl -s -o /dev/null -D - https://claude-pagespeed-logo.kiwi-walk.pages.dev/
access-control-allow-origin: *
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-robots-tag: noindex
所以 Observatory 一開始就有幾項是過的(Referrer Policy、X-Content-Type-Options),扣分的是要自己加的那幾個。
CSP 為什麼卡在 inline script
Content-Security-Policy 列出頁面可以從哪些來源載入 script、樣式、圖片。它防 XSS 的關鍵在 script-src:只要沒寫 'unsafe-inline',頁面裡直接寫在 HTML 的 <script>…</script> 和 onclick= 一律被擋,注入進來的程式碼就跑不了。反過來,網站自己寫的 inline script 也會一起被擋。
kiwi-walk 的 build 結果裡有 18 段不同的 inline script:GA 的設定、漢堡選單、圖片放大、分類頁的配色、程式碼複製按鈕……。要讓 CSP 真的有用,這 18 段得先處理掉。
想法與取捨
先 Report-Only,再強制
CSP 寫錯,網站會直接壞:script 被擋,選單打不開、圖片不能放大。所以分兩步:先上 Content-Security-Policy-Report-Only,它什麼都不擋,只在瀏覽器 console 記下「如果強制的話會擋什麼」;確認沒有誤擋,再換成強制的 Content-Security-Policy。
上 Report-Only 之前,先盤點了全站 342 個 HTML 頁面實際載入的東西,每一項對應到 policy 的哪一條:
| 載入的東西 | 對應 |
|---|---|
GA gtag/js(www.googletagmanager.com) | script-src;回報資料的網域放 connect-src |
| Disqus(目前沒在頁面開留言) | script-src、style-src、frame-src |
| YouTube 嵌入(13 處) | frame-src |
| 文章圖片:cdn.kiwi-walk.com 2345 張,另有誠品 81、hackmd 31 等外連 | img-src 'self' data: https: |
style="…" 屬性,全站 1775 處 | style-src 保留 'unsafe-inline' |
script 嚴格,style 放寬
18 段 inline script 全部搬成 assets/js/ 裡的檔案,用 Hugo 的 minify | fingerprint 載入、帶 integrity。這樣 script-src 就不需要 'unsafe-inline',以後誰再寫一段 inline script,瀏覽器會直接擋下來,逼它也變成檔案。
樣式就不同了:全站有 1775 個 style="…" 屬性,很多是文章內文和模板產生的,一個一個搬不實際,所以 style-src 保留 'unsafe-inline'。代價是 Observatory 會在 CSP 那一項註明「style-src 有 unsafe 來源」,但不扣分。
其他標頭:留給嵌入內容的空間
- X-Frame-Options: SAMEORIGIN 加上 CSP 的
frame-ancestors 'self':只有這個網站自己能把頁面嵌進框架(站內的簡報頁會嵌自己的投影片)。Disqus 和 YouTube 是「嵌在我們頁面裡」的框架,不受這個限制。 - Permissions-Policy 關掉 11 項部落格用不到的功能(相機、麥克風、定位、付款、USB……),刻意不關 YouTube 嵌入會要的那幾項(autoplay、fullscreen、picture-in-picture 等)。
- Cross-Origin-Opener-Policy 用
same-origin-allow-popups,不用更嚴格的same-origin:Disqus 的登入是彈出視窗,要能跟原頁面溝通。
兩項決定不改
Access-Control-Allow-Origin: *:Pages 預設加的,securityheaders.com 說需要限制。但後來說全站都是公開內容,所以允許跨來源讀取不會洩漏任何東西;站內搜尋讀/index.json是同網域,也不依賴它。以後如果有子網域從這個 Pages 專案提供私人資料,再用! Access-Control-Allow-Origin拿掉。- Subresource Integrity(Observatory −5):唯一的外部 script 是 GA 的
gtag/js,Google 會原地更新它、沒有版本號,寫死雜湊值下次一改就壞。接受扣分;網站自己的 script 都已經帶integrity,CSP 也把script-src限定在 googletagmanager.com。
HSTS 先開 Always Use HTTPS,max-age 從一個月開始
HSTS 是 Cloudflare 後台的設定,不在 repo 裡。查的時候發現 http://cdn.kiwi-walk.com/… 回 200、沒有轉到 https,所以順序是先開 Always Use HTTPS,再開 HSTS:max-age 先設一個月,includeSubDomains 不勾、不申請 preload,平穩一兩週再加長。先設短的原因是:瀏覽器會在 max-age 期間內強制 HTTPS,伺服器端關掉也撤不回來,先用一個月,出問題最多影響一個月。includeSubDomains 要等確認每個 DNS 記錄都只走 HTTPS 才考慮。要撤回的話,把 max-age 設成 0。
實作
一個 /* 區塊放全部的標頭
# ...(說明註解略)
/*
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com https://sean22492249-gmail-com.disqus.com https://*.disquscdn.com https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://*.disquscdn.com; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://www.googletagmanager.com https://*.disqus.com https://cloudflareinsights.com; frame-src https://www.youtube.com https://www.youtube-nocookie.com https://disqus.com; media-src 'self' https://cdn.kiwi-walk.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'
X-Frame-Options: SAMEORIGIN
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), midi=(), magnetometer=(), display-capture=(), browsing-topics=()
Cross-Origin-Opener-Policy: same-origin-allow-popups
這是 static/_headers 現在的樣子。檔案裡的註解寫明這些標頭一定要放在同一個 /* 區塊,原因在「踩到的坑」。script-src 裡沒有 'unsafe-inline';frame-ancestors 也放在同一行 CSP 裡,和 X-Frame-Options 一起擋。
inline script 搬成檔案:以 GA 為例
Hugo 內建的 GA 模板會吐一段 inline 的 gtag('config', …)。把它複製到 layouts/partials/google_analytics.html 覆寫,設定的部分搬到檔案,追蹤碼改用 data- 屬性傳進去:
{{- if not site.Config.Privacy.GoogleAnalytics.Disable }}
{{- with site.Config.Services.GoogleAnalytics.ID }}
{{- $gtag := resources.Get "js/gtag.js" | minify | fingerprint }}
<script async src="https://www.googletagmanager.com/gtag/js?id={{ . }}"></script>
<script src="{{ $gtag.RelPermalink }}" integrity="{{ $gtag.Data.Integrity }}" crossorigin="anonymous"
data-id="{{ . }}" data-dnt="{{ site.Config.Privacy.GoogleAnalytics.RespectDoNotTrack }}"></script>
{{- end }}
{{- end -}}
(function () {
var cfg = document.currentScript.dataset;
var dnt = navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack;
if (cfg.dnt === 'true' && (dnt == '1' || dnt == 'yes')) return;
window.dataLayer = window.dataLayer || [];
window.gtag = function () { dataLayer.push(arguments); };
gtag('js', new Date());
gtag('config', cfg.id);
})();
document.currentScript.dataset 讀的是載入這支 script 的 <script> 標籤上的 data- 屬性,所以檔案本身不用寫死追蹤碼,模板也不用產生任何 inline 程式碼。其他 17 段都是同一個模式:程式碼進 assets/js/,原本要內插的值(分類的配色、按鈕文字)改成 data- 屬性。漢堡選單那支刻意不加 async、緊接在 </nav> 後面,避免選單先閃一下才收起來。
踩到的坑
這三個都有一個共同點:分支的預覽網址上一切正常,上了正式網域才壞。
兩個 /* 區塊,正式站只送出第二個
X-Frame-Options 那批標頭和 CSP Report-Only 是分開做的,各自在 _headers 裡寫了一個 /* 區塊。兩邊各自的預覽網址都檢查過,標頭都在。兩邊都上線之後,正式站只送出 Report-Only 的 CSP,第一個區塊的 X-Frame-Options、frame-ancestors、Permissions-Policy、COOP 全不見了。
預覽抓不到的原因很單純:每個分支的 _headers 都只有自己那一個 /* 區塊,同一個路徑有兩個區塊的情況,只會在兩邊都上線後才出現。原本檔案裡還有一行註解寫「同一個路徑的規則會合併」,正是這個錯誤的假設。修法是把所有標頭放進同一個 /* 區塊,並把那行註解改成現在這行警告。
Cloudflare Web Analytics 只在正式網域出現
CSP 強制之後,正式站每一頁都多了一個 console 錯誤:Cloudflare Web Analytics 的 beacon(https://static.cloudflareinsights.com/beacon.min.js/…)被 script-src 擋了。GA 和網站自己的 script 都沒事,但 Cloudflare 的瀏覽統計停了。
這支 beacon 是 Pages 自動注入的,而且只注入在自訂網域上,*.pages.dev 的預覽完全沒有它,所以盤點時、預覽時都看不到。修法是 script-src 加 https://static.cloudflareinsights.com、connect-src 加它回報資料的 https://cloudflareinsights.com,並在 _headers 寫註解說明為什麼預覽抓不到。
同時上線的新功能還帶著 inline script
CSP 強制上線的同時,系列頁的月曆功能也在另一邊開發,它自己帶了一段 inline <script>。兩邊各自都沒問題,一起上線後,正式站的月曆整個壞掉:每個月份疊在一起,點日期直接跳到文章,console 是:
Executing inline script violates … script-src
修法跟其他 18 段一樣:搬成 assets/js/series-calendar.js,用同樣的方式載入:
{{- $calJS := resources.Get "js/series-calendar.js" | minify | fingerprint }}
<script src="{{ $calJS.RelPermalink }}" integrity="{{ $calJS.Data.Integrity }}" crossorigin="anonymous"></script>
這次在本機 build 之後,帶著正式站的 Content-Security-Policy 標頭跑一次確認月曆能用、console 沒有錯誤,也確認 build 出來的 HTML 裡除了 JSON-LD 已經沒有 inline script。CSP 強制時,專案的 CLAUDE.md 就加了一條規則:不要寫 inline script,放進 assets/js/。
清單上的 HSTS 標成完成,但沒有生效
HSTS 那一項,寫好後台操作步驟之後就被當成完成了。隔天用 curl 檢查正式站:
$ curl -s -o /dev/null -D - https://kiwi-walk.com/ | grep -ic "^strict-transport-security"
0
一個都沒有。步驟寫好了,但後台的開關還沒打開,Observatory 也就停在 B(75 分):剩下的扣分是 HSTS −20 和接受的 SRI −5。
在 Cloudflare 後台打開 HSTS(max-age 一個月)之後再測:
$ curl -sD - -o /dev/null https://kiwi-walk.com/ | grep -i strict-transport
strict-transport-security: max-age=2592000
Observatory 升到 A−(85 分)。HSTS 從 −20 變成 −10,原因寫在結果裡:hsts-implemented-max-age-less-than-six-months,max-age 不到六個月。SSL Labs 也一樣,看得到 HSTS,但還是 A 不是 A+:
Strict Transport Security (HSTS) Yes TOO SHORT (less than 180 days) max-age=2592000
這是刻意先設短的代價。Cloudflare 後台的「6 months」是 max-age=15552000,剛好是 SSL Labs 要的 180 天;Observatory 的原始碼也把 15552000 當成六個月(註解寫著很多網站都用這個值),所以平穩一陣子把 max-age 拉到 6 個月,兩邊的分數都會回來。securityheaders.com 則直接從 D 變成 A+,六個標頭全綠:


小結
kiwi-walk 的安全標頭從 Observatory D(30 分、通過 8/12)升到 A−(85 分、通過 10/12),securityheaders.com 從 D 升到 A+:CSP 強制執行、全站沒有 inline script,X-Frame-Options、Permissions-Policy、COOP、HSTS 都在。剩下的扣分是刻意先設短的 HSTS max-age(SSL Labs 也因此停在 A),和刻意接受的 SRI。
這次最實際的一課是:Cloudflare Pages 的預覽網址和正式網域不是同一個環境。 正式網域會多注入 Web Analytics 的 beacon,而多個分支各自正確的 _headers 或 script,合在一起也可能出錯。所以標頭和 CSP 的改動,要在上線後對正式網域再測一次(curl 看標頭、瀏覽器 console 看有沒有被擋的資源),而不是只看預覽。
另一個是:「步驟寫好了」不等於「設定生效了」。後台開關這種不在 repo 裡的東西,要用 curl 對正式站看到結果,才算完成。
