用 Cloudflare Workers 幫 Vaultwarden 代理 Favicon,伺服器 IP 不外洩
自建 Vaultwarden 的人大概都沒細想過一件事:那些帳號旁邊漂亮的網站 icon 是怎麼來的。預設的 ICON_SERVICE=internal 其實是伺服器自己出去抓,你存了多少個網域,伺服器就對多少個網域發過一次請求,等於把伺服器的對外 IP 送給每一個存過密碼的網站。內建的替代方案 duckduckgo、google、bitwarden 不會洩漏伺服器 IP,但換成把使用者查詢的網域清單整批交給那些第三方。
自己也在跑 Vaultwarden,想了想這兩個選項都不滿意,於是寫了 vaultwarden-icons-worker:圖示代理自己架,架在 Cloudflare 的邊緣節點上,伺服器和客戶端的 IP 都不會被目標網站看到。
洩漏的路徑,和 Worker 怎麼擋
Vaultwarden 原本的流程很直接:伺服器看到一筆帳號要顯示 icon,就自己發 request 去對方網站抓 favicon。這裡有兩個資訊會外流:
- 伺服器的 egress IP,暴露給每一個存過密碼的網站
- 如果換成
duckduckgo/google/bitwarden,變成把整批網域查詢丟給第三方
我的做法是插入一層代理,而且刻意不是「重新導向」而是「代理抓取」:
Client (web vault / 瀏覽器套件 / App)
│ GET /icons/github.com/icon.png
▼
Vaultwarden ──302/307 redirect──▶ https://vaultwarden-icons.example.com/github.com
│ (伺服器本身完全不發出對外請求)
▼ client 跟著 redirect 走
Cloudflare Worker ──fetch──▶ github.com(解析 <link rel=icon> + /favicon.ico)
│ 回傳的是圖片的 bytes,不是第二次 redirect
▼ ▲ 目標網站只看得到 Cloudflare 的 IP
Client 顯示 icon
關鍵在最後一步:Worker 是把抓到的圖片內容直接回傳給 client,而不是再轉一次導向讓 client 自己去對方網站抓。這樣目標網站從頭到尾只看得到 Cloudflare 的 IP,伺服器和使用者的 IP 都沒有曝光。它不是 forward proxy(不能當 HTTPS_PROXY 用,Cloudflare Worker 做不到那種層級的轉發),單純是換掉 Vaultwarden 的 ICON_SERVICE 指向。
怎麼抓圖示
對 /<domain> 的請求,Worker 走四個步驟:
- 驗證 host(
src/ssrf.ts),擋掉 IP 字面量、localhost、單一標籤網域、.local/.internal這類保留 TLD,還有自訂的封鎖清單。Worker 是公開端點,每次都要重新驗證,不能假設呼叫端已經過濾過。 - 找候選圖示(
src/favicon.ts),抓目標網站的 HTML,用HTMLRewriter解析<link rel="icon">/apple-touch-icon,按宣告的尺寸排序,找不到就 fallback 到/favicon.ico。每一個解析出來的 href 都要再過一次 SSRF 驗證,不會因為是網站自己說的連結就跳過檢查。 - 驗證回應內容(
src/sanitize.ts),點陣圖直接回傳;SVG 用svg-hush編譯成 WASM 做消毒(跟 Vaultwarden 內部用的是同一套 allowlist 邏輯),再包一層嚴格的Content-Security-Policysandbox 送出去,同時限制檔案大小上限與逾時秒數。 - 邊緣快取,用 Cloudflare 的 Cache API 存下結果並附上
Cache-Control。找不到圖示或 host 被擋下時,回傳一個可被快取的 404,讓各家 client 顯示自己內建的預設圖示,而不是硬塞一張來路不明或空白的圖。
SVG 那一步值得多說一句:favicon 來源是不受信任的外部內容,SVG 又能夾帶 script 和外部參照,直接回傳給瀏覽器等於幫使用者開了一個小型 XSS 入口。用 WASM 消毒加上 sandbox CSP 是防禦縱深,兩層都要中才會出事。
設定
wrangler.jsonc 的 vars 裡可調:
| 變數 | 預設值 | 說明 |
|---|---|---|
MAX_ICON_BYTES | 524288 | 超過這個大小的 favicon 直接拒絕 |
FETCH_TIMEOUT_MS | 5000 | 對外抓取的逾時(毫秒) |
CACHE_TTL_SECONDS | 604800 | 邊緣快取存活時間(預設 7 天) |
BLOCKED_SUFFIXES | "" | 額外要擋的網域後綴,逗號分隔 |
FALLBACK_ICON_SERVICE | "" | 預設關閉。自家解析失敗時,由 Worker(不是 client)向第三方要圖:duckduckgo、google、bitwarden,或自訂 https://…/{} |
FALLBACK_ICON_SERVICE 這個設計挺務實:預設不用任何第三方,但保留一個逃生口。就算開了,也是 Worker 在伺服器端幫你去查,client 的 IP 一樣不會外流,犧牲的只是「這個網域曾經被查過圖示」這件事本身的隱私。
部署
npm install
npx wrangler login
# 想綁自訂網域的話,在 wrangler.jsonc 加:
# "routes": [{ "pattern": "vaultwarden-icons.example.com", "custom_domain": true }]
npm run deploy
部署完,Vaultwarden 端只要改環境變數,完全不用碰程式碼:
ICON_SERVICE=https://vaultwarden-icons.example.com/{} # 恰好一個 {} 預留位置
ICON_REDIRECT_CODE=307 # 想讓 client 快取這個 redirect 就用 308
Vaultwarden 會自動根據這個 URL 前綴推導出 CSP 的 img-src,web vault 不用再額外設定就能正常載入圖示。
安全設計
專案 README 把安全模型攤開講:只接受公開、以名稱解析的 http(s) host,IP 字面量與內部/保留網域在進入端與每個解析出來的 icon href 都會再擋一次;SVG 用 svg-hush(WASM)去除 script 與外部參照,再包 default-src 'none'; sandbox 的 CSP,兩層防禦;端點本身是公開的(client 透過 redirect 到達,沒辦法要求帶認證標頭),回應只允許圖片型別並限制大小,README 也建議在 Worker route 上另外掛一條 Cloudflare WAF rate-limit 規則擋濫用。
它把能力邊界說得很白:能藏住伺服器和 client 的 IP,也能不依賴第三方圖示服務,但不能取代真正的 forward proxy。Vaultwarden 內部的 fetcher 如果要走 HTTPS_PROXY,還是得靠 VPS、Tor 或 WARP 那類真正的代理,Cloudflare Worker 在這個角色上幫不上忙。
收尾
我的取捨很清楚:不想把伺服器 IP 送給每個存過密碼的網站,也不想把查詢過的網域清單交給 DuckDuckGo 或 Google,所以自己在邊緣節點跑一層薄薄的代理。整個服務只靠 wrangler.jsonc 的環境變數設定,不需要 KV 或 D1,Vaultwarden 端也只是改一行 ICON_SERVICE,不用動到程式碼。
對其他自建 Vaultwarden 的人來說,應該也是目前 favicon 隱私問題裡最省事的解法,歡迎直接拿去用。
專案原始碼:GitHub