GHSA-wmmp-3585-3rmp CVE-2026-92598
Nodemailer: IDN/Punycode domain allow-list bypass leads to email delivery to an attacker-controlled domain
- Severity
- 8.3 / 10
- High · CVSS 4.0
- Exploit chance (EPSS)
- 0.40%
- Chance of exploitation in the next 30 days. Higher than 33.75% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Fix
9.1.0- Upgrade nodemailer to this version or later
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:
// 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+00ADSOFT 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 nodemailer@9.0.6. No special configuration; the discrepancy is in domain normalization itself.
poc-idn.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.comdiffers from the deliveredxn--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 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 versions
npm
| Package | Vulnerable | Fixed in |
|---|---|---|
| nodemailer | < 9.1.0 |
9.1.0 |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
nodemailer npm → 9.1.0
- npm
npm install nodemailer@9.1.0- yarn
yarn add nodemailer@9.1.0- pnpm
pnpm add nodemailer@9.1.0
Severity breakdown
CVSS 4.0 8.3
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X
- Attack vector
- Network
- Attack complexity
- High
- Attack requirements
- Present
- Privileges required
- None
- User interaction
- None
- Confidentiality (vulnerable system)
- High
- Integrity (vulnerable system)
- Low
- Availability (vulnerable system)
- None
- Confidentiality (subsequent systems)
- None
- Integrity (subsequent systems)
- None
- Availability (subsequent systems)
- None
- Exploit maturity
- Not defined
- Scope
- X
CVSS 3.1 6.5
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N
- Attack vector
- Network
- Attack complexity
- High
- Privileges required
- None
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- High
- Integrity
- Low
- Availability
- None
Weakness
CWE-20: Improper Input Validation
The product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly.
How to prevent it
- Architecture and Design: Consider using language-theoretic security (LangSec) techniques that characterize inputs using a formal language and build "recognizers" for that language. This effectively requires parsing to be a distinct layer that effectively enforces a boundary between raw input and internal data representations, instead of allowing parser code to be scattered throughout the program, where it could be subject to errors or inconsistencies that create weaknesses. [REF-1109] [REF-1110] [REF-1111]
- Architecture and Design: Use an input validation framework such as Struts or the OWASP ESAPI Validation API. Note that using a framework does not automatically address all input validation problems; be mindful of weaknesses that could arise from misusing the framework itself (CWE-1173).
- Architecture and Design, Implementation: Understand all the potential areas where untrusted inputs can enter the product, including but not limited to: parameters or arguments, cookies, anything read from the network, environment variables, reverse DNS lookups, query results, request headers, URL components, e-mail, files, filenames, databases, and any external systems that provide data to the application. Remember that such inputs may be obtained indirectly through API calls.
- Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright.
- Architecture and Design: For any security checks that are performed on the client side, ensure that these checks are duplicated on the server side, in order to avoid CWE-602. Attackers can bypass the client-side checks by modifying values after the checks have been performed, or by changing the client to remove the client-side checks entirely. Then, these modified values would be submitted to the server. Even though client-side checks provide minimal benefits with respect to server-side security, they are still useful. First, they can support intrusion detection. If the server receives input that should have been rejected by the client, then it may be an indication of an attack. Second, client-side error-checking can provide helpful feedback to the user about the expectations for valid input. Third, there may be a reduction in server-side processing time for accidental input errors, although this is typically a small savings.
- Implementation: When your application combines data from multiple sources, perform the validation after the sources have been combined. The individual data elements may pass the validation step but violate the intended restrictions after they have been combined.
- Implementation: Be especially careful to validate all input when invoking code that crosses language boundaries, such as from an interpreted language to native code. This could create an unexpected interaction between the language boundaries. Ensure that you are not violating any of the expectations of the language with which you are interfacing. For example, even though Java may not be susceptible to buffer overflows, providing a large argument in a call to native code might trigger an overflow.
- Implementation: Directly convert your input type into the expected data type, such as using a conversion function that translates a string into a number. After converting to the expected data type, ensure that the input's values fall within the expected range of allowable values and that multi-field consistencies are maintained.
- Implementation: Inputs should be decoded and canonicalized to the application's current internal representation before being validated (CWE-180, CWE-181). Make sure that your application does not inadvertently decode the same input twice (CWE-174). Such errors could be used to bypass allowlist schemes by introducing dangerous inputs after they have been checked. Use libraries such as the OWASP ESAPI Canonicalization control. Consider performing repeated canonicalization until your input does not change any more. This will avoid double-decoding and similar scenarios, but it might inadvertently modify inputs that are allowed to contain properly-encoded dangerous content.
- Implementation: When exchanging data between components, ensure that both components are using the same character encoding. Ensure that the proper encoding is applied at each interface. Explicitly set the encoding you are using whenever the protocol allows you to do so.
CWE-436: Interpretation Conflict
Product A handles inputs or steps differently than Product B, which causes A to perform incorrect actions based on its perception of B's state.
References
Packages
Other links
- github.com/nodemailer/nodemailer/security/advisories/GHSA-wmmp-3585-3rmp
- github.com/nodemailer/nodemailer/pull/1848
- github.com/nodemailer/nodemailer/commit/259c32d7d266301e3377a212776c3fff993c0148
- github.com/nodemailer/nodemailer/commit/b212ac4e27bce8182478044fcb8d1642ccdad46e
- github.com/nodemailer/nodemailer/releases/tag/v9.1.0
- github.com/nodemailer/nodemailer/commit/259c32d
- github.com/nodemailer/nodemailer/commit/b212ac4
- vulncheck.com/advisories/nodemailer-before-9.1.0-idn-punycode-domain-allow-list-by…
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| GHSA-8c8m-p3m8-cjg8 Nodemailer: A nested comment ends early in addressparser, releasing the rest of the comment as a recipient | Medium | Also affects nodemailer |
| GHSA-m943-m63m-w62w Nodemailer: A ";" inside a quoted string or comment ends an address group early in addressparser, turning display text into a recipient | Medium | Also affects nodemailer |
| GHSA-85x2-q946-cp5h Nodemailer: A ">" inside a quoted local part ends an angle-addr early in addressparser, delivering to a domain the header does not name | Medium | Also affects nodemailer |
| GHSA-4g23-2xm8-66gc Nodemailer: Quadratic (O(n^2)) SMTP client reply parsing lets a malicious server or MITM cause a pre-auth remote denial of service | High | Also affects nodemailer |
| GHSA-39m8-27wv-hr27 Nodemailer: Quadratic (O(n^2)) DKIM header unfolding lets a large folded header cause remote denial of service during signing | High | Also affects nodemailer |
| GHSA-g73g-hqqh-jr95 Nodemailer: RFC 5322 comment inside an angle-bracket address is never stripped | Medium | Also affects nodemailer |
| GHSA-4ffr-jq9g-5ffx Nodemailer: Quadratic backtracking in SMTP AUTH mechanism detection regexes allows pre-auth remote denial of service | High | Also affects nodemailer |
| GHSA-8vvx-rff5-p5rq Nodemailer: Nested structured recipient arrays bypass the parser depth limit and cause stack exhaustion DoS | High | Also affects nodemailer |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor