"Not worth a CVE": the mailpit deny-list, and CVE-2026-84697
A maintainer read my report, agreed with every claim in it, shipped the fix in two minutes and declined to request an identifier. I think the decision was defensible and still wrong, and this is the argument for why — plus the two addresses that started it.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Nothing was ever sent to a host I do not own. The issue was reported privately through the project’s own security advisory process, the maintainer shipped a fix, and this is written up only after the fix and the CVE record were public.
On 29 August, a02821bf landed in mailpit. Twenty-four lines added to internal/tools/net.go, thirteen to its test file. The message reads:
Fix: Block Azure WireServer, IPv4-translated prefix and additional reserved ranges in SSRF deny-list
If you run mailpit on Azure, that commit is the difference between your test-mail server being able to dial your platform’s host-agent channel and not. Nothing told you so. There was no advisory, no severity, no affected-version range, no release note flagging it. The commit went by in the stream with everything else.
I am the reason that commit exists, and I want to argue with the decision that left it unannounced — carefully, because the maintainer here did almost everything right and the failure mode of posts like this is to pretend otherwise.
Two minutes
The report went in on 28 August. The reply came the next morning at 08:33 UTC: he agreed with the finding, and he said it was not worth a CVE. At 08:35 — two minutes later — the fix was committed. Then the advisory was closed without ever being published.
Two minutes. He had read the report, understood it, decided the patch was right and written it. I have waited three weeks for a maintainer to acknowledge that an email arrived. Whatever else is true here, this is not a story about neglect.
It is also not a story about a maintainer who dismissed the finding. He accepted every claim in it. The disagreement was narrow and specific, and it was about paperwork: whether this particular defect earned an identifier.
What he was agreeing to
The bug is small, and I want to be honest about that because it is load-bearing for his side of the argument.
Mailpit fetches things by design. It checks the links in the mail you send it, it checks CSS in HTML messages, and it proxies assets so the web UI can render them. All three take URLs out of message content, and message content is untrusted by definition, because the product is a mail server you throw test mail at. Point one of those fetches at an address that exists only inside the network the server runs in and you have Server-Side Request Forgery.
The project had already been through this twice. Two prior advisories walked it through, and the remediation for the second one consolidated three separate copies of safeDialContext onto a single predicate, tools.IsInternalIP. I went looking for the obvious follow-up, a fourth dialer that missed the consolidation, and there is not one. The three are byte-identical and all three call the same function.
That function is good. It blocks loopback, private, link-local unicast and multicast, unspecified, multicast, CGNAT, benchmarking, IETF protocol assignments, TEST-NET 1 through 3, reserved-for-future, deprecated site-local, documentation. It carries a decoder that reaches inside NAT64 well-known and local-use prefixes, 6to4, Teredo, ISATAP and IPv4-compatible addresses to inspect the IPv4 destination hiding in the low bits. I have read a lot of guards like this one and it is in the top decile.
It misses two things.
The first is 168.63.129.16. On Azure that address is the platform’s host agent — DHCP, DNS, the machinery a VM talks to in order to be a VM. Everywhere else it is nothing. And structurally it is an ordinary global-unicast IPv4 address: IsPrivate() is false, IsLinkLocalUnicast() is false, and it belongs to no range the predicate enumerates, because it is not a range. Microsoft took one address out of routable space and gave it a meaning.
What turns that from trivia into a finding is the comment sitting directly above the function:
// IsLinkLocalUnicast - 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
The code claims to cover cloud metadata and names the address it covers. On AWS and GCP that sentence is true. On Azure it is false, and nothing in the file says the coverage depends on which cloud you are in. Somebody auditing this later reads the comment, agrees, and moves on. That is what an incomplete fix looks like from the inside: not an absent check, but a check whose advertised scope is wider than its real one.
The second miss is a clean off-by-one in a family of three. There are three documented ways to carry an IPv4 address inside an IPv6 one:
| Form | Prefix | Bytes 8–11 |
|---|---|---|
| IPv4-compatible (deprecated) | ::/96 |
all zero |
| IPv4-mapped | ::ffff:0:0/96 |
00 00 ff ff |
| IPv4-translated (RFC 2765/6145) | ::ffff:0:0:0/96 |
ff ff 00 00 |
Mapped is handled, because Go’s To4() decodes it natively. Compatible is handled by an explicit prefix check. Translated falls between them for an exact reason: bytes 8 and 9 are 0xffff, so To4() returns nil; and the compatible check requires bytes 0 through 11 to be zero, which they are not. The decoder returns “no embedded IPv4 here” and the address inside is never examined.
Which means ::ffff:0:a9fe:a9fe — the IPv4-translated form of 169.254.169.254, the exact address the comment above promises to cover — walks through a function that decodes five other embedding schemes.
Reaching either one needs a DNS record rather than a literal, because the dialer resolves the hostname, validates every resolved address, and then dials the validated address instead of the name. Rebinding is closed. Redirect-chasing is closed. The check runs correctly, on an address it does not recognise.
I proved it with a Go test against the real package whose first case has to pass: twenty-two addresses the predicate is supposed to block, which all still block in the same run that shows the five it does not. If the harness were broken, the twenty-two would fail with everything else. A PASS and a FAIL side by side is the difference between a result and a guess — and I would rather spend the extra twenty minutes than publish a bypass that was really a typo.
I also found a NAT64 /48 embedding quirk next door and did not report it, because RFC 8215 leaves that embedding site-defined and the project’s reading is defensible. Padding a report with a third finding you would lose an argument about is a good way to lose the two you would win.
The case for his position
Put fairly, it goes like this. The Azure address only matters on Azure. Mailpit is a development tool, usually run on a laptop or in a CI container, and the population of people running it on an Azure VM where reaching the host agent buys an attacker something real is small. Nothing is read back to the attacker — this is a blind fetch. The fix is four lines. Filing an advisory means writing a summary, picking a severity you will be second-guessed on, choosing an affected range, and inviting a stream of “are we affected” issues from people who are not. For a defect this size, on a tool this shape, the ceremony can genuinely cost more than it returns.
I do not think that reasoning is stupid. I think it answers the wrong question.
Why I asked anyway
The question a maintainer naturally asks is how bad is this. The question an identifier answers is how do the people running it find out.
Those come apart, and this case is where they come apart most sharply. Severity here is modest and conditional. Distribution is the entire problem. There is no channel — none — by which a person running mailpit behind a corporate Azure deployment learns that a02821bf is the commit they want. They are not reading the diff. Their scanner has nothing to match on. The release notes say nothing. The advisory that would have told them was closed before it was ever visible.
A CVE is not a badge for the finder and it is not a scolding for the maintainer. It is a routing number. It is how a fact about your software reaches someone who will never speak to you.
So I asked VulnCheck for one on 30 August. The request asked for an identifier and explicitly declined vendor coordination — he had already given his answer, and having a third party go back and press him would have been both rude and useless. Three days later the record published, and it says the thing he chose not to say:
Mailpit SSRF Deny List Bypass via Azure Metadata and IPv6 Prefix — Mailpit’s IsInternalIP deny list function fails to block the Azure WireServer address 168.63.129.16 and the RFC 2765/6145 IPv4-translated IPv6 prefix, allowing server-side request forgery to internal destinations.
Affected mailpit <= 1.31.0, with references to the fix commit and to net.go at the vulnerable tag, so a reader gets from the identifier to the code in two clicks. CVE-2026-84697.
The part I did not expect
The record credits me as finder.
That should be unremarkable, and it is not, because of how the usual route works. When GitHub assigns an identifier as CNA for a published repository advisory, the advisory page carries the reporter’s credit and the CVE record carries credits: null. Your name is on the page nobody queries and missing from the record everybody does.
Here it inverted. There is no advisory page at all, and the credit is in the record.
I have now obtained three identifiers this way, and all three carry credit that the vendor path would not have produced. That is a strange thing to have learned. The route that exists for when the normal process fails turns out to produce a better artifact, on the one axis I care about, than the normal process does when it succeeds.
Where this leaves the disagreement
I am not going to end by telling you the maintainer should have filed an advisory. He shipped a correct fix in two minutes and owed me nothing further, and a rule that every four-line hardening patch requires the full ceremony would make maintaining anything worse. His judgement about cost was probably right for him.
What I would say is narrower. When you decline an identifier, you are not deciding whether the bug was serious. You are deciding whether the people who deployed your software get to find out, and those are different decisions that feel like the same one. If the answer to the second is “they will not, by any path”, that is worth a minute’s thought even when the answer to the first is “barely”.
And if you are on the reporting side: a closed door is not the end of the process. It took one form, three days and no further contact with anyone who had already said no.
Eurico Nicacio — @h3llh0und