A small Cloudflare Worker that resolves and serves website favicons for Vaultwarden, so that your Vaultwarden server's IP is never exposed to the sites your users have saved.
Vaultwarden's default icon service (ICON_SERVICE=internal) downloads each
site's favicon from your server. Every saved login leaks your server's
egress IP to that domain. The built-in alternatives (duckduckgo, google,
bitwarden) avoid that, but instead hand the list of domains your users look up
to a third party.
This Worker is a third option: a favicon resolver you own, running on Cloudflare's edge.
Client (web vault / extension / mobile)
│ GET /icons/github.com/icon.png
▼
Vaultwarden ──302/307 redirect──▶ https://vaultwarden-icons.example.com/github.com
│ (server makes NO outbound request)
▼ client follows redirect
Cloudflare Worker ──fetch──▶ github.com (HTML <link rel=icon> + /favicon.ico)
│ returns the image BYTES (reverse proxy, not a second redirect)
▼ ▲ target site sees only Cloudflare's IP
Client renders icon
Because the Worker fetches and returns the bytes (rather than redirecting the client onward to the target), the target site never sees the client's IP either. All three parties — server, client, target — are shielded.
- ✅ Hides the Vaultwarden server IP from target sites (server makes no icon request).
- ✅ Hides the client IP from target sites (the Worker fetches, not the client).
- ✅ No third-party icon service by default — the Worker does its own discovery
(an optional server-side fallback can be enabled via
FALLBACK_ICON_SERVICE). - ❌ Not an
HTTPS_PROXY/SOCKS forward proxy. A Cloudflare Worker cannot serve asHTTPS_PROXYfor Vaultwarden's internal fetcher — that requires a real proxy (VPS/Tor/WARP). This project uses Vaultwarden'sICON_SERVICEredirect model instead.
For a request to /<domain> the Worker:
- Validates the host (
src/ssrf.ts) — rejects IP literals,localhost, single-label and reserved-TLD names (.local,.internal, …), and any operator-configured blocked suffixes. The Worker is public, so it re-validates every host independently of Vaultwarden. - Discovers candidates (
src/favicon.ts) — fetches the site HTML and usesHTMLRewriterto extract<link rel="icon">/apple-touch-iconhrefs, ordered by declared size, then falls back to/favicon.ico. Every discovered href is re-validated against the SSRF guard before it is fetched. - Validates the response (
src/sanitize.ts) — raster image types are served as-is. SVG is sanitized withsvg-hushcompiled to WASM (svg-sanitizer/) — the same allowlist sanitizer Vaultwarden uses internally — then served behind a strictContent-Security-Policysandbox. A byte cap and per-request timeout are enforced. - Caches at the edge (Cloudflare Cache API) and returns the bytes with
Cache-Control. When no icon can be found (or the host is rejected) it returns a cacheable 404 so each client renders its own built-in placeholder, rather than serving a foreign or blank image.
npm install
npx wrangler login
# (optional) bind a custom domain in wrangler.jsonc:
# "routes": [{ "pattern": "vaultwarden-icons.example.com", "custom_domain": true }]
npm run deployThen point Vaultwarden at it (environment variables — no Vaultwarden code changes):
ICON_SERVICE=https://vaultwarden-icons.example.com/{} # exactly one {} placeholder
ICON_REDIRECT_CODE=307 # 308 if you want clients to cache the redirectVaultwarden derives its Content-Security-Policy img-src from the icon-service
URL prefix automatically, so the web vault loads icons from the Worker with no
further configuration.
Set in wrangler.jsonc under vars (all are strings):
| Var | Default | Meaning |
|---|---|---|
MAX_ICON_BYTES |
524288 |
Reject favicons larger than this (bytes). |
FETCH_TIMEOUT_MS |
5000 |
Per-request outbound timeout (ms). |
CACHE_TTL_SECONDS |
604800 |
Edge cache TTL for resolved icons (7 days). |
BLOCKED_SUFFIXES |
"" |
Comma-separated hostname suffixes to additionally refuse. |
FALLBACK_ICON_SERVICE |
"" |
Off by default. When own discovery fails, fetch from a third party server-side (the client is never exposed): duckduckgo, google, bitwarden, or a custom https://…/{} URL. |
npm run dev # local Worker at http://localhost:8787 (try /github.com)
npm test # vitest (unit + integration via @cloudflare/vitest-pool-workers)
npm run typecheck # tsc --noEmit
npm run lint # biome lint- Unit tests (
test/ssrf,test/sanitize,test/favicon) cover host validation, content-type rules, and the discovery/fetch logic — the favicon tests mock outbound HTTP withfetchMockfromcloudflare:test. test/worker.test.tscovers routing, validation and fallback throughSELF. It intentionally does not usefetchMock: the undici mock andSELFdeadlock together in the Workers pool, so the outbound path is covered only at the unit level.
- SSRF: only public, name-based http(s) hosts are fetched; IP literals and internal/reserved names are refused at request entry and again for every discovered icon href.
- SVG: sanitized with
svg-hush(WASM) to strip scripts and external refs, then served behind adefault-src 'none'; sandboxCSP — defense in depth. - Abuse: the endpoint is public (clients reach it via a redirect, so it cannot require an auth header). Responses are images only and size-capped. Add a Cloudflare WAF rate-limiting rule on the Worker route to bound abuse.
- Optional KV-backed negative cache across colos.
- Optional content-sniffing to reject responses whose bytes don't match an image
magic number even when the
Content-Typeclaims otherwise.
Released under the MIT License.