GitHub Advisoryadvisory · npm
GHSA-wmmp-3585-3rmp Nodemailer: IDN/Punycode domain allow-list bypass leads to email delivery to an attacker-controlled domain
MEDIUM
Official page ↗
- Ecosystem
- npm
- Packages
- nodemailer
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N
- Weaknesses
- CWE-20, CWE-436
- Reviewed
- 2026-09-08
Affected packages
• npm nodemailer: < 9.1.0 → fixed in 9.1.0
Description
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:` (`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@company.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 `[email protected]`. 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: '[email protected]', to: RECIPIENT, subject: 'reset your password', text: 'secret link' });
t.close(); server.close();
});
```
Run:
```
npm init -y && npm install [email protected]
node poc-idn.js
```
Actual output (Nodemailer 9.0.6):
```
validator (url.domainToASCII) sees: "company.com" => ALLOWED (equals trusted domain)
nodemailer transmits : RCPT TO:
```
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 `` 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:` 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), so `victim@company.com` resolves to `company.com`, matching `url.domainToASCII` and browsers.
One caveat on the suggested remedi…
Related
References
- https://github.com/nodemailer/nodemailer/security/advisories/GHSA-wmmp-3585-3rmp web
- https://github.com/nodemailer/nodemailer/pull/1848 web
- https://github.com/nodemailer/nodemailer/commit/259c32d7d266301e3377a212776c3fff993c0148 web
- https://github.com/nodemailer/nodemailer/commit/b212ac4e27bce8182478044fcb8d1642ccdad46e web
- https://github.com/nodemailer/nodemailer package
- https://github.com/nodemailer/nodemailer/releases/tag/v9.1.0 web