GHSA-j3fj-qppj-fmmc: Mailpit has an incomplete fix for GHSA-6jxm: HTML check still permits SSRF to private/loopback/IMDS via missing IP-filter dialer
SummaryThe fix for GHSA-6jxm-fv7w-rw5j (CVE-2026-23845, "Server-Side Request Forgery (SSRF) via HTML Check API"), shipped in mailpit `v1.28.3`, hardened `internal/htmlcheck/css.go::downloadCSSToBytes` with a 5MB size cap, a `text/css` content-type check, login-info stripping in `isValidURL`, and an opt-in `--block-remote-css-and-fonts` config flag — but **did not add the IP-filtering dialer that the same codebase already uses on the two sister SSRF endpoints** (the proxy handler and link-check). At HEAD `8bc966e61834a24c48b4465da418f75e73be0afd` (2026-05-06), `internal/htmlcheck/css.go::newSafeHTTPClient` is mis-named — it builds an `http.Client` whose `Transport.DialContext` calls `net.Dialer.DialContext` directly with no IP allowlisting. As a result, the SSRF originally reported by Bao Anh Phan still permits the server to dial:loopback (`127.0.0.0/8`, `::1`),private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`),link-local incl. **cloud IMDS** (`169.254.0.0/16`, especially `169.254.169.254`),CGNAT (`100.64.0.0/10`),and any other reserved/multicast range,— provided the target replies with `HTTP/200` and a content-type beginning with `text/css`. With redirect-following (`CheckRedirect` allows redirects to any `isValidURL` URL with no IP filter), an attacker-controlled public site can redirect mailpit's request into the private network without ever appearing in the email's HTML.In the default mailpit deploy (no UI auth, no SMTP auth, port 1025/8025 exposed), this is an unauthenticated, network-reachable SSRF triggered by sending an HTML email and then issuing one HTTP `GET` to `/api/v1/message/{id}/html-check`.Affected versions`internal/htmlcheck/css.go` at HEAD `8bc966e61834a24c48b4465da418f75e73be0afd` (2026-05-06).All versions `>= v1.28.3` (the version that shipped the GHSA-6jxm fix). Versions `<= v1.28.2` are vulnerable to the original GHSA-6jxm; versions `>= v1.28.3` carry the still-vulnerable variant described here.The incomplete fixThe original GHSA-6jxm fix added size+content-type+login-info hardening to `downloadCSSToBytes`. But the dialer it uses still has no `safeDialContext`. The companion `linkcheck` and `proxy` handlers in the same codebase have all-three protections: size cap, content-type/redirect filter, **AND** a `safeDialContext` that runs `tools.IsInternalIP(ip.IP)` per resolved address — same pattern the htmlcheck dialer should adopt.Side-by-side at HEAD `8bc966e`:| File | Function | `safeDialContext` (IP filter)? | TOCTOU-safe (dial-by-IP)? |
|---|---|---|---|
| `internal/linkcheck/status.go::safeDialContext` line 140-163 | dial check | YES | YES (resolved IP joined with port) |
| `server/handlers/proxy.go::safeDialContext` line 393-415 | dial check | YES | YES |
| `internal/htmlcheck/css.go::newSafeHTTPClient` line 275-310 | dial check | **NO** | n/a |The mis-named `newSafeHTTPClient` reads:`isValidURL` only rejects non-http(s) and userinfo URLs — it does NOT reject internal IPs. Compare `linkcheck/status.go::safeDialContext`:That's the protection htmlcheck is missing.Reachability chain (default deploy)PoCDefault-deploy reproduction (no auth):The HTTP server-side dial follows `http://attacker.example.com/test.css` → 302 redirect to `http://127.0.0.1:6379/` → mailpit completes a TCP connect to the loopback Redis. No request body is reflected to the attacker (mailpit only inlines successful 200 + `text/css` responses), but:**State-changing internal GETs.** Any internal admin app served on `127.0.0.1` or RFC1918 with a "GET /admin/restart", "GET /vacuum", "GET /flush" pattern can be triggered through this primitive. Several common stacks (Spring Actuator, etcd debug, internal Prometheus admin, Redis HTTP front-ends, Jaeger UI) expose such operations on private ports.**Cloud-IMDS reachability oracle.** Because IMDS responses don't carry `text/css`, the body is not inlined — but the redirect chain DOES dial 169.254.169.254. A side-channel (response time, DNS log) can confirm IMDS reachability from a default-deploy mailpit on cloud.**Internal port-scan via timing.** The 5s+15s timeouts produce a clear timing differential between "RST refused" (~ms), "open and HTTP-noisy" (~10ms+), and "filtered" (multi-second).**Authenticated `Mailpit/<version>` GET.** Every internal target sees a known UA from a trusted internal subnet; combined with redirect-stripping, this can fool internal allowlists keyed on UA.Threat model alignmentThe maintainer's prior position on the SSRF class is captured by GHSA-6jxm-fv7w-rw5j (HTML Check, Medium), GHSA-mpf7-p9x7-96r3 (Link Check, Medium), and GHSA-8v65-47jx-7mfr (Proxy Endpoint, Medium). All three are siblings in the same SSRF class, and the maintainer chose to remediate each via a `safeDialContext`-style filter in the linkcheck and proxy fixes. The htmlcheck fix is the outlier: same class, same severity, but the IP filter was not applied. The remaining surface is therefore a regression of the published fix's stated goal ("disallow internal targets").Default-deploy reachability is unauthenticated (per the maintainer's own README, mailpit is intended to run without auth in dev/CI). With UI auth configured, the same primitive is post-auth — still useful (UI-auth mailpit deployments often live on the internal/ops subnet, exposing other ops services).Suggested fixMake `newSafeHTTPClient` use the same `safeDialContext` pattern already proven in `linkcheck/status.go` and `server/handlers/proxy.go`. Concretely:Two further hardening notes:**Add CGNAT 100.64.0.0/10 (RFC 6598).** `tools.IsInternalIP` covers loopback, private, link-local, multicast, unspecified — but not CGNAT. This affects all three SSRF dialers (htmlcheck, linkcheck, proxy). Tailscale tailnets and GCP IAP fall in `100.64.0.0/10`; an mailpit instance running on a Tailscale node can be used to pivot into the tailnet. Concrete fix: extend `tools.IsInternalIP` with `cgnat := net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}; if cgnat.Contains(ip) { return true }`.**Re-validate the rename.** `newSafeHTTPClient` is a misleading name today — once the dialer is hardened, the name will be accurate. Until then, consider renaming it to `newHTTPClient` to remove the false sense of safety it conveys to maintainers reading the file.Reproduction environmentTested against: HEAD `8bc966e61834a24c48b4465da418f75e73be0afd` (2026-05-06).Code locations:Vulnerable dialer: `internal/htmlcheck/css.go:275-310`Vulnerable downloader: `internal/htmlcheck/css.go:192-229`Reachability gate: `internal/htmlcheck/css.go:131-187` (`inlineRemoteCSS`)Trigger handler: `server/apiv1/other.go:18-79` (`HTMLCheck`)Default no-UI-auth: `internal/auth/auth.go` + middleware in `server/server.go:317`Default no-SMTP-auth: `internal/smtpd/main.go:229-230`Sister fixed dialers (for diff): `internal/linkcheck/status.go:140-163`, `server/handlers/proxy.go:393-415`ReporterEddie Ran. Filed via reporter API.
CVSS v3.1: 5.8