[object Object]

← back to George Gmail

Patch Nodemailer file and URL access advisory

50764ccb0daa6e236e5d9c47812145c834b4bc43 · 2026-08-28 21:30:20 -0700 · Steve Abrams

Files touched

Diff

commit 50764ccb0daa6e236e5d9c47812145c834b4bc43
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 28 21:30:20 2026 -0700

    Patch Nodemailer file and URL access advisory
---
 lib/attachment-mime.js           | 17 +++++++++++
 package-lock.json                |  8 ++---
 package.json                     |  4 +--
 server.js                        |  9 ++----
 test/nodemailer-security.test.js | 52 +++++++++++++++++++++++++++++++
 verification/e2e-proof.json      | 66 +++++++++++++++++++---------------------
 6 files changed, 108 insertions(+), 48 deletions(-)

diff --git a/lib/attachment-mime.js b/lib/attachment-mime.js
new file mode 100644
index 0000000..5004899
--- /dev/null
+++ b/lib/attachment-mime.js
@@ -0,0 +1,17 @@
+'use strict';
+
+const nodemailer = require('nodemailer');
+
+const transport = nodemailer.createTransport({
+  streamTransport: true,
+  buffer: true,
+  disableFileAccess: true,
+  disableUrlAccess: true,
+});
+
+async function buildAttachmentMime(mailOptions) {
+  const info = await transport.sendMail(mailOptions);
+  return info.message;
+}
+
+module.exports = { buildAttachmentMime };
diff --git a/package-lock.json b/package-lock.json
index 9aaacc7..53a4b66 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
         "express": "^4.18.2",
         "googleapis": "^164.1.0",
         "helmet": "^8.1.0",
-        "nodemailer": "^8.0.5",
+        "nodemailer": "9.0.6",
         "parse5": "7.3.0"
       }
     },
@@ -1063,9 +1063,9 @@
       }
     },
     "node_modules/nodemailer": {
-      "version": "8.0.11",
-      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
-      "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
+      "version": "9.0.6",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.6.tgz",
+      "integrity": "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==",
       "license": "MIT-0",
       "engines": {
         "node": ">=6.0.0"
diff --git a/package.json b/package.json
index cc9b8c9..5337dc5 100644
--- a/package.json
+++ b/package.json
@@ -5,13 +5,13 @@
   "main": "server.js",
   "scripts": {
     "start": "node server.js",
-    "test": "node test/send-preflight.test.js"
+    "test": "node test/send-preflight.test.js && node test/nodemailer-security.test.js"
   },
   "dependencies": {
     "express": "^4.18.2",
     "googleapis": "^164.1.0",
     "helmet": "^8.1.0",
-    "nodemailer": "^8.0.5",
+    "nodemailer": "9.0.6",
     "parse5": "7.3.0"
   }
 }
diff --git a/server.js b/server.js
index 50f8a36..452d6fd 100644
--- a/server.js
+++ b/server.js
@@ -8,7 +8,7 @@ const helmet = require('helmet');
 const path = require('path');
 const fs = require('fs');
 const { google } = require('googleapis');
-const nodemailer = require('nodemailer');
+const { buildAttachmentMime } = require('./lib/attachment-mime');
 const { sendPreflight } = require('./lib/send-preflight');
 
 const PORT = process.env.PORT || 9850;
@@ -1413,12 +1413,7 @@ app.post('/api/send-with-attachment', async (req, res) => {
     if (cc) mailOpts.cc = cc;
     if (bcc) mailOpts.bcc = bcc;
 
-    const mime = await new Promise((resolve, reject) => {
-      const mail = nodemailer.createTransport({ streamTransport: true, buffer: true }).sendMail(
-        mailOpts,
-        (err, info) => (err ? reject(err) : resolve(info.message))
-      );
-    });
+    const mime = await buildAttachmentMime(mailOpts);
     const raw = Buffer.from(mime).toString('base64url');
     const result = await gmailClient.users.messages.send({ userId: 'me', requestBody: { raw } });
     res.json({
diff --git a/test/nodemailer-security.test.js b/test/nodemailer-security.test.js
new file mode 100644
index 0000000..a5b4e83
--- /dev/null
+++ b/test/nodemailer-security.test.js
@@ -0,0 +1,52 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { buildAttachmentMime } = require('../lib/attachment-mime');
+
+async function compose(message) {
+  return Buffer.from(await buildAttachmentMime(message)).toString('utf8');
+}
+
+async function expectRejected(message, expectedCode) {
+  await assert.rejects(
+    compose(message),
+    (error) => error && error.code === expectedCode,
+    `expected ${expectedCode}`,
+  );
+}
+
+(async () => {
+  const mime = await compose({
+    from: 'info@designerwallcoverings.com',
+    to: 'customer@real-domain.test',
+    subject: 'UTF-8 sample — Café',
+    html: '<p>Attached sample.</p>',
+    attachments: [{
+      filename: 'sample.txt',
+      content: Buffer.from('known attachment bytes', 'utf8'),
+      contentType: 'text/plain',
+    }],
+  });
+
+  assert.match(mime, /^From: info@designerwallcoverings\.com$/m);
+  assert.match(mime, /^To: customer@real-domain\.test$/m);
+  assert.match(mime, /^Subject: =\?UTF-8\?/m);
+  assert.match(mime, /Content-Type: multipart\/mixed;/);
+  assert.match(mime, /filename=sample\.txt/);
+  assert.match(mime, /a25vd24gYXR0YWNobWVudCBieXRlcw==/);
+
+  const common = {
+    from: 'info@designerwallcoverings.com',
+    to: 'customer@real-domain.test',
+    subject: 'Security boundary',
+  };
+  await expectRejected({ ...common, html: { path: '/etc/passwd' } }, 'EFILEACCESS');
+  await expectRejected({ ...common, html: { href: 'http://127.0.0.1:9/internal' } }, 'EURLACCESS');
+  await expectRejected({ ...common, raw: { path: '/etc/passwd' } }, 'EFILEACCESS');
+  await expectRejected({ ...common, raw: { href: 'http://127.0.0.1:9/internal' } }, 'EURLACCESS');
+
+  console.log('Nodemailer MIME compatibility and access controls: PASS');
+})().catch((error) => {
+  console.error(error);
+  process.exitCode = 1;
+});
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
index 51a19ee..6c6c7e3 100644
--- a/verification/e2e-proof.json
+++ b/verification/e2e-proof.json
@@ -1,61 +1,57 @@
 {
-  "intent": "Harden Phase-1 commercial preflight so a non-empty explicit From cannot bypass account-identity validation",
-  "risk_tier": "R3",
-  "environment": "isolated George process on localhost:64847; live pm2 George untouched; no Gmail send/draft API invoked",
-  "timestamp": "2026-08-28T20:30:00-07:00",
-  "ticket": "TK-10945-reject-malformed-explicit-from-identities",
-  "baseline": "Commit ed923bc treated any non-empty unparsable From as absent because emailOf returned an empty string, allowing display-name-only and malformed identities to inherit the configured account",
+  "intent": "Remediate GHSA-p6gq-j5cr-w38f without changing George's send-with-attachment MIME behavior",
+  "risk_tier": "R1",
+  "environment": "local dependency and streamTransport only; no George process, Gmail API, draft/send, PM2, deploy, production write, or remote push",
+  "baseline_commit": "9cfb00ae972c9d65dfa67644031a77eec0ccf641",
+  "timestamp": "2026-08-28T21:29:08-07:00",
+  "ticket": "TK-10946-remediate-nodemailer-ghsa-p6gq-j5cr-w38f",
   "commands": [
-    "node test/send-preflight.test.js",
-    "node --check server.js && node --check lib/send-preflight.js",
-    "PORT=64847 node server.js",
-    "authenticated localhost POST /api/send-preflight for malformed external, compliant commercial, and internal-only payloads",
-    "npm audit --json",
+    "npm test",
+    "node --check server.js",
+    "node --check lib/attachment-mime.js",
+    "node --check test/nodemailer-security.test.js",
+    "npm audit --omit=dev --audit-level=high",
     "git diff --check"
   ],
   "assertions": [
     {
-      "boundary": "explicit From identity",
+      "boundary": "dependency advisory",
       "verdict": "PASS",
-      "evidence": "blank/absent From inherits the configured account; any non-empty From must be one strict mailbox equal to the resolved account; display-only, malformed, multiple-address, CRLF, mismatched, and trailing-content values are rejected"
+      "evidence": "package and lockfile resolve exact nodemailer 9.0.6; fresh npm audit reports 0 vulnerabilities"
     },
     {
-      "boundary": "classification",
+      "boundary": "production usage",
       "verdict": "PASS",
-      "evidence": "internal recipients are server-derived exempt; explicit commercial/compliance_required is blocking; legacy external and caller-claimed transactional/reply remain report-only pending migration"
+      "evidence": "send-with-attachment continues to map caller content_base64 into in-memory Buffer attachments; production now calls the tested buildAttachmentMime helper with disableFileAccess and disableUrlAccess forced true"
     },
     {
-      "boundary": "commercial content validation",
+      "boundary": "MIME compatibility",
       "verdict": "PASS",
-      "evidence": "parse5 parsed-tree tests reject hidden/comment/malformed compliance content, empty or hidden labels, attacker recipients, and BCC injection while canonical info@ opt-out passes"
+      "evidence": "hermetic production-helper test generated multipart/mixed MIME with UTF-8 subject, expected addressing, filename, content type, and exact base64 attachment bytes"
     },
     {
-      "boundary": "real local API",
+      "boundary": "file and URL access controls",
       "verdict": "PASS",
-      "evidence": "malformed external payload returned 422 with shouldBlock=true and five structured failures; compliant explicit commercial returned 200 with six passing checks; internal-only returned 200 server-derived exemption"
+      "evidence": "the production helper rejected html.path and raw.path with EFILEACCESS, and html.href and raw.href with EURLACCESS; loopback URL targets were never contacted"
     },
     {
-      "boundary": "legacy send safety",
+      "boundary": "existing compliance behavior",
       "verdict": "PASS",
-      "evidence": "legacy external sends without classification are report-only and preserve the existing external approval-token guard; no live George restart or Gmail mutation occurred"
+      "evidence": "the complete existing send-preflight suite still passes"
     },
     {
-      "boundary": "dependency audit",
-      "verdict": "PARTIAL",
-      "evidence": "parse5 7.3.0 introduced no advisory; npm audit reports one pre-existing high nodemailer advisory requiring a semver-major upgrade, deferred as the next verified security seed"
+      "boundary": "side effects",
+      "verdict": "PASS",
+      "evidence": "tests used streamTransport buffers only; no Gmail client, live service, account, network URL, or persistent message state was touched"
     }
   ],
   "negative_checks": [
-    "commercial missing footer/suppression/audience evidence",
-    "misleading Re subject without server-verified reply",
-    "hidden and malformed HTML compliance content",
-    "empty or hidden-only opt-out label",
-    "mailto BCC and additional-recipient injection",
-    "unknown preflight class defaults to commercial evaluation",
-    "caller-claimed reply or transactional class cannot bypass into blocking enforcement",
-    "explicit From display-only, malformed, mismatch, multiple-address, and CRLF injection"
+    "filesystem-backed HTML body",
+    "URL-backed HTML body targeting loopback",
+    "message-level raw filesystem input from the advisory",
+    "message-level raw URL input from the advisory"
   ],
-  "cleanup": "isolated localhost server stopped with Ctrl-C; live pm2 process and Gmail state unchanged",
-  "known_limit": "Phase 1 does not yet fail closed on unclassified legacy external traffic; caller inventory, server-verified reply/correlation evidence, and staged migration are required before universal enforcement",
-  "verdict": "PASS_WITH_KNOWN_LIMIT"
+  "cleanup": "No server or external fixture was started; generated MIME remained in process memory and exited with the test process",
+  "residual_risk": "Nodemailer remains a direct security-sensitive dependency; keep the exact patched pin and re-run npm audit during future upgrades",
+  "verdict": "PASS"
 }

← 9cfb00a Reject malformed explicit From identities  ·  back to George Gmail  ·  auto-data-snapshot: 2026-08-29T04:46:12 (1 data files) — dat e9e8962 →