在 Astro 與 Vercel 上手寫可自我驗證的 CSP — 官方的 security.csp 曾靜靜弄壞離線功能

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

黑色卡片上的白色 Astro 火箭標誌與字樣,火焰為粉紅色
本頁目錄

引言

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

Trend Micro Site Safety Center 對 oharu121.com 的重新分類結果,日期為 2026 年 9 月 2 日 11:29 AM UTC,顯示 New Safety Rating: Dangerous、New Content Type: Disease Vector

這份重新分類結果。這個網站先前的狀態是「Unrated」/「Untested」。

對一個沒有留言表單、沒有使用者上傳、也沒有任何第三方指令碼的靜態部落格來說,這兩個標籤都不代表字面上的意思。但我讀過,缺少安全性標頭有可能是某些掃描工具評分時考量的因素之一,而這個部落格當時什麼都沒有:沒有 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 not
compatible with Content Security Policy (CSP). Consider using Prism syntax
highlighting 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 Policy
directive '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 been
blocked.

被擋下來的是 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 的指令碼元素,所以這從一開始就不是問題,只是需要真實瀏覽器才能確認這一點。

Content-Security-Policy 標頭實際如何被強制執行由左至右的四個步驟:瀏覽器請求頁面;伺服器回應頁面內容與 Content-Security-Policy 標頭;接著瀏覽器在載入頁面上每個資源時,將其與該政策比對。結果分為兩種:允許的資源正常載入並執行;被封鎖的資源則被阻止,並改為在主控台記錄一筆違規紀錄。下方的說明指出,這個判斷發生在瀏覽器端,與建置工具事先是否正確雜湊無關。1瀏覽器請求頁面2伺服器回應:HTML 加上 Content-Security-Policy 標頭3瀏覽器載入每個資源時進行檢查允許正常載入並執行封鎖改為在主控台記錄違規這個判斷發生在瀏覽器端、載入當下,與建置工具事先是否正確雜湊無關。
Content-Security-Policy 標頭只約束頁面到達之後,瀏覽器要執行或算繪什麼;它完全不會告訴你,建置工具在此之前有沒有正確地雜湊好一切。

改為手寫政策

一個會在每一頁都悄悄弄壞離線閱讀的 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 規則,就是能用指令碼檢查的地方就不要依賴紀律。這裡可能出錯的地方有兩種,而且需要兩種不同的檢查。

兩種 CSP 違規,兩種對應的檢查兩張卡片並排顯示。左側卡片,CI,pnpm csp:check,偵測已知腳本雜湊值過期的情況:從建置後的 HTML 重新計算 4 個固定腳本的雜湊值,每次推送時執行,不需要瀏覽器。右側卡片,本機,pnpm csp:sweep,偵測網站上任何地方出現的新違規:以實際設定的標頭提供服務,並在真正的無頭 Chromium 中載入每個頁面,在推送涉及腳本、樣式或元件的變更前手動執行。CI — pnpm csp:check偵測對象已知腳本的雜湊值過期方法從建置後的 HTML 重新計算4 個固定腳本的雜湊值執行時機每次推送時執行,不需要瀏覽器本機 — pnpm csp:sweep偵測對象網站上任何地方出現的新違規方法以真實標頭提供服務,在真正的 Chromium 中載入每個頁面執行時機在推送變更前手動執行
過期的固定雜湊值和全新的違規,失敗的方式不一樣,所以各自需要一種檢查。

第一種失敗模式又窄又機械化:四個固定腳本裡的某一個變了,而改動它的人忘了重新產生雜湊值。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 its
MIME 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; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

文章頁面,以及這個靜態網站上唯一在伺服器端執行的那條路由 /api/likes/[slug],結果都一樣。

至於這整段繞路一開始要解決的那個問題,現在仍然懸而未決。我還沒有把網址重新提交給 Trend Micro,所以這一切是否會改變「Dangerous」/「Disease Vector」這個判定,目前無法確認。安全性標頭的缺失,有可能是自動掃描工具評分時考量的因素,這是個合理的猜測,但它終究只是猜測,不是 Trend Micro 公開發表過的診斷依據。 如果分類結果沒有因此改變,這些標頭本身仍然值得單獨導入;它們只是沒能成為當初那個問題的完整答案而已。

總結

這次走的路,並不是一開始的計畫。Astro 那個穩定、有官方文件的 security.csp,原本看起來就是靜態網站的正確長期解方。但要找出它會悄悄漏掉頂層 layout 以外的腳本、進而弄壞 Service Worker 註冊,需要的是一個真正的無頭瀏覽器,而不是框架自己的建置輸出。 最後上線的政策是手寫的,它強迫 Astro 的建置流程不要內嵌任何一個全站固定政策雜湊不到的東西,並且用兩種不同的方式,分別對應兩種可能出錯的方向:過期的固定雜湊值,交給便宜的 CI 指令碼;可能新出現的違規,交給本機的真實瀏覽器巡查。至於 Trend Micro 的「Dangerous」判定會不會改變這個最初的問題,目前仍然懸而未決。

參考連結

分享這篇文章