GHSA-wmmp-3585-3rmp: Input Validation

Published Sep 8, 2026
·
Updated

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 idnaconvert 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), so victim@compa­ny.com resolves to company.com, matching url.domainToASCII and browsers.

One caveat on the suggested remediation, hardened in 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 Software

1 affected componentFixes available
npm/nodemailer<9.1.0
9.1.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/nodemailer to a version that resolves this vulnerability.

    Fixed in 9.1.0
  2. Upgrade

    Upgrade nodemailer to a version that resolves this vulnerability.

    Fixed in 9.1.0
  3. Configuration

    Ensure Nodemailer’s domain normalization applies UTS-46 mapping/compatibility processing before Punycode encoding (the report notes Nodemailer currently only `.toLowerCase()`s and uses bundled RFC-3492 `punycode.toASCII`, leading to A-label mismatches). Use the runtime’s `url.domainToASCII()` for the deliverable A-label to match browsers/validators/DNS-facing resolvers.

    Nodemailer domain normalization = Use the runtime URL host parser (`url.domainToASCII`) with matching UTS-46 processing instead of the bundled raw RFC-3492 `punycode.toASCII` codec
  4. Compensating control

    Run a domain normalization consistency check: compare the recipient domain computed by your app’s validator/parser (e.g., `url.domainToASCII(...)`) against the domain/punycode Nodemailer will deliver (e.g., what appears on the wire as `RCPT TO:<...>`). If they differ, reject the recipient.

Event History

Sep 8, 2026
Advisory Published
via GitHub·09:33 PM
Data Sourced
via GitHub·09:33 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which applications are realistically exposed to unintended delivery?

Applications are exposed when they validate or allow-list recipient domains with a UTS-46-conformant IDN parser, or display that parser's normalized recipient for approval, and then pass the address to Nodemailer for delivery. The validation can identify a domain as trusted while Nodemailer delivers to a different attacker-registrable Punycode domain.

2

What does an attacker need to exploit this issue?

An attacker needs to supply a recipient address whose internationalized domain produces different results in a standards-compliant UTS-46 parser and Nodemailer's raw RFC-3492 Punycode processing. The mismatch can be triggered using an invisible U+00AD SOFT HYPHEN, which UTS-46 folds away to the trusted domain.

3

Does the previously mentioned 7.0.7 fix address this behavior?

No. The advisory states that this IDN/Punycode interpretation mismatch is not addressed by the 7.0.7 fix.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203