[{"data":1,"prerenderedAt":827},["ShallowReactive",2],{"blog-vulnerabilities/nodemailer-address-parser-differential":3},{"id":4,"title":5,"body":6,"category":797,"date":683,"dateModified":683,"description":798,"draft":799,"extension":800,"faq":801,"featured":799,"headerVariant":812,"image":813,"keywords":814,"meta":815,"navigation":178,"ogDescription":816,"ogTitle":813,"path":817,"readTime":818,"schemaOrg":819,"schemaType":820,"seo":821,"sitemap":822,"stem":823,"tags":824,"twitterCard":825,"__hash__":826},"blog/blog/vulnerabilities/nodemailer-address-parser-differential.md","Nodemailer Sent Mail to the Wrong Domain: We Tested It (2026)",{"type":7,"value":8,"toc":788},"minimark",[9,13,16,32,37,40,68,85,89,104,312,315,371,381,390,394,397,455,466,478,500,504,512,531,542,552,555,559,570,576,582,591,595,699,702,754,772,784],[10,11,12],"p",{},"Nodemailer shipped 9.1.0 on 2026-08-31 and two advisories landed the next day. Both say a version of the same thing: the recipient domain your code checked is not the recipient domain nodemailer hands to the mail server.",[10,14,15],{},"We had a reason to care beyond the advisory. A week earlier we published a post about an Auth.js magic-link flaw and said plainly that nobody had tested whether nodemailer completes that attack chain. So we installed both versions and measured it.",[17,18,19],"tldr",{},[10,20,21,22,26,27,31],{},"Both advisories are real and nodemailer 9.1.0 fixes both, confirmed against the actual SMTP envelope rather than the parser alone. Two things worth knowing that the advisories do not say. The crude validator was the safe one: an ",[23,24,25],"code",{},"endsWith(\"@company.com\")"," check blocked the comment payload, while a standards-compliant one waved it through. And nodemailer does ",[28,29,30],"strong",{},"not"," apply NFKC normalization, so the Auth.js homoglyph attack does not complete through it.",[33,34,36],"h2",{"id":35},"what-the-two-advisories-say","What the two advisories say",[10,38,39],{},"Disclosed 2026-09-01, both CVSS 6.5 Moderate, both fixed in 9.1.0, neither carrying a CVE. Note that the GitHub Advisory Database only ingested them on 2026-09-08, so any tool reporting that later date is showing you the filing date rather than the disclosure.",[10,41,42,51,52,55,56,59,60,63,64,67],{},[28,43,44],{},[45,46,50],"a",{"href":47,"rel":48},"https://github.com/advisories/GHSA-cc9r-2j5m-2m83",[49],"nofollow","GHSA-cc9r-2j5m-2m83",", affecting ",[23,53,54],{},">=6.9.16 \u003C9.1.0",". RFC 5322 permits comments in parentheses inside an address. When a closing parenthesis is immediately followed by a non-break character, nodemailer's parser sets ",[23,57,58],{},"noBreak = true"," and concatenates what follows onto the preceding token. So ",[23,61,62],{},"user@good-corp.com(x)evil.com"," becomes ",[23,65,66],{},"user@good-corp.comevil.com",".",[10,69,70,51,77,80,81,84],{},[28,71,72],{},[45,73,76],{"href":74,"rel":75},"https://github.com/advisories/GHSA-wmmp-3585-3rmp",[49],"GHSA-wmmp-3585-3rmp",[23,78,79],{},"\u003C9.1.0",". Nodemailer bundled a raw RFC 3492 punycode codec with no UTS-46 mapping, while browsers and Node's own ",[23,82,83],{},"url.domainToASCII"," do apply UTS-46. Put a soft hyphen (U+00AD) inside a domain and the two disagree about what domain it even is.",[33,86,88],{"id":87},"what-we-measured","What we measured",[10,90,91,92,95,96,99,100,103],{},"Rather than trust the summaries, we installed ",[23,93,94],{},"nodemailer@9.0.0"," and ",[23,97,98],{},"nodemailer@9.1.0"," and sent through ",[23,101,102],{},"jsonTransport",", which builds the real message and envelope without delivering it. The envelope is what matters. It's the address handed to the SMTP server, and it's the one the advisories are about.",[105,106,108],"code-block",{"label":107},"The test",[109,110,115],"pre",{"className":111,"code":112,"language":113,"meta":114,"style":114},"language-js shiki shiki-themes github-dark","const nodemailer = require('nodemailer');\nconst t = nodemailer.createTransport({ jsonTransport: true });\n\n// U+00AD SOFT HYPHEN sits between \"compa\" and \"ny\"\nconst payloads = [\n  'staff@company.com(x)evil.com',\n  'staff@compa­ny.com',\n];\n\nfor (const to of payloads) {\n  const info = await t.sendMail({ from: 'app@example.com', to, subject: 'x', text: 'y' });\n  console.log(to, '->', info.envelope.to);\n}\n","js","",[23,116,117,148,173,180,187,200,209,217,223,228,248,288,306],{"__ignoreMap":114},[118,119,122,126,130,133,137,141,145],"span",{"class":120,"line":121},"line",1,[118,123,125],{"class":124},"snl16","const",[118,127,129],{"class":128},"sDLfK"," nodemailer",[118,131,132],{"class":124}," =",[118,134,136],{"class":135},"svObZ"," require",[118,138,140],{"class":139},"s95oV","(",[118,142,144],{"class":143},"sU2Wk","'nodemailer'",[118,146,147],{"class":139},");\n",[118,149,151,153,156,158,161,164,167,170],{"class":120,"line":150},2,[118,152,125],{"class":124},[118,154,155],{"class":128}," t",[118,157,132],{"class":124},[118,159,160],{"class":139}," nodemailer.",[118,162,163],{"class":135},"createTransport",[118,165,166],{"class":139},"({ jsonTransport: ",[118,168,169],{"class":128},"true",[118,171,172],{"class":139}," });\n",[118,174,176],{"class":120,"line":175},3,[118,177,179],{"emptyLinePlaceholder":178},true,"\n",[118,181,183],{"class":120,"line":182},4,[118,184,186],{"class":185},"sAwPA","// U+00AD SOFT HYPHEN sits between \"compa\" and \"ny\"\n",[118,188,190,192,195,197],{"class":120,"line":189},5,[118,191,125],{"class":124},[118,193,194],{"class":128}," payloads",[118,196,132],{"class":124},[118,198,199],{"class":139}," [\n",[118,201,203,206],{"class":120,"line":202},6,[118,204,205],{"class":143},"  'staff@company.com(x)evil.com'",[118,207,208],{"class":139},",\n",[118,210,212,215],{"class":120,"line":211},7,[118,213,214],{"class":143},"  'staff@compa­ny.com'",[118,216,208],{"class":139},[118,218,220],{"class":120,"line":219},8,[118,221,222],{"class":139},"];\n",[118,224,226],{"class":120,"line":225},9,[118,227,179],{"emptyLinePlaceholder":178},[118,229,231,234,237,239,242,245],{"class":120,"line":230},10,[118,232,233],{"class":124},"for",[118,235,236],{"class":139}," (",[118,238,125],{"class":124},[118,240,241],{"class":128}," to",[118,243,244],{"class":124}," of",[118,246,247],{"class":139}," payloads) {\n",[118,249,251,254,257,259,262,265,268,271,274,277,280,283,286],{"class":120,"line":250},11,[118,252,253],{"class":124},"  const",[118,255,256],{"class":128}," info",[118,258,132],{"class":124},[118,260,261],{"class":124}," await",[118,263,264],{"class":139}," t.",[118,266,267],{"class":135},"sendMail",[118,269,270],{"class":139},"({ from: ",[118,272,273],{"class":143},"'app@example.com'",[118,275,276],{"class":139},", to, subject: ",[118,278,279],{"class":143},"'x'",[118,281,282],{"class":139},", text: ",[118,284,285],{"class":143},"'y'",[118,287,172],{"class":139},[118,289,291,294,297,300,303],{"class":120,"line":290},12,[118,292,293],{"class":139},"  console.",[118,295,296],{"class":135},"log",[118,298,299],{"class":139},"(to, ",[118,301,302],{"class":143},"'->'",[118,304,305],{"class":139},", info.envelope.to);\n",[118,307,309],{"class":120,"line":308},13,[118,310,311],{"class":139},"}\n",[10,313,314],{},"Results, run 2026-09-09:",[316,317,318,334],"table",{},[319,320,321],"thead",{},[322,323,324,328,331],"tr",{},[325,326,327],"th",{},"Payload",[325,329,330],{},"9.0.0 envelope",[325,332,333],{},"9.1.0 envelope",[335,336,337,355],"tbody",{},[322,338,339,345,350],{},[340,341,342],"td",{},[23,343,344],{},"staff@company.com(x)evil.com",[340,346,347],{},[23,348,349],{},"staff@company.comevil.com",[340,351,352],{},[23,353,354],{},"staff@company.com",[322,356,357,362,367],{},[340,358,359],{},[23,360,361],{},"staff@compa\u003CU+00AD>ny.com",[340,363,364],{},[23,365,366],{},"staff@xn--company-pka.com",[340,368,369],{},[23,370,354],{},[10,372,373,374,95,377,380],{},"Both bugs reproduce exactly as described, and both are closed in 9.1.0. ",[23,375,376],{},"company.comevil.com",[23,378,379],{},"xn--company-pka.com"," are ordinary domains that anyone can register.",[382,383,384],"danger-box",{},[10,385,386,389],{},[28,387,388],{},"The password reset is the version that hurts."," An allowlist bypass on signup gets a stranger an account. The same primitive pointed at a password reset gets them somebody else's account, because the reset token is in the mail and the mail went to a domain they own. If your app restricts anything by email domain, this is the flow to check first.",[33,391,393],{"id":392},"the-finding-the-advisories-do-not-mention","The finding the advisories do not mention",[10,395,396],{},"We also ran each payload past the three domain checks an app like this actually contains, to see which ones the attack gets past.",[316,398,399,416],{},[319,400,401],{},[322,402,403,406,411],{},[325,404,405],{},"Validator",[325,407,408],{},[23,409,410],{},"company.com(x)evil.com",[325,412,413],{},[23,414,415],{},"compa\u003CU+00AD>ny.com",[335,417,418,430,441],{},[322,419,420,425,428],{},[340,421,422],{},[23,423,424],{},"email.endsWith(\"@company.com\")",[340,426,427],{},"blocked",[340,429,427],{},[322,431,432,437,439],{},[340,433,434],{},[23,435,436],{},"email.split(\"@\")[1] === \"company.com\"",[340,438,427],{},[340,440,427],{},[322,442,443,448,450],{},[340,444,445],{},[23,446,447],{},"domainToASCII(email.split(\"@\")[1]) === \"company.com\"",[340,449,427],{},[340,451,452],{},[28,453,454],{},"allowed",[10,456,457,458,461,462,465],{},"Read the first column. The comment payload is stopped by every check we tried, including the two crude ones, because the raw string genuinely does not end in ",[23,459,460],{},"@company.com",". To be exploited, that bug needs a validator that parses RFC 5322 comments correctly and therefore agrees the domain is ",[23,463,464],{},"company.com",", while nodemailer disagrees. That means a proper address-parsing library, not a string comparison.",[10,467,468,469,472,473,475,476,67],{},"Now the second column. The soft-hyphen payload walks past exactly one validator, and it's the good one. ",[23,470,471],{},"domainToASCII"," applies UTS-46, sees ",[23,474,464],{},", and approves. Nodemailer 9.0.0 then delivers to ",[23,477,379],{},[479,480,482],"lesson-box",{"title":481},"The careful developer was the exposed one",[10,483,484,485,489,490,492,493,496,497,499],{},"Both bypasses need your validator to be ",[486,487,488],"em",{},"more"," standards-compliant than your sender. A founder who wrote ",[23,491,424],{}," because they didn't know any better was accidentally safe from both. A founder who normalized properly with ",[23,494,495],{},"new URL()"," or ",[23,498,471],{}," was the one who could be hit. That is not an argument for writing worse code. It's the reason the fix isn't \"validate harder\".",[33,501,503],{"id":502},"about-the-authjs-magic-link-chain","About the Auth.js magic-link chain",[10,505,506,507,511],{},"Our post on the ",[45,508,510],{"href":509},"/blog/vulnerabilities/nextauth-magic-link-homoglyph","Auth.js magic-link homoglyph flaw"," said the attack needs the downstream mail sender to apply Unicode NFKC normalization, that the advisory named no sender that does, and that nobody had published results for nodemailer. We can close the nodemailer half now.",[10,513,514,515,518,519,522,523,526,527,530],{},"The payload is an address with a fullwidth commercial at, U+FF20, which NFKC folds into a real ",[23,516,517],{},"@",". Send ",[23,520,521],{},"victim＠company.com@evil.com"," and a normalizing sender would turn it into ",[23,524,525],{},"victim@company.com"," plus a trailing ",[23,528,529],{},"@evil.com",", changing which domain receives the mail.",[105,532,534],{"label":533},"The homoglyph payload, both versions",[109,535,540],{"className":536,"code":538,"language":539},[537],"language-text","nodemailer 9.0.0  envelope.to = [\"victim＠company.com@evil.com\"]\nnodemailer 9.1.0  envelope.to = [\"victim＠company.com@evil.com\"]\n","text",[23,541,538],{"__ignoreMap":114},[10,543,544,545,548,549],{},"The fullwidth character survives untouched in both versions. The domain stays ",[23,546,547],{},"evil.com",", which is where the mail was always going, so nothing is redirected and nothing is gained. ",[28,550,551],{},"Nodemailer does not NFKC-normalize recipient addresses, so the Auth.js homoglyph attack does not complete through it.",[10,553,554],{},"That's a negative result and it's worth publishing precisely because it's negative. If you run Auth.js with the nodemailer provider, that particular chain is not your problem. Resend, SendGrid and Postmark remain untested, by us and by anyone else we can find.",[33,556,558],{"id":557},"what-to-do","What to do",[560,561,563],"step",{"number":562},"1",[10,564,565,566,569],{},"Check your version: ",[23,567,568],{},"npm ls nodemailer",". Anything below 9.1.0 has both bugs. Nodemailer 10.0.1 is current, so upgrade there unless something pins you back.",[560,571,573],{"number":572},"2",[10,574,575],{},"Search your code for a decision made on the recipient domain. Invite-only signup gated to one company, a \"corporate accounts only\" branch, a support inbox routing rule. No domain decision means nothing to bypass, and you can stop here.",[560,577,579],{"number":578},"3",[10,580,581],{},"Where you find one, make it normalize once and send to the normalized value. Don't validate a parsed form and then hand the raw user input to your mail library, which is the shape both of these advisories punish.",[583,584,585],"tip-box",{},[10,586,587,590],{},[28,588,589],{},"A one-line check that catches the whole class."," After normalizing, compare the normalized address to the raw input. If they differ, either reject it or send to the normalized version. You never want the string you inspected and the string you deliver to be two different strings.",[33,592,594],{"id":593},"sources","Sources",[316,596,597,610],{},[319,598,599],{},[322,600,601,604,607],{},[325,602,603],{},"Claim",[325,605,606],{},"Source",[325,608,609],{},"Date",[335,611,612,628,642,659,673,684],{},[322,613,614,620,625],{},[340,615,616,617,619],{},"RFC 5322 comment concatenation, ",[23,618,54],{},", CVSS 6.5, no CVE",[340,621,622],{},[45,623,50],{"href":47,"rel":624},[49],[340,626,627],{},"disclosed 2026-09-01",[322,629,630,635,640],{},[340,631,632,633,619],{},"Raw RFC 3492 punycode with no UTS-46 mapping, ",[23,634,79],{},[340,636,637],{},[45,638,76],{"href":74,"rel":639},[49],[340,641,627],{},[322,643,644,649,656],{},[340,645,646,648],{},[23,647,98],{}," publication time",[340,650,651],{},[45,652,655],{"href":653,"rel":654},"https://registry.npmjs.org/nodemailer",[49],"npm registry",[340,657,658],{},"2026-08-31T11:29:12Z",[322,660,661,665,670],{},[340,662,663,648],{},[23,664,94],{},[340,666,667],{},[45,668,655],{"href":653,"rel":669},[49],[340,671,672],{},"2026-06-14T17:57:22Z",[322,674,675,678,681],{},[340,676,677],{},"Envelope results, validator matrix, homoglyph result",[340,679,680],{},"CheckYourVibe, run against 9.0.0 and 9.1.0",[340,682,683],{},"2026-09-09",[322,685,686,689,696],{},[340,687,688],{},"Auth.js homoglyph flaw and its NFKC precondition",[340,690,691],{},[45,692,695],{"href":693,"rel":694},"https://github.com/advisories/GHSA-7rqj-j65f-68wh",[49],"GHSA-7rqj-j65f-68wh",[340,697,698],{},"disclosed 2026-07-23",[10,700,701],{},"Both advisories were ingested into the GitHub Advisory Database on 2026-09-08, a week after disclosure. We use the disclosure date throughout.",[703,704,705,712,718,736,748],"faq-section",{},[706,707,709],"faq-item",{"question":708},"What are GHSA-cc9r-2j5m-2m83 and GHSA-wmmp-3585-3rmp?",[10,710,711],{},"Two nodemailer advisories disclosed on 2026-09-01, both CVSS 6.5 Moderate, both fixed in 9.1.0, and neither has a CVE assigned. The first is an RFC 5322 comment parsing bug affecting 6.9.16 up to 9.1.0. The second is an internationalized-domain bug affecting everything below 9.1.0, where nodemailer used a raw RFC 3492 punycode codec with no UTS-46 mapping. Both end in the same place: the domain your code approved isn't the domain the mail server was handed.",[706,713,715],{"question":714},"Am I actually affected if I run nodemailer below 9.1.0?",[10,716,717],{},"Only if your app decides something based on the recipient domain, such as an invite-only signup restricted to one company, and then passes the same raw string to nodemailer. If you send mail to whatever address a user typed with no domain check, there's nothing to bypass. Upgrade anyway, since it's one version bump.",[706,719,721],{"question":720},"Does upgrading to 9.1.0 fix both problems?",[10,722,723,724,726,727,729,730,732,733,735],{},"Yes. We measured the SMTP envelope directly on 9.0.0 and 9.1.0. On 9.0.0 the comment payload was delivered to ",[23,725,376],{}," and the soft-hyphen payload to ",[23,728,379],{},". On 9.1.0 both resolved to ",[23,731,464],{},", matching what a browser and Node's own ",[23,734,83],{}," see. Nodemailer 10 is current, so most projects should go there instead.",[706,737,739],{"question":738},"Does this mean the Auth.js magic-link homoglyph bug is exploitable through nodemailer?",[10,740,741,742,744,745,747],{},"No, and this is the useful negative result. That attack needs the downstream sender to apply NFKC normalization so a fullwidth commercial at (U+FF20) folds into a real ",[23,743,517],{},". We passed that exact payload through nodemailer 9.0.0 and 9.1.0 and the fullwidth character survived untouched in both, so the domain stayed ",[23,746,547],{}," and the chain doesn't complete. Nodemailer does not NFKC-normalize recipient addresses.",[706,749,751],{"question":750},"Which domain check should I use for an invite-only signup?",[10,752,753],{},"Compare against a normalized form and then send to that same normalized string, never to the raw input. The bug class here isn't any one parser being wrong, it's your app using two different parsers: one to decide and one to deliver. Normalize once, make the decision on the result, and pass the result downstream.",[755,756,757,762,767],"related-articles",{},[758,759],"related-card",{"description":760,"href":509,"title":761},"The open question this post answers for nodemailer, and what stays unmeasured.","The Auth.js Magic-Link Homoglyph Flaw",[758,763],{"description":764,"href":765,"title":766},"Token generation, single use, expiry, and the out-of-band hop.","/blog/how-to/magic-links","Magic Links Done Right",[758,768],{"description":769,"href":770,"title":771},"The other way an Auth.js misconfiguration lets the wrong person in.","/blog/vulnerabilities/next-auth-v5-fail-open","NextAuth v5 Fail-Open",[773,774,777,781],"cta-box",{"href":775,"label":776},"/","Start Free Scan",[33,778,780],{"id":779},"does-your-signup-trust-the-domain","Does Your Signup Trust the Domain?",[10,782,783],{},"CheckYourVibe probes your deployed app's signup and reset flows with addresses that look like one domain and resolve to another.",[785,786,787],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":114,"searchDepth":150,"depth":150,"links":789},[790,791,792,793,794,795,796],{"id":35,"depth":150,"text":36},{"id":87,"depth":150,"text":88},{"id":392,"depth":150,"text":393},{"id":502,"depth":150,"text":503},{"id":557,"depth":150,"text":558},{"id":593,"depth":150,"text":594},{"id":779,"depth":150,"text":780},"vulnerabilities","Two advisories say nodemailer under 9.1.0 delivers to a different domain than your validator approved. We ran both payloads through the real SMTP envelope. Here is what actually happens.",false,"md",[802,804,806,808,810],{"question":708,"answer":803},"Two nodemailer advisories disclosed on 2026-09-01, both CVSS 6.5 Moderate, both fixed in nodemailer 9.1.0, and neither has a CVE assigned. The first is an RFC 5322 comment parsing bug affecting 6.9.16 up to 9.1.0. The second is an internationalized-domain bug affecting everything below 9.1.0, where nodemailer used a raw RFC 3492 punycode codec with no UTS-46 mapping. Both end in the same place: the domain your code approved is not the domain the mail server was handed.",{"question":714,"answer":805},"Only if your app decides something based on the recipient domain, such as an invite-only signup restricted to one company, and then passes the same raw string to nodemailer. If you send mail to whatever address a user typed with no domain check, there is nothing to bypass. Upgrade anyway, since it is one version bump.",{"question":720,"answer":807},"Yes. We measured the SMTP envelope directly on 9.0.0 and 9.1.0. On 9.0.0 the comment payload was delivered to company.comevil.com and the soft-hyphen payload to xn--company-pka.com. On 9.1.0 both resolved to company.com, matching what a browser and Node's own url.domainToASCII see. Nodemailer 10 is current, so most projects should go there instead.",{"question":738,"answer":809},"No, and this is the useful negative result. That attack needs the downstream sender to apply NFKC normalization so a fullwidth commercial at (U+FF20) folds into a real @. We passed that exact payload through nodemailer 9.0.0 and 9.1.0 and the fullwidth character survived untouched in both, so the domain stayed evil.com and the chain does not complete. Nodemailer does not NFKC-normalize recipient addresses.",{"question":750,"answer":811},"Compare against a normalized form and then send to that same normalized string, never to the raw input. The bug class here is not any one parser being wrong, it is your app using two different parsers: one to decide and one to deliver. Normalize once, make the decision on the result, and pass the result downstream.","red",null,"nodemailer vulnerability, GHSA-cc9r-2j5m-2m83, GHSA-wmmp-3585-3rmp, nodemailer 9.1.0, nodemailer sent email to wrong domain, email domain allowlist bypass, punycode UTS-46 email, nodemailer security",{},"We installed nodemailer 9.0.0 and 9.1.0 and measured the envelope. One payload reaches an attacker domain, one does not, and the crude validator was the safe one.","/blog/vulnerabilities/nodemailer-address-parser-differential","9 min read","[object Object]","Article",{"title":5,"description":798},{"loc":817},"blog/vulnerabilities/nodemailer-address-parser-differential",[],"summary_large_image","HAHvht7uPuZMWZJ0lMqJn1TMxeKQ31bOqh6WVLxZCHA",1789672845340]