Sigil
A small, self-hosted favicon resolver. Given a domain, Sigil fetches that
site's real favicon server-side, normalizes it to a PNG at the size you
ask for, and caches the result. It exists to replace third-party favicon
proxies (e.g. google.com/s2/favicons) so that resolving an icon never leaks
which domains your users look up to an outside party.
Apache-2.0. Sigil fetches URLs derived from untrusted domains — an SSRF
surface by design — so a hardened fetch boundary is its headline feature (see
the Security model and THREAT-MODEL.md).
Self-reviewed, not independently audited.
Security model
Sigil's whole reason to be a separate service is containment: the code that
fetches attacker-influenceable URLs must not run in a process that holds
database credentials or an internal-network position. The running instance is
designed to have zero internal secrets and egress-only reach — if its
defenses were ever bypassed, the blast radius is a box that can talk to the
public internet and nothing else.
The fetch boundary (pkg/favicon/fetch.go) enforces, for every request and
every redirect hop:
- Dial-time IP validation (TOCTOU-safe). The resolved IP is checked in
net.Dialer.Control — after DNS resolution, immediately before connect, once
per candidate address — using code.dny.dev/ssrf
(an IANA-registry-derived denylist). This closes the resolve-then-connect gap
and defeats DNS rebinding: a name that rebinds between check and dial is still
validated at the dial. Validation is on netip.Addr, so IPv4-mapped IPv6
(::ffff:169.254.169.254) cannot slip past as global unicast.
- A default-deny address policy. Loopback, RFC 1918 private space, CGNAT
(
100.64.0.0/10), link-local 169.254.0.0/16 (the cloud metadata endpoint),
multicast, reserved ranges, IPv6 ULA/link-local, and NAT64 are all rejected.
Only TCP to ports 80/443 is permitted.
- Bounded redirects. At most 3 hops, and every hop must be
http/https —
a file://, gopher://, etc. redirect is refused.
- Bounded responses. The body is read through a hard byte cap, the
content type is decided by sniffing (never the response header), only
raster image types are accepted, and the decoded pixel dimensions are
bounded before full decode to defeat decompression bombs.
- No SVG. SVG favicons are rejected in v1: an SVG is XML that can carry
script (stored-XSS risk) or external entities (XXE / second-order SSRF). The
monogram fallback covers the small number of SVG-only sites.
The SSRF test suite (pkg/favicon/fetch_test.go) asserts each of these
independently of the guard library, so a dependency bump that regressed a range
would fail our tests rather than silently ship.
What it is (and is not)
- Is: a hardened favicon fetcher + normalizer, usable as a Go library
(
github.com/ciphera-net/sigil/pkg/favicon) or as a tiny HTTP service
(cmd/server).
- Is not: a public favicon API for the internet, a general URL fetcher, or
a proxy. The published instance stays internal; publishing the code is the
open-source act.
Install (library)
go get github.com/ciphera-net/sigil/pkg/favicon
Requires Go 1.25+.
API (service)
| Route |
Response |
GET /icon?domain=<hostname>&sz=<16|32|64|128> |
200 image/png resolved icon · 404 no icon (negatively cached) · 400 bad domain/size |
GET /healthz |
liveness |
GET /metrics |
Prometheus |
domain must be a bare hostname (no scheme, port, path, or IP literal).
Self-host
docker build -t sigil .
docker run -p 8085:8085 sigil
The container is a distroless static image holding only the server binary.
CI
.woodpecker/ runs, as separate checks: test (gofmt, go vet,
go test -race, including the SSRF suite), govulncheck (dependency-vuln
scan), and — once the service lands — build / push / deploy.