← All news
security4 min read

Email header injection in AI-generated Node.js mailers

By Seaworthy · July 7, 2026

Close-up of the iOS mail app icon on an iPhone home screen

Photo by Brett Jordan on Unsplash

The vulnerability that should have died in 2003

Email header injection is not new. It was documented and patched in PHP's mail() function over two decades ago. But AI code assistants keep regenerating it, because their training data is full of naive contact form tutorials that concatenate user input directly into header fields.

The pattern looks harmless at first glance:

const nodemailer = require('nodemailer');

async function handleContactForm(req, res) {
  const { name, email, subject, message } = req.body;

  await transporter.sendMail({
    from: '[email protected]',
    to: '[email protected]',
    replyTo: email,
    subject: `Contact form: ${subject}`,
    text: message,
  });

  res.json({ ok: true });
}

Both replyTo and subject are exploitable. The replyTo field takes raw user input. The subject interpolates it. Neither is validated.

What CRLF injection actually does

Email headers are separated by carriage return + line feed sequences: \r\n. If you concatenate raw user input into a header value and the attacker submits a string containing \r\n, the email library may split that into separate headers.

Consider the replyTo field. If an attacker submits:

[email protected]\r\nBCC: [email protected],[email protected]

The raw message ends up like this:

From: [email protected]
To: [email protected]
Reply-To: [email protected]
BCC: [email protected],[email protected]
Subject: Contact form: hello

The library has just appended a new header. Depending on how the MTA handles it, those BCC addresses receive the email. Your contact form is now a spam relay, and your domain's reputation is paying for it.

The subject line is worse. It is often built from input that feels "safe" (a topic name, a product title). Inject \r\nFrom: [email protected] and you can rewrite the sender. That sets up a phishing email routed through your legitimate mail server, with your domain's SPF and DKIM records intact.

How common is the AI-generated version

Genuinely very common. When you ask a code assistant to "write a contact form in Node.js with nodemailer", most outputs look like the snippet above. The model has seen thousands of tutorials doing the same thing. It is not making a reasoning error. It is faithfully reproducing the median tutorial it was trained on.

Those tutorials were written fast, tested only with well-formed inputs, and never threat-modelled.

The fix

Validate email addresses before they touch a header, and strip control characters from anything being interpolated into a subject line.

import { validate as validateEmail } from 'email-validator';

async function handleContactForm(req, res) {
  const { name, email, subject, message } = req.body;

  if (!validateEmail(email)) {
    return res.status(400).json({ error: 'Invalid email address' });
  }

  const safeSubject = subject.replace(/[\r\n]/g, '');

  await transporter.sendMail({
    from: '[email protected]',
    to: '[email protected]',
    replyTo: { name: name.replace(/[\r\n]/g, ''), address: email },
    subject: `Contact form: ${safeSubject}`,
    text: message,
  });

  res.json({ ok: true });
}

Two things happening here. email-validator rejects addresses that contain \r\n because they are not valid RFC 5321 addresses. The subject is explicitly stripped of control characters before interpolation. For replyTo, passing a structured address object (with name and address as separate fields) forces nodemailer to treat them independently, so injection in the name portion cannot bleed into the address.

On provider-level protection

Some email providers (SendGrid, Postmark, SES) sanitise headers for you. Pass a string with \r\n and they strip or reject it server-side.

Do not rely on this. It is not a defence, it is an accident of implementation. You have no control over it. Swap providers, or the provider updates its behaviour, and your application silently becomes vulnerable. The fix belongs in your code.

The uncomfortable part

This is a five-minute fix. The reason it keeps appearing in AI-generated code is not that it is hard to solve. It is that code generation is optimistic by default. It generates the happy path. It does not imagine someone submitting [email protected]\r\nBCC: ... into your contact form because that is not what the tutorial was modelling.

Your job, before shipping AI-generated form handlers, is to ask: what happens if the input is adversarial? For anything that ends up in a header, the answer is: validate it, strip control characters, and use structured inputs where the library accepts them.

Seaworthy flags direct interpolation of request body values into nodemailer header fields as part of its data exposure checks.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode