在 Astro 與 Vercel 上手寫可自我驗證的 CSP — 官方的 security.csp 曾靜靜弄壞離線功能
Astro 官方的 security.csp 可能悄悄弄壞 Service Worker 註冊。一份手寫、由 CI 與真實瀏覽器共同驗證的 CSP,在 Astro 與 Vercel 上的實作紀錄。

本頁目錄
引言
我把這個部落格的網址提交給 Trend Micro 的 Site Safety Center,原本期待拿到一張乾淨的健康證明。結果在 2026 年 9 月 2 日收到的,是一份把網站重新分類為 Dangerous、內容類型標示為 Disease Vector 的結果。

對一個沒有留言表單、沒有使用者上傳、也沒有任何第三方指令碼的靜態部落格來說,這兩個標籤都不代表字面上的意思。但我讀過,缺少安全性標頭有可能是某些掃描工具評分時考量的因素之一,而這個部落格當時什麼都沒有:沒有 Content-Security-Policy,沒有 HSTS,什麼都沒有。無論這是不是這次結果的真正原因,把這件事補起來都是值得的。
我要求的是一套經過仔細研究、對照當下最佳實務、而不是照搬範本的實作。最後得到的確實是一個能運作的 Content-Security-Policy 加上標準的安全強化標頭,但走的路徑跟我們兩人一開始預期的完全不同:Astro 官方的 CSP 功能本身就有一個真正的臭蟲,如果不是真實瀏覽器先抓到,這個臭蟲原本會讓每一位讀者收到一個壞掉的 Service Worker。這篇文章談的就是這段繞路,以及它留下的兩層驗證機制。至於這樣做是否真的能讓 Trend Micro 的掃描工具滿意,目前仍是未解的問題。我還沒有重新提交那個網址。
最直覺的做法:Astro 官方的 CSP 功能
這個網站是完全靜態的 Astro 部落格,架在 Vercel 上:每一頁都預先算繪,沒有任何第三方指令碼、沒有 CDN 字型,除了自己的來源之外什麼都不載入。理論上,這應該是網路上最容易用嚴格政策鎖起來的網站之一。
Astro 從遠早於這個網站所使用版本的 Astro 6.0 開始,就已經提供穩定、非實驗性的 CSP 支援。啟用 security.csp 之後,Astro 會在建置時為它處理過的每一個指令碼與樣式產生雜湊,接著要嘛注入一個 <meta> 標籤,要嘛在搭配 Vercel adapter 的 staticHeaders: true 選項時,為每一頁寫出真正的 Content-Security-Policy 標頭。這正是靜態網站想要的:不需要手動維護雜湊清單,而且即使是 JSON-LD 這種每頁內容都不同的東西,也能正確地逐頁雜湊。
打造這個網站的 agent 一開始就試了這個方法,結果第一次建置就沒有通過。
Astro found issue(s) with your configuration:
! security.csp: Did not match union. > Expected type boolean | Directives script-src and style-src (including their -elem/-attr variants) are not allowed in security.csp.directives. Please use security.csp.scriptDirective and security.csp.styleDirective instead, scoping resources/hashes to the more specific directives with the kind option ("element" or "attribute").修法就寫在錯誤訊息裡:不是在頂層的 directives 陣列裡放一個純字串,而是要在 styleDirective.resources 裡加一筆帶有 kind: "attribute" 的項目。建置能通過了,但出現了一個持續存在的新警告。
[WARN] [config] Shiki syntax highlighting uses inline styles that are notcompatible with Content Security Policy (CSP). Consider using Prism syntaxhighlighting instead, or disable CSP if Shiki is required.這個部落格的程式碼區塊是用 Shiki 同時算繪亮色與暗色兩種主題,這代表每一個被語法標亮的 token,都會帶著自己的 style="--0:#24292e;--1:#e1e4e8" 這類屬性,存放兩組顏色值。這是一個真實、無法避免的機制,而不是設定錯誤:Shiki 的雙主題輸出必須把顏色放在某個地方,而在元素上放 CSS 自訂屬性,正是它避免把整個程式碼區塊算繪兩次的方式。這個網站建置出來的 HTML 檔案裡,有 125 個至少帶了一次這種屬性。修法很窄也很刻意:只把 'unsafe-inline' 限定在 style-src-attr 上,完全不影響真正 <style> 元素的雜湊驗證。
瀏覽器實際看到的情況
CSP 標頭只有在瀏覽器真的強制執行時才有意義,而在本機並沒有簡單的方法可以確認這件事。pnpm preview 用一個單純的靜態檔案伺服器提供已建置的網站,完全不附加任何標頭;而在啟用 adapter 的 staticHeaders: true 選項之後,Astro 連 <meta> 標籤都不再輸出,於是筆電上就沒有任何東西可以用來測試了。Agent 寫了一個小工具:一個本機 HTTP 伺服器,讀取 Astro 實際產生的標頭,並把已建置的網站連同這些標頭一起提供服務,接著再用無頭瀏覽器巡過幾個具代表性的頁面,檢查主控台裡是否出現任何提到 Content-Security-Policy 的訊息。
第一次執行,每一頁都出現了違規。
Executing inline script violates the following Content Security Policydirective 'script-src ...'. Either the 'unsafe-inline' keyword, a hash('sha256-CUF1UsP1KF+rujto8WMYHvUjA3RVvt9YEmoxguvdzDs='), or a nonce('nonce-...') is required to enable inline execution. The action has beenblocked.被擋下來的是 Service Worker 的註冊指令碼。 這個網站的每一頁都會註冊一個 Service Worker 來支援離線閱讀,但 Astro 的雜湊機制從一開始就沒有涵蓋到它。把實際不吻合的雜湊值,拿去比對真正算繪出來的 <script> 內容的 sha256,一路追回原始碼,就浮現出一個模式:直接寫在網站頂層 layout 裡的指令碼會被正確雜湊,但寫在 layout 匯入的子元件裡的同類 is:inline 指令碼卻不會。搜尋捷徑用的指令碼和離線頁面用的指令碼,都出現了一模一樣的問題。這不是快取造成的假象:agent 把所有建置快取(node_modules/.astro、dist、.vercel/output)全部清空後從零重跑一次,結果那些指令碼依然沒有被雜湊。
部分 <style> 區塊也有同樣的問題,而且模式更難預測。在某一個文章頁面上,有兩個元件樣式沒有被雜湊,而就在同一頁裡緊挨著的第三個樣式卻正確地被雜湊了。三者都是同一種帶作用域的元件樣式,從差異上完全看不出為什麼一個被涵蓋,另外兩個沒有。
只有一個疑問,朝著相反的方向乾淨地解開了。 JSON-LD 區塊(<script type="application/ld+json">)在每一頁的內容(標題、日期、描述)都不同,而這正是 Astro 自家雜湊機制一開始看起來最有吸引力的原因。但無論內容是什麼,它們在任何一頁上都從來沒有觸發過違規。script-src 原來根本不會限制 type 屬性不是 JavaScript MIME type 的指令碼元素,所以這從一開始就不是問題,只是需要真實瀏覽器才能確認這一點。
改為手寫政策
一個會在每一頁都悄悄弄壞離線閱讀的 CSP,比完全沒有 CSP 還糟。 於是 agent 建議放棄 Astro 的自動雜湊機制,改在 vercel.json 裡直接手寫政策。我是在這個失敗被實際證明之後才核准這次轉向,而不是事前就同意。整個網站的 Service Worker 壞掉,不是「不用手動維護雜湊清單」這點方便性值得承擔的風險。
手寫政策解決了可靠性的問題,卻重新打開了 Astro 雜湊機制原本要解決的那個問題:這個網站上確實有一小撮腳本是固定不變的(主題切換用的腳本、搜尋捷徑、離線頁面,以及 Service Worker 註冊本身),這些可以雜湊一次就固定下來。但 Astro 預設的建置流程,還是會在頁面別的樣式表和打包後的小型 <script type="module"> 區塊夠小時,把它們直接內嵌進 HTML 裡,而哪些區塊會被內嵌,則隨每一頁使用的元件而異。單一個涵蓋全站的政策,追不上一個逐頁變動的值。所以在 astro.config.mjs 裡設定 build.inlineStylesheets: 'never' 與 vite.build.assetsInlineLimit: 0,強制把這些東西全部改成外部檔案。之後 style-src 'self' 與 script-src 'self' 就能統一涵蓋這部分,不再需要手動雜湊任何東西。
原本的計畫還假設,網站上那 7 個內嵌的 style="" 屬性可以直接重構成 CSS class,完全不需要任何 'unsafe-inline' 就能補上 CSP 的缺口。這個假設在真正重新清點之後就站不住腳了。 對 style={ 下 grep,又多找出 9 筆:好幾個文章裡的圖表元件,是在迴圈裡依每個資料點的公式,個別設定 SVG 圖形的透明度。一個連續計算出來的數值,沒有一小撮 CSS class 可以對應。所以 style-src-attr 'unsafe-inline' 仍然留在手寫的政策裡,只是這一次是刻意、範圍明確的決定,而不是意外發現的漏洞。
我在這個網域上沒有,也不打算,運行其他任何服務。正是這一點,讓這個決定變得很直接。完整的權衡如下:
沒有 preload | 有 preload | |
|---|---|---|
| HTTPS 何時開始被強制 | 從訪客第一次以 HTTPS 造訪開始 | 從任何瀏覽器發出的第一個請求開始,甚至早於第一次造訪之前 |
includeSubDomains 綁定的範圍 | 目前這個網域,往後適用 | 永遠涵蓋每一個子網域,直到移除生效為止 |
| 如何撤銷 | 變更或移除這個標頭 | 申請移除,再等 6 到 12 週以上讓它從大多數瀏覽器清除 |
避免政策過期
一份手寫的雜湊清單,能有多可靠,取決於維持它最新的紀律有多好,而這個專案自己的 CLAUDE.md 規則,就是能用指令碼檢查的地方就不要依賴紀律。這裡可能出錯的地方有兩種,而且需要兩種不同的檢查。
第一種失敗模式又窄又機械化:四個固定腳本裡的某一個變了,而改動它的人忘了重新產生雜湊值。scripts/compute-csp-hashes.ts 讀取的是實際建置出來的 HTML(而不是原始碼,因為雜湊值必須逐位元組對應 Astro 算繪出來的結果),它要嘛把目前的雜湊集合寫進 vercel.json(pnpm csp:build),要嘛檢查現有的是否已經正確,不正確就以非零狀態結束(pnpm csp:check)。這項檢查現在已經接在建置步驟之後於 CI 中執行,因為它需要拿建置後的 HTML 來比對。
第二種失敗模式,是任何固定雜湊清單都抓不到的:未來某個從沒讀過這篇文章的人,加入一個帶有自己的內嵌腳本或樣式的新 MDX 元件。只有真實瀏覽器,對著真實頁面強制執行真實政策,才能找出這種問題,這正是 scripts/check-csp-sweep.ts 在做的事:它會用直接從 vercel.json 讀出來的真實標頭來提供已建置的網站(這樣一來,它永遠不會拿一個已經和實際上線內容脫節的政策來測試),然後用無頭 Chromium 載入它擁有的每一種語言、每一篇已發佈的文章,還有各個工具頁面。pnpm csp:sweep 用這個方式檢查整個網站;pnpm csp:sweep <slug> 則只檢查一篇文章。它沒有接進 CI 裡。這個 repo 裡已經有一個完全一樣的先例,也就是檢查圖表是否溢出邊框的那個 Playwright 檢查,理由完全可以照搬過來:沒有任何一個 workflow 會安裝 Chromium 執行檔,而這項檢查只有在有人動到腳本、樣式或元件時才有意義,為此付出那個成本並不划算。
就連這支巡查用的指令碼,在寫出來之後也還修了一次。它第一次執行時,回報了一個本機根本不存在的 Vercel Analytics 端點的違規。
Refused to execute script from '.../_vercel/insights/script.js' because itsMIME type ('') is not executable, and strict MIME type checking is enabled.這讀起來像是 CSP 違規,但其實不是。 X-Content-Type-Options: nosniff 擋下的,是一個沒有 Content-Type 標頭的 404 回應被當成指令碼執行。這同樣是真實瀏覽器的保護機制,只是不是原本要抓的那一個。主控台訊息的過濾條件當時是廣泛比對「refused to」,而不是明確要求「Content Security Policy」這幾個字,所以兩種情況都被抓進來了。把過濾條件收窄,並讓本機伺服器的 404 回應帶上正確的 Content-Type,就讓這個誤判消失了,完全不用動到真正的檢查邏輯。
驗證:目前上線的內容,以及尚未確認的部分
對整個網站執行 pnpm csp:sweep,結果是檢查了 158 頁,零違規。pnpm check 與 pnpm build 也都乾淨通過,而 Service Worker 預先快取的核心內容,從 326KB 成長到約 445KB(以前被內嵌的 CSS 和 JS 現在全部變成外部檔案,因此也一併納入了預先快取),仍然在自訂的 500KB 預算之內。
部署後的第一次實際檢查,本身就是一場誤報。合併完成後立刻執行 curl -sI https://oharu121.com/,回應完全沒有 Content-Security-Policy 標頭,還帶著 Age: 333。這其實是一次發生在正式部署真正完成之前的 CDN 快取命中。 等部署工作完成,再用一個避開快取的網址重新請求,才看到真正的結果。
Content-Security-Policy: default-src 'self'; img-src 'self' data:; font-src'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors'none'; connect-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline';script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' ...Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Content-Type-Options: nosniffX-Frame-Options: DENY文章頁面,以及這個靜態網站上唯一在伺服器端執行的那條路由 /api/likes/[slug],結果都一樣。
至於這整段繞路一開始要解決的那個問題,現在仍然懸而未決。我還沒有把網址重新提交給 Trend Micro,所以這一切是否會改變「Dangerous」/「Disease Vector」這個判定,目前無法確認。安全性標頭的缺失,有可能是自動掃描工具評分時考量的因素,這是個合理的猜測,但它終究只是猜測,不是 Trend Micro 公開發表過的診斷依據。 如果分類結果沒有因此改變,這些標頭本身仍然值得單獨導入;它們只是沒能成為當初那個問題的完整答案而已。
總結
這次走的路,並不是一開始的計畫。Astro 那個穩定、有官方文件的 security.csp,原本看起來就是靜態網站的正確長期解方。但要找出它會悄悄漏掉頂層 layout 以外的腳本、進而弄壞 Service Worker 註冊,需要的是一個真正的無頭瀏覽器,而不是框架自己的建置輸出。 最後上線的政策是手寫的,它強迫 Astro 的建置流程不要內嵌任何一個全站固定政策雜湊不到的東西,並且用兩種不同的方式,分別對應兩種可能出錯的方向:過期的固定雜湊值,交給便宜的 CI 指令碼;可能新出現的違規,交給本機的真實瀏覽器巡查。至於 Trend Micro 的「Dangerous」判定會不會改變這個最初的問題,目前仍然懸而未決。
參考連結
- MDN: Content-Security-Policy
- Astro docs: Content Security Policy configuration reference
- withastro/astro issue #13996: experimentalStaticHeaders not working on Vercel
- withastro/astro issue #14301: Experimental CSP not working with Astro image components
- HSTS Preload List submission and removal, hstspreload.org



