Kelly Michels 5 min read Security

SMTP Header Injection via Form Fields

Why .trim() and HTML stripping aren't enough.

Your contact form strips HTML tags and trims whitespace, then feeds the result straight to SMTP. An attacker who knows about newlines (CRLF) can inject email headers like Bcc:, Cc:, and Subject:, turning your form into a mass-mailing tool.

The attack

Your contact form has a Name field. Attacker submits:

Kelly Michels
Bcc: spam-list@attacker.com
Subject: You've been hacked

Your backend does this:

const name = strip(req.body.name)  // Removes HTML tags
const message = `To: you@example.com\nFrom: form@example.com\nSubject: Contact Form\n\n${name}`
await sendSMTP(message)

The SMTP server receives:

To: you@example.com
From: form@example.com
Subject: Contact Form

Kelly Michels
Bcc: spam-list@attacker.com
Subject: You've been hacked

The attacker's Bcc: header is processed as a real SMTP header. The message gets sent to both your inbox AND the attacker's spam list, silently.

Why it happens

SMTP uses newlines (\r\n, "CRLF") to separate headers from message body. The protocol has no concept of escaping: if your header value contains a newline, the SMTP parser treats everything after it as a new header.

Most sanitization functions remove HTML tags but leave newlines intact:

const strip = s => String(s ?? '').replace(/<[^>]*>/g, '').trim()
// Input:  "Kelly\r\nBcc: attacker@evil.com"
// Output: "Kelly\r\nBcc: attacker@evil.com" ← Newlines still there!

When that string gets embedded in email headers, the newlines become header boundaries, and the injected headers execute.

The fix: strip CRLF characters

Add one line to the sanitization function:

const strip = s => String(s ?? '')
  .replace(/<[^>]*>/g, '')      // Remove HTML tags
  .replace(/[\r\n]/g, '')        // Remove carriage returns & newlines
  .trim()

Now CRLF injection is impossible. The attacker's payload becomes:

KellyBcc: spam-list@attacker.com

That's just plain text in the message body — no injected headers, no bypass.

Verification

To confirm the fix is applied, check that your form sanitization removes both HTML AND newlines:

const testInput = "Name\r\nBcc: attacker@evil.com"
const result = strip(testInput)
console.assert(
  !result.includes('\n') && !result.includes('\r'),
  'Sanitization must remove CRLF'
)

If your form backend uses a different library (nodemailer, SendGrid, etc.), check that you're sanitizing user inputs BEFORE passing them to the mail library, not relying on the library to do it for you.

← Back to Blog