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

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

- Source: https://oharu121.com/zh-tw/blog/astro-vercel-security-csp-service-worker-hash/
- Published: 2026-09-03T00:26:46+09:00
- Tags: Astro, Vercel, Service Worker, 安全

---
## 引言

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

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

*Figure — CspRequestFlow: 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'` 仍然留在手寫的政策裡，**只是這一次是刻意、範圍明確的決定，而不是意外發現的漏洞。**

> **話說**
>
> **HSTS 的 `preload` flag，是這整個專案裡唯一一個我親自決定、而不是交給 agent 的判斷。** 一旦提交，`includeSubDomains` 會綁定這個網域未來會擁有的每一個子網域，而要移除，需要 6 到 12 週才能在大多數瀏覽器上清除。

我在這個網域上沒有，也不打算，運行其他任何服務。正是這一點，讓這個決定變得很直接。完整的權衡如下：

| | 沒有 `preload` | 有 `preload` |
| --- | --- | --- |
| HTTPS 何時開始被強制 | 從訪客第一次以 HTTPS 造訪開始 | 從任何瀏覽器發出的第一個請求開始，甚至早於第一次造訪之前 |
| `includeSubDomains` 綁定的範圍 | 目前這個網域，往後適用 | 永遠涵蓋每一個子網域，直到移除生效為止 |
| 如何撤銷 | 變更或移除這個標頭 | 申請移除，再等 6 到 12 週以上讓它從大多數瀏覽器清除 |

## 避免政策過期

一份手寫的雜湊清單，能有多可靠，取決於維持它最新的紀律有多好，而這個專案自己的 CLAUDE.md 規則，就是能用指令碼檢查的地方就不要依賴紀律。**這裡可能出錯的地方有兩種，而且需要兩種不同的檢查。**

*Figure — TwoWayCspCheck: 過期的固定雜湊值和全新的違規，失敗的方式不一樣，所以各自需要一種檢查。*

**第一種失敗模式又窄又機械化**：四個固定腳本裡的某一個變了，而改動它的人忘了重新產生雜湊值。`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」判定會不會改變這個最初的問題，目前仍然懸而未決。

## 參考連結

- [MDN: Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP)
- [Astro docs: Content Security Policy configuration reference](https://docs.astro.build/en/reference/configuration-reference/)
- [withastro/astro issue #13996: experimentalStaticHeaders not working on Vercel](https://github.com/withastro/astro/issues/13996)
- [withastro/astro issue #14301: Experimental CSP not working with Astro image components](https://github.com/withastro/astro/issues/14301)
- [HSTS Preload List submission and removal, hstspreload.org](https://hstspreload.org/)
