{"id":"GHSA-wmmp-3585-3rmp","aliases":[],"url":"https://o3.security/vulnerability/GHSA-wmmp-3585-3rmp","summary":"Nodemailer: IDN/Punycode domain allow-list bypass leads to email delivery to an attacker-controlled domain","details":"### Summary\n\nNodemailer 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**.\n\nAn 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.\n\nBecause 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.\n\n### Details\n\n`lib/mime-node/index.js` → `_normalizeAddress(address)` (around lines 1307–1346) splits the address at the last `@` and normalizes the domain like this:\n\n```js\n// lib/mime-node/index.js\ntry {\n    if (/[\\x80-￿]/.test(user)) {\n        encodedDomain = punycode.toUnicode(domain.toLowerCase());   // line ~1338\n    } else {\n        encodedDomain = punycode.toASCII(domain.toLowerCase());     // line ~1340\n    }\n} catch (_err) {\n    // keep domain as supplied\n}\nreturn `${this._normalizeLocalPart(user)}@${encodedDomain}`;         // line ~1346\n```\n\n`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:\n\n* removing **Ignored** code points such as `U+00AD` SOFT HYPHEN,\n* **Mapping** full‑width / compatibility characters to their canonical ASCII forms,\n* Unicode **NFC** normalization,\n* validity checks.\n\nAs 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:\n\n* the SMTP envelope recipient written to the wire as `RCPT TO:<local@xn--…>` (`getEnvelope()` → `lib/smtp-connection/index.js` `_setEnvelope`), **and**\n* the address emitted in the `To:` / `From:` headers (`_convertAddresses`).\n\nSo 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):\n\n| recipient (raw) | UTS‑46 parser (`url.domainToASCII`) | Nodemailer delivers to |\n|---|---|---|\n| `victim@compa{U+00AD}ny.com` (invisible soft hyphen) | `company.com` | `xn--company-pka.com` |\n| `victim@ｃｏｍｐａｎｙ.com` (full‑width) | `company.com` | `xn--mi7cd4afch9d.com` |\n| `user@exámple.com` (NFD `a`+U+0301) | `xn--exmple-qta.com` | `xn--example-vge.com` |\n\nThis 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.\n\n**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)`).\n\n### PoC\n\nEnvironment: Node.js ≥ 18, the published `nodemailer@9.0.6`. No special configuration; the discrepancy is in domain normalization itself.\n\n`poc-idn.js`:\n\n```js\n'use strict';\nconst net = require('net');\nconst url = require('url');\nconst nodemailer = require('nodemailer'); // 9.0.6\n\nconst TRUSTED   = 'company.com';                        // the only domain the app will mail\nconst RECIPIENT = 'victim@compa\\u00ADny.com';           // attacker input: invisible U+00AD inside \"company\"\n\n// The app's domain allow-list check, done the standard (UTS-46 / browser / WHATWG) way:\nconst seen = url.domainToASCII(RECIPIENT.split('@').pop());\nconsole.log('validator (url.domainToASCII) sees:', JSON.stringify(seen),\n            seen === TRUSTED ? '=> ALLOWED (equals trusted domain)' : '');\n\n// A tiny SMTP sink that prints the literal RCPT TO Nodemailer transmits:\nconst server = net.createServer(sock => {\n  let buf = ''; sock.write('220 sink\\r\\n');\n  sock.on('data', d => { buf += d; let i;\n    while ((i = buf.indexOf('\\r\\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 2);\n      const u = line.toUpperCase();\n      if (u.startsWith('EHLO')) sock.write('250-sink\\r\\n250 8BITMIME\\r\\n');\n      else if (u.startsWith('RCPT')) { console.log('nodemailer transmits             :', line); sock.write('250 ok\\r\\n'); }\n      else if (u.startsWith('DATA')) sock.write('354 go\\r\\n');\n      else if (line === '.') sock.write('250 ok\\r\\n');\n      else if (u.startsWith('QUIT')) { sock.write('221 bye\\r\\n'); sock.end(); }\n      else sock.write('250 ok\\r\\n'); } });\n});\nserver.listen(0, '127.0.0.1', async () => {\n  const t = nodemailer.createTransport({ host: '127.0.0.1', port: server.address().port, secure: false });\n  await t.sendMail({ from: 'app@company.com', to: RECIPIENT, subject: 'reset your password', text: 'secret link' });\n  t.close(); server.close();\n});\n```\n\nRun:\n\n```\nnpm init -y && npm install nodemailer@9.0.6\nnode poc-idn.js\n```\n\nActual output (Nodemailer 9.0.6):\n\n```\nvalidator (url.domainToASCII) sees: \"company.com\" => ALLOWED (equals trusted domain)\nnodemailer transmits             : RCPT TO:<victim@xn--company-pka.com>\n```\n\nThe 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.\n\nA 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`.\n\n### Impact\n\nAny 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:\n  * recipient **allow‑list / block‑list / “same corporate domain” checks** implemented with `new URL()`, `url.domainToASCII`, a browser‑side check, or an IDN library;\n  * flows that **display or log the normalized recipient domain** for human confirmation (the shown `company.com` differs from the delivered `xn--company-pka.com`);\n  * any domain‑gated feature (employee‑only registration, “send only to our tenant”, notification routing).\n\n## Patched in 9.1.0\n\nDomain 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.\n\nOne 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.\n\nOn 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.","published":"2026-09-08T21:33:32Z","modified":"2026-09-08T21:45:05.628621316Z","cvss":{"score":6.5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"nodemailer","fixedVersion":"9.1.0"}],"fix":{"url":"https://github.com/nodemailer/nodemailer/pull/1848","label":"nodemailer/nodemailer#1848"},"references":[{"type":"WEB","url":"https://github.com/nodemailer/nodemailer/security/advisories/GHSA-wmmp-3585-3rmp"},{"type":"WEB","url":"https://github.com/nodemailer/nodemailer/pull/1848"},{"type":"WEB","url":"https://github.com/nodemailer/nodemailer/commit/259c32d7d266301e3377a212776c3fff993c0148"},{"type":"WEB","url":"https://github.com/nodemailer/nodemailer/commit/b212ac4e27bce8182478044fcb8d1642ccdad46e"},{"type":"PACKAGE","url":"https://github.com/nodemailer/nodemailer"},{"type":"WEB","url":"https://github.com/nodemailer/nodemailer/releases/tag/v9.1.0"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-08T21:45:05.628621316Z"}}