//nefariousplan

CVE-2026-34975: Plunk's CRLF Patch Lives in the Schema. The Schema Missed Two of Eight Fields.

pattern

cve

proof of concept

apps/api/src/services/SESService.ts builds outbound MIME by interpolating caller-controlled fields into a string template. Eight of those fields are arbitrary strings the API receives as JSON. CVE-2026-34975 is the vendor's response to a researcher who confirmed CRLF injection through four of them. The fix landed entirely in the Zod input-validation schemas. Six of the eight interpolations now carry a \r\n regex. Two do not.

The construction layer is a string template

apps/api/src/services/SESService.ts exports a single sendRawEmail function. The Plunk API's POST /v1/send handler validates the request body against a Zod schema, then forwards the parsed object to this function. The function builds the message envelope as a JavaScript template literal:

let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: ${rootContentType}${extraHeaders}

`;

Each ${field} is a header value the caller controls. The template literal preserves the literal newlines between header lines, producing one line per header. There is no escape function on the interpolated values, no header builder object, no per-field sanitizer. The raw bytes are subsequently TextEncoder().encode()d and handed to AWS SES via ses.sendRawEmail.

Three of the interpolations are validated as email addresses elsewhere in the schema (from.email, recipient.email, reply), and Zod's z.string().email() rejects \r\n bytes. The other interpolations are arbitrary strings.

The recipient list passes through a separate composition step at line 115:

const toHeader = to.map(recipient => {
  if (typeof recipient === 'string') {
    return recipient;
  } else {
    return recipient.name ? `${recipient.name} <${recipient.email}>` : recipient.email;
  }
}).join(', ');

Each recipient name is interpolated raw into the To header.

The custom-headers block is composed from the request's headers object:

const extraHeaderLines = [
  ...(headers ? Object.entries(headers).map(([key, value]) => `${key}: ${value}`) : []),
  ...(unsubscribeHeader ? [unsubscribeHeader] : []),
];
const extraHeaders = extraHeaderLines.length > 0 ? `\n${extraHeaderLines.join('\n')}` : '';

Each key and value is interpolated raw into the envelope.

Three more fields reach the per-attachment header block lower in the function:

rawMessage += `\n--${relatedBoundary}
Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64
Content-ID: <${attachment.contentId || attachment.filename}>
Content-Disposition: inline; filename="${attachment.filename}"

Eight string-typed caller-controlled fields total: from.name, recipient.name, subject, the headers map's keys and values, attachment.contentType, attachment.contentId, attachment.filename. Every one of them is interpolated into a header line. A \r\n byte in any of them is appended into the raw message as two header-terminating bytes. The next byte in the same field starts a new header line.

The first patch fixed one of the eight

Commit 08e5c0d, dated February 16, 2026, author Shuvadipta Das. The PR title:

fix: contentId header injection fixed and separte unit test SESService added to verify MIME boundaries

The diff to packages/shared/src/schemas/index.ts:

- contentId: z.string().min(1).max(255).optional(),
+ contentId: z
+   .string()
+   .min(1)
+   .max(255)
+   .regex(/^[^<>\r\n]+$/, 'Content ID cannot contain <, >, \r, or \n')
+   .optional(),

A regex on one field. The same commit adds 174 lines of new tests in apps/api/src/services/__tests__/SESService.test.ts that verify the structure of MIME boundaries for mixed and related multipart layouts. None of the new tests pass \r\n in any field. The PR title's framing is "contentId header injection fixed", singular; the corresponding mental model is that the bug is contentId. Seven other interpolations into the same template are not touched.

The commit ships in v0.7.0 on March 5.

The second patch fixed four more

Commit b50b5af, dated March 31, 2026, author Dries Augustyns (project owner). The PR title:

fix: Enhance email step configuration validation and recipient handling

Five regex insertions across four fields:

- subject: z.string().min(1).max(998).optional(),
+ subject: z.string().min(1).max(998).regex(/^[^\r\n]*$/, 'Subject contains invalid characters').optional(),
...
- name: z.string().optional(),
+ name: z.string().regex(/^[^\r\n]*$/, 'Name contains invalid characters').optional(),
...
- headers: z.record(z.string().max(998)).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),
+ filename: z.string().min(1).max(255).regex(/^[^\r\n"]+$/, 'Filename contains invalid characters'),

The commit message is a generic feature-style description. It does not contain the words "CVE", "security", "CRLF", or "injection". It calls itself an "enhancement" to "configuration validation and recipient handling." The five regex insertions cover subject, the name field on the from object, the headers map's keys and values, and filename. The commit ships in v0.8.0 the same day, March 31, 2026.

CVE-2026-34975 is the public assignment for this fix. It was published in late April 2026 with CVSS 8.5 against the schema state that existed before March 31. The PoC by Romain Deperne, dated April 29, names four exploit vectors: from.name, subject, custom header value, and attachment filename. All four belong to the set of fields the March 31 patch added regex to. The CVE's attack surface, as far as the published research goes, is closed in v0.8.0 and later.

Two of the eight do not have a regex

The schema regex covers six fields. The template interpolates eight.

The first uncovered field is the recipient name. From the send action schema:

to: z.union([
  email,
  z.object({
    name: z.string().optional(),
    email: email,
  }),
  z.array(
    z.union([
      email,
      z.object({
        name: z.string().optional(),
        email: email,
      }),
    ]),
  ),
]),

name here is z.string().optional(). No .regex(). The handler at apps/api/src/controllers/Actions.ts line 188 keeps the field intact through normalization ({email: recipient.email, name: recipient.name}), the EmailService.sendTransactionalEmail enqueue stamps it onto the email row as toName, and the email processor at apps/api/src/jobs/email-processor.ts line 186 reconstructs the recipient object and forwards it into sendRawEmail:

to: typeof recipient === 'string' ? [recipient] : [{name: recipient.name, email: recipient.email}],

sendRawEmail then interpolates the value into toHeader at line 120. The from.name injection the published PoC demonstrates lands in the From line; the same payload in to[0].name lands in the To line, in the same envelope-header position, against an unpatched field. A request body of:

{
  "from": {"name": "X", "email": "[email protected]"},
  "to": {"name": "Foo\r\nBcc: [email protected]", "email": "victim@example"},
  "subject": "ok",
  "body": "<p>ok</p>"
}

passes Zod and reaches sendRawEmail with toHeader equal to "Foo\r\nBcc: [email protected] <victim@example>". The interpolated To: line breaks at the \r\n; the next byte starts a new envelope header line.

The second uncovered field is the attachment contentType. In the same schema:

attachments: z.array(
  z.object({
    filename: z.string().min(1).max(255).regex(/^[^\r\n"]+$/, 'Filename contains invalid characters'),
    content: z.string().min(1),
    contentType: z.string().min(1).max(255),
    contentId: z.string().min(1).max(255).regex(/^[^<>\r\n]+$/, ...).optional(),
    disposition: z.enum(['attachment', 'inline']).default('attachment'),
  })...
)

The siblings of contentType (filename and contentId) carry the regex. contentType carries only min(1).max(255). The handler interpolates it into each attachment block at lines 186 and 201:

rawMessage += `\n--${mixedBoundary}
Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="${attachment.filename}"

${breakLongLines(attachment.content, 76, true)}`;

A contentType of text/plain\r\n\r\n<arbitrary HTML> injects a blank line into the part headers. Per RFC 5322 section 2.1, the blank line terminates the header section of the part; the next bytes are the part body. The attacker controls part body content from a field they were supposed to use to declare its type.

Both fields are present in v0.10.0, the current release. Both reach the same template literal the four patched vectors reach. Both pass Zod with \r\n intact.

The same function got a different fix on the day the CVE was published

April 30, 2026. MITRE's record for CVE-2026-34975 went live. The same day, a third maintainer pushed commit 11c428d to the next branch:

fix(ses): emit List-Unsubscribe inside the header section, not the body

The raw MIME template in sendRawEmail produced a blank line between Content-Type and List-Unsubscribe whenever no custom headers were passed, because the ternary for custom headers expanded to an empty string surrounded by newlines.

Per RFC 5322 §2.1 a blank line terminates the header section, so List-Unsubscribe ended up as the first line of the body. Lenient clients (Gmail, Outlook) recover; strict clients (Thunderbird) do not, breaking one-click unsubscribe and degrading Gmail/Yahoo bulk-sender deliverability signals.

The author cites RFC 5322 section 2.1 by section number. The author explains that a blank line between header lines terminates the header section. The author traces the blank line to a template-literal interpolation that expanded to an empty string surrounded by newlines. The author walks through the consequence for downstream parsers.

That is a precise account of the bug class CVE-2026-34975 belongs to. The mechanism is identical: a value interpolated into the rawMessage template can break the header section. The fix in 11c428d addresses one input shape, an empty extraHeaders value. The fix does not generalize to "user input in any of the eight interpolations can produce a stray blank line on purpose, and two of the eight have no schema regex protecting against it."

The release that contains this fix is v0.10.0. v0.10.0 is the first release where every condition in this post is true at once: a maintainer in the same function citing the rule, a schema regex pass that covers six of eight fields, and two interpolations the regex pass walked past.

The patch architecture is the bug

This is the Design Debt Driver shape. The substrate is "raw MIME composed by string interpolation, with safety pushed to a separate input-validation layer." The schema is the chokepoint. The template is the sieve. Patches add regex to the chokepoint, one field at a time, as researchers find them. The sieve is unchanged.

The pattern's own framing applies cleanly: "Each patch closes the specific instance. The instance count keeps going up." The first patch closed one. The second patch closed four more. The next patch will close to.name and attachment.contentType after some researcher writes a PoC. The patch after that will close whichever new field gets added to /v1/send and forwarded into sendRawEmail by the developer who didn't think to add a regex. There is no architectural reason a CRLF check belongs in the JSON validation layer rather than the byte-construction layer; there is only the historical reason that the JSON validation layer was where the contentId fix happened to land in February.

The architectural alternative is structured MIME composition. Node's nodemailer, Python's email.mime, every framework that builds RFC 5322 messages with a header object and a setHeader(name, value) constructor escapes header values at the leaf. The construction layer enforces its own invariant. Adding a new field is then automatically safe, because the field passes through the builder, not around it. The patch did not take that path. The patch added a regex to the schema. Future fields will need their own regex too, on the day the developer who adds them happens to remember.

Three different authors touched sendRawEmail in three months. The first added a regex to contentId. The second added regexes to four more fields and shipped v0.8.0 on the same day. The third cited RFC 5322 section 2.1 in a commit message and patched the one input shape that produced the stray blank line their team had noticed. The same project that named the rule shipped the construction code that violates it for two of eight inputs. CVE-2026-34975 named four. The ninth field, whenever it's added, will look exactly like the eighth field looked the day before someone wrote a PoC for from.name.

PoC: romain-deperne/CVE-2026-34975

The schema added regex to six fields. The template interpolates eight.