Skip to main content

mailpit CVE-2026-55187

MEDIUM
Server-Side Request Forgery (SSRF) (CWE-918)
2026-06-19 https://github.com/axllent/mailpit GHSA-w4mc-hhc6-xp28
5.8
CVSS 3.1 · Vendor: https://github.com/axllent/mailpit
Share

Severity by source

Vendor (https://github.com/axllent/mailpit) PRIMARY
5.8 MEDIUM
AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N
vuln.today AI
5.8 MEDIUM

Unauthenticated network API with scope change to internal services; C:L reflects status-code leakage only, with no integrity or availability impact.

3.1 AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N
4.0 AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N
SUSE
MEDIUM
qualitative

Primary rating from Vendor (https://github.com/axllent/mailpit).

CVSS VectorVendor: https://github.com/axllent/mailpit

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Changed
Confidentiality
Low
Integrity
None
Availability
None

Lifecycle Timeline

3
Source Code Evidence Fetched
Jun 19, 2026 - 23:43 vuln.today
Analysis Generated
Jun 19, 2026 - 23:43 vuln.today
CVE Published
Jun 19, 2026 - 21:16 github-advisory
MEDIUM 5.8

DescriptionCVE.org

Summary

The remediation shipped in mailpit v1.29.2 for GHSA-mpf7-p9x7-96r3 (CVE-2026-27808) is incomplete. The tools.IsInternalIP deny-list relies on Go's stdlib classification helpers (IsLoopback, IsPrivate, IsLinkLocalUnicast, IsLinkLocalMulticast, IsUnspecified, IsMulticast) plus an inline CGNAT range, but those helpers do not match two classes of IPv6 address that should be blocked for SSRF purposes:

  1. IPv6 forms that embed an IPv4 destination via documented translation mechanisms - 6to4, NAT64, IPv4-compatible IPv6, ISATAP, or (in older Go versions) IPv4-mapped IPv6. These let an attacker reach internal IPv4 destinations by supplying an IPv6 literal that encodes the desired IPv4.
  2. IPv6 prefixes that fall outside the narrow private/loopback/link-local ranges Go's stdlib classifies - specifically the deprecated site-local prefix fec0::/10 (RFC 3879/4291) and the documentation prefix 2001:db8::/32 (RFC 3849). The first is still routable on dual-stack hosts and is cited as a bypass form in CVE-2026-44430; the second should never appear in real network traffic and is safe to block as fail-safe behavior.

Together these gaps let the Link Check API be coerced into dialing internal destinations that the v1.29.2 fix was intended to block.

This is the same bug class as GHSA-56c3-vfp2-5qqj / CVE-2026-44430 (MCP Registry) and GHSA-86m8-88fq-xfxp / CVE-2026-45741 (Gotenberg) - projects that, like mailpit, built their SSRF deny-list around Go's stdlib Is* family and discovered the resulting bypass post-disclosure.

The underlying ecosystem-wide issue is tracked upstream at golang/go#79925, which proposes extending net.IP.IsPrivate to handle these IPv6 transition forms. Until that lands, every Go project that wants comprehensive SSRF protection has to implement the decoding itself - which is exactly the gap that produced this advisory and the three CVEs in adjacent projects cited above.

Affected versions

  • mailpit v1.29.2 and later HEAD - the GHSA-mpf7-p9x7-96r3 fix is in place but tools.IsInternalIP does not cover the IPv6 forms enumerated below.
  • Pre-v1.29.2 versions remain vulnerable to the original advisory.

Vulnerable code

internal/tools/net.go L25-L34 - IsInternalIP:

go
func IsInternalIP(ip net.IP) bool {
    return ip.IsLoopback() ||
        ip.IsPrivate() ||
        ip.IsLinkLocalUnicast() ||
        ip.IsLinkLocalMulticast() ||
        ip.IsUnspecified() ||
        ip.IsMulticast() ||
        cgnatRange.Contains(ip)
}

internal/linkcheck/status.go L140-L163 - safeDialContext calls IsInternalIP on resolved IPs before dialing, but only blocks when one of the seven predicates above fires.

For each of the following bypass forms, net.IP.IsLoopback, IsPrivate, IsLinkLocalUnicast, IsLinkLocalMulticast, IsUnspecified, IsMulticast, and the CGNAT range check all return false - so the dial proceeds:

IPv4-embedded-in-IPv6 forms (each carries an IPv4 destination via a documented translation prefix):

Bypass IPv6 literalDecoded IPv4 destinationRFC
64:ff9b::a9fe:a9fe169.254.169.254 (AWS / GCP / Azure metadata)RFC 6052 - NAT64 well-known prefix
64:ff9b:1::a9fe:a9fe169.254.169.254RFC 8215 - NAT64 local-use
2002:a9fe:a9fe::169.254.169.254RFC 3056 - 6to4
::a9fe:a9fe169.254.169.254RFC 4291 §2.5.5.1 - IPv4-compatible IPv6
64:ff9b::7f00:1127.0.0.1RFC 6052 (loopback via NAT64)
2002:0a00:0001::10.0.0.1RFC 3056 (RFC 1918 via 6to4)
<any-prefix>:5efe:<ipv4><ipv4> (e.g. 2001:db8::5efe:7f00:1127.0.0.1)RFC 5214 - ISATAP

Direct IPv6 prefixes not classified by the stdlib Is* family:

Bypass IPv6 literalWhat it isRFC
fec0::1 (any address in fec0::/10)Deprecated site-local - still routable on dual-stack hostsRFC 3879 (deprecation) / RFC 4291 §2.5.7
2001:db8::1 (any address in 2001:db8::/32)Documentation prefix - should never appear on the wireRFC 3849

IsInternalIP returns false for every entry in both tables.

The original advisory's stated mitigations *do* hold against the embedded-IPv4 forms in the narrow case where the IPv6 literal is ::ffff:<ipv4> (IPv4-mapped), because Go's net.IP.To4() normalizes that form and the stdlib Is* methods then check the embedded IPv4. This was the partial fix shipped in Go 1.22.4 / CVE-2024-24790. But it does not extend to 6to4, NAT64, IPv4-compatible, or ISATAP forms - those require explicit decoding that neither Go's stdlib nor IsInternalIP performs. The direct prefixes (fec0::/10, 2001:db8::/32) likewise are simply outside the scope of any Go stdlib Is* method.

Proof of Concept

The repro depends on environment-specific routing for the embedded IPv4 destination. The forms below all *pass* the safeDialContext check on a stock mailpit v1.29.2 - they will not be blocked by the SSRF deny-list. Whether they connect successfully depends on whether the host's network has NAT64 / 6to4 routing to reach the embedded IPv4.

Unit-test repro (no network dependency)

The most defensible PoC is a unit test against IsInternalIP itself - it demonstrates the deny-list gap directly without depending on the test environment routing the bypass IPs:

go
// internal/tools/net_ssrf_test.go
package tools

import (
    "net"
    "testing"
)

func TestIsInternalIP_UncoveredIPv6Forms(t *testing.T) {
    cases := map[string]net.IP{
        // IPv4-embedded-in-IPv6 forms.
        "NAT64 well-known wrapping AWS IMDS (RFC 6052)":     net.ParseIP("64:ff9b::a9fe:a9fe"),
        "NAT64 local-use wrapping AWS IMDS (RFC 8215)":      net.ParseIP("64:ff9b:1::a9fe:a9fe"),
        "6to4 wrapping AWS IMDS (RFC 3056)":                 net.ParseIP("2002:a9fe:a9fe::"),
        "IPv4-compatible IPv6 wrapping AWS IMDS (RFC 4291)": net.ParseIP("::a9fe:a9fe"),
        "NAT64 wrapping loopback (RFC 6052)":                net.ParseIP("64:ff9b::7f00:1"),
        "6to4 wrapping RFC 1918 (RFC 3056)":                 net.ParseIP("2002:0a00:0001::"),
        "ISATAP wrapping AWS IMDS (RFC 5214)":               net.ParseIP("2001:db8::5efe:a9fe:a9fe"),

        // Direct IPv6 prefixes outside the stdlib Is* family.
        "Deprecated site-local fec0::/10 (RFC 3879/4291)":   net.ParseIP("fec0::1"),
        "Documentation prefix 2001:db8::/32 (RFC 3849)":     net.ParseIP("2001:db8::1"),
    }

    for name, ip := range cases {
        t.Run(name, func(t *testing.T) {
            if !IsInternalIP(ip) {
                t.Errorf("IsInternalIP(%s) = false - SSRF deny-list bypass", ip)
            }
        })
    }
}

Run with:

go test ./internal/tools/ -run TestIsInternalIP_UncoveredIPv6Forms

On v1.29.2 every subtest fails. Each failure is a documented bypass.

End-to-end repro

In an environment where the embedded IPv4 destination is reachable (e.g. a host whose network provides NAT64 to RFC 1918 / link-local):

  1. Send a crafted email to mailpit's SMTP listener containing an <a href> with a bypass URL:
html
   <a href="http://[64:ff9b::a9fe:a9fe]/latest/meta-data/iam/security-credentials/">link</a>
  1. POST /api/v1/message/{ID}/link-check.
  2. Observe the doHead HTTP HEAD response status - non-zero status (success or specific error) confirms the dial reached the destination rather than being blocked by IsInternalIP.

In environments without NAT64 / 6to4 routing the connection will time out, but the absence of a private/reserved address blocked response confirms the deny-list bypass logically; the unit test above is the canonical PoC.

Impact

Identical scope and severity model to the original GHSA-mpf7-p9x7-96r3:

  • The link-check API is reachable in mailpit's default deploy without authentication (no --ui-auth, no --smtp-auth required).
  • An attacker who can deliver email to the mailpit SMTP listener (often unauthenticated in default config) and invoke the link-check API can probe internal services using any of the uncovered IPv6 forms above - either via the embedded-IPv4 mechanisms to reach IPv4 destinations like cloud metadata endpoints (169.254.169.254, 168.63.129.16), or by addressing a routable IPv6 service via fec0::/10 directly.
  • The status-code-and-error feedback exposed by the link-check API leaks reachability information per probe.
  • Damage ceiling is bounded by the mailpit response shape (status code, status text, 451 Blocked private/reserved address sentinel) - no response body is exposed - but reachability + status-code mapping is sufficient for service discovery and for confirming cloud-metadata service identity.
  • Scope note: tools.IsInternalIP is also used by the screenshot-proxy and HTML-Check-API endpoints (per maintainer disclosure). The same deny-list bypass applies to dialer decisions in those paths, but they include additional checks that mute the impact. The Link Check API remains the most revealing because its response includes the HTTP status code from the dialed destination; the other two are less directly leaky.

Severity: Moderate, mirroring the original advisory (CVSS 5.8).

Suggested remediation

The fix has two parts:

  1. For the IPv4-embedded-in-IPv6 forms: decode the embedded IPv4 and re-check it. This is the same pattern Python's ipaddress.is_private implemented in 3.13, what code.dny.dev/ssrf (IANA Special Purpose Registry-driven, auto-synced) implements out-of-the-box, and the behavior change being proposed for Go's stdlib at golang/go#79925.
  2. For the direct IPv6 prefixes: add them to the first range check alongside cgnatRange.Contains.

Reference implementation (extends the existing helper, keeps the call-site contract identical):

go
// internal/tools/net.go
package tools

import (
    "encoding/binary"
    "net"
)

var (
    cgnatRange          = mustCIDR("100.64.0.0/10")    // RFC 6598
    deprecatedSiteLocal = mustCIDR("fec0::/10")         // RFC 3879 / 4291
    documentationPrefix = mustCIDR("2001:db8::/32")     // RFC 3849
    nat64WellKnown      = mustCIDR("64:ff9b::/96")      // RFC 6052
    nat64LocalUse       = mustCIDR("64:ff9b:1::/48")    // RFC 8215
    sixToFour           = mustCIDR("2002::/16")         // RFC 3056
    teredo              = mustCIDR("2001::/32")         // RFC 4380
    ipv4Compatible      = mustCIDR("::/96")             // RFC 4291 §2.5.5.1
    ipv4Mapped          = mustCIDR("::ffff:0:0/96")     // RFC 4291 §2.5.5.2
)

func mustCIDR(s string) *net.IPNet {
    _, n, err := net.ParseCIDR(s)
    if err != nil {
        panic(err)
    }
    return n
}

// IsInternalIP reports whether ip should be blocked as a connection target.
// Covers the stdlib Is* checks plus CGNAT, plus IPv6 forms outside the
// stdlib's scope (deprecated site-local, documentation prefix, and the
// IPv6 transition mechanisms whose embedded IPv4 is itself internal).
func IsInternalIP(ip net.IP) bool {
    if ip.IsLoopback() ||
        ip.IsPrivate() ||
        ip.IsLinkLocalUnicast() ||
        ip.IsLinkLocalMulticast() ||
        ip.IsUnspecified() ||
        ip.IsMulticast() ||
        cgnatRange.Contains(ip) ||
        deprecatedSiteLocal.Contains(ip) ||
        documentationPrefix.Contains(ip) {
        return true
    }
    if embedded, ok := embeddedIPv4(ip); ok {
        return IsInternalIP(embedded)
    }
    return false
}

// embeddedIPv4 returns the IPv4 destination encoded in ip, if ip is an
// IPv6 form documented to carry an embedded IPv4 destination.
func embeddedIPv4(ip net.IP) (net.IP, bool) {
    // Skip IPv4 / IPv4-mapped IPv6 - covered by the stdlib Is* checks via To4.
    if ip.To4() != nil {
        return nil, false
    }
    ip16 := ip.To16()
    if ip16 == nil || len(ip16) != net.IPv6len {
        return nil, false
    }
    switch {
    case nat64WellKnown.Contains(ip16), nat64LocalUse.Contains(ip16),
        ipv4Compatible.Contains(ip16):
        // Last 32 bits are the embedded IPv4.
        return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4(), true
    case sixToFour.Contains(ip16):
        // Bits 16..47 are the embedded IPv4.
        return net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5]).To4(), true
    case teredo.Contains(ip16):
        // Bits 96..127 are the embedded IPv4 XOR'd with 0xFFFFFFFF.
        x := binary.BigEndian.Uint32(ip16[12:16]) ^ 0xFFFFFFFF
        b := make([]byte, 4)
        binary.BigEndian.PutUint32(b, x)
        return net.IPv4(b[0], b[1], b[2], b[3]).To4(), true
    case ip16[10] == 0x5e && ip16[11] == 0xfe:
        // ISATAP (RFC 5214) - interface identifier ends with :5efe:<ipv4>.
        // Match structurally on bytes 10-11; the /64 prefix is not fixed.
        // Must run after the fixed-prefix cases above (Teredo can legitimately
        // have 5efe in bytes 10-11; its embedding takes precedence).
        return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4(), true
    }
    return nil, false
}

This covers every bypass in the two tables above. The direct-prefix additions (deprecatedSiteLocal, documentationPrefix) are two lines in the first if-block; the embedded-IPv4 decoder is the substantive new function.

Alternative - adopt a comprehensive library: Replace the hand-rolled deny-list with code.dny.dev/ssrf, which generates its IPv4 and IPv6 prefix lists from the IANA Special Purpose Registries via a bi-monthly auto-sync. This protects against future RFCs adding new transition forms without requiring further mailpit maintenance.

References

AnalysisAI

Incomplete SSRF remediation in mailpit v1.29.2 through v1.30.1 leaves the Link Check API bypassable via IPv6 transition mechanism literals (6to4, NAT64, IPv4-compatible IPv6, ISATAP, Teredo) and unclassified IPv6 prefixes (fec0::/10, 2001:db8::/32) that Go's stdlib Is* classification helpers silently pass. An unauthenticated network attacker who can deliver email to mailpit's SMTP listener and invoke the Link Check API can coerce the application into dialing internal IPv4 destinations - including cloud metadata endpoints at 169.254.169.254 - by encoding the target as an IPv6 literal that returns false for all seven predicates in IsInternalIP, bypassing the guard introduced for CVE-2026-27808. Publicly available exploit code exists in the form of a reproducible unit test and end-to-end proof-of-concept published in the advisory; this is the same deny-list bypass class confirmed in CVE-2026-44430 (MCP Registry) and CVE-2026-45741 (Gotenberg).

Technical ContextAI

mailpit is a Go-based email testing and development tool (CPE: pkg:go/github.com_axllent_mailpit) that exposes a Link Check API (POST /api/v1/message/{ID}/link-check) for validating hyperlinks embedded in received messages. The SSRF defense lives in internal/tools/net.go at IsInternalIP (lines 25-34); the caller safeDialContext in internal/linkcheck/status.go (lines 140-163) invokes IsInternalIP on each resolved IP before dialing and blocks only when one of seven predicates fires: IsLoopback, IsPrivate, IsLinkLocalUnicast, IsLinkLocalMulticast, IsUnspecified, IsMulticast, or a CGNAT range check (100.64.0.0/10). CWE-918 (Server-Side Request Forgery) applies: the root cause is that Go's stdlib net.IP helpers do not decode or classify IPv6 transition mechanism prefixes - 6to4 (2002::/16, RFC 3056), NAT64 well-known (64:ff9b::/96, RFC 6052), NAT64 local-use (64:ff9b:1::/48, RFC 8215), IPv4-compatible (::/96, RFC 4291), ISATAP (bytes 10-11 equal 0x5e 0xfe, RFC 5214), or Teredo (2001::/32, RFC 4380) - nor the deprecated site-local prefix fec0::/10 (RFC 3879/4291) or the documentation prefix 2001:db8::/32 (RFC 3849). Go's net.IP.IsPrivate does normalize IPv4-mapped IPv6 (::ffff:0:0/96) via To4() - a partial fix shipped in Go 1.22.4 / CVE-2024-24790 - but this normalization does not extend to any of the other transition forms. The structural gap is tracked upstream at golang/go#79925, which proposes extending net.IP.IsPrivate; until that lands, projects must implement their own embedded-IPv4 decoding.

RemediationAI

Upgrade mailpit to v1.30.2 or later, which resolves the incomplete IsInternalIP deny-list. The fix has two parts as described in the advisory: (1) add explicit CIDR checks for the deprecated site-local prefix fec0::/10 (RFC 3879) and documentation prefix 2001:db8::/32 (RFC 3849) alongside the existing cgnatRange.Contains call; and (2) implement an embeddedIPv4 decoder that extracts the IPv4 address encoded in 6to4, NAT64 well-known, NAT64 local-use, IPv4-compatible IPv6, ISATAP, and Teredo literals and recursively passes it through IsInternalIP - reference Go implementation is provided in full in the advisory. An alternative remediation is to replace the hand-rolled deny-list entirely with the code.dny.dev/ssrf library (https://pkg.go.dev/code.dny.dev/ssrf), which generates its IPv4 and IPv6 prefix lists from the IANA Special Purpose Registries via bi-monthly auto-sync, eliminating the need for manual updates when future RFCs define new transition forms. If immediate upgrade is not feasible, restrict access to the mailpit HTTP API port (default 8025) to trusted hosts only via network-layer controls, and enable UI authentication using the --ui-auth flag - this prevents unauthenticated invocation of the Link Check API but does not fix the underlying deny-list gap and has the trade-off of requiring credentials for all mailpit UI and API access.

More in Python

View all
CVE-2025-24016 CRITICAL POC
9.9 Feb 10

Wazuh SIEM platform versions 4.4.0 through 4.9.0 contain an unsafe deserialization vulnerability in the DistributedAPI t

CVE-2025-27520 CRITICAL POC
9.8 Apr 04

BentoML version 1.4.2 and earlier contains an unauthenticated remote code execution vulnerability through insecure deser

CVE-2025-2945 CRITICAL POC
9.9 Apr 03

pgAdmin 4 contains critical remote code execution vulnerabilities in the Query Tool download and Cloud Deployment endpoi

CVE-2013-5093 MEDIUM POC
6.8 Sep 27

The renderLocalView function in render/views.py in graphite-web in Graphite 0.9.5 through 0.9.10 uses the pickle Python

CVE-2025-32375 CRITICAL POC
9.8 Apr 09

BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Rated critica

CVE-2014-0224 HIGH POC
7.4 Jun 05

OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph

CVE-2024-21644 HIGH POC
7.5 Jan 08

pyLoad download manager version prior to 0.5.0b3.dev77 exposes the Flask SECRET_KEY through an unauthenticated endpoint.

CVE-2026-33017 CRITICAL POC
9.3 Mar 17

Langflow (a visual LLM pipeline builder) contains a critical unauthenticated code execution vulnerability (CVE-2026-3301

CVE-2017-9462 HIGH POC
8.8 Jun 06

In Mercurial before 4.1.3, "hg serve --stdio" allows remote authenticated users to launch the Python debugger, and conse

CVE-2026-39987 CRITICAL POC
9.3 Apr 08

Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/

CVE-2024-21645 MEDIUM POC
5.3 Jan 08

pyLoad is the free and open-source Download Manager written in pure Python. Rated medium severity (CVSS 5.3), this vulne

CVE-2026-55255 HIGH POC
8.4 Jun 19

Cross-user flow execution in Langflow (< 1.9.1) lets any authenticated API-key holder run another user's flow by passing

Vendor StatusVendor

SUSE

Severity: Moderate
Product Status
SUSE Linux Enterprise Server 16.1 Affected
SUSE Linux Enterprise Server for SAP applications 16.1 Affected
SUSE Linux Enterprise Module for Package Hub 15 SP5 Affected
SUSE Linux Enterprise Module for Package Hub 15 SP6 Affected
openSUSE Leap 15.5 Affected

Share

CVE-2026-55187 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy