Files
SRCmail/vnc/plugins/smime/verify-fixes.mjs
T
Bernd RodlerandClaude Opus 4.8 bc5d2a57e8 security(smime): fix audit finding 2 — unauthenticated CBC on decrypt
Upstream applied no content-encryption check at all on decrypt, and ran
every decryption through the liner engine — which registers DES-CBC,
3DES-CBC and RC2-CBC. Those OIDs exist in crypto-engine.js for PKCS#12
password-based encryption; the CMS content path merely reused the same
engine and inherited them. A crafted message could therefore be decrypted
under a broken cipher, and unauthenticated plaintext was handed straight
to the renderer — the EFAIL precondition.

The obvious fix would have been wrong. Accepting only AEAD breaks most
real S/MIME mail: RFC 5751 makes AES-128-CBC the MUST-implement content
cipher, Outlook and Thunderbird default to CBC, and AES-GCM in CMS
(RFC 5084) is barely deployed. An AEAD-only allowlist is a functionality
catastrophe wearing a security fix's clothes.

Three layers instead:

1. Allowlist the AES family and refuse everything else, with the gate
   running before any private key is touched. CBC stays for interop;
   DES/3DES/RC2 are refused.

2. Take the mail path off the legacy engine. Normal decryption now uses
   nativeEngine(); the liner engine is reachable only when a genuine
   legacy RSAES-PKCS1-v1_5 key is in play. This removes the weak ciphers
   structurally rather than by policy — native WebCrypto handles RSA-OAEP
   key transport and AES-CBC/GCM content perfectly well.

3. Refuse to render unauthenticated plaintext as HTML. CBC output is
   malleable and HTML is EFAIL's exfiltration channel. The host does block
   remote content by default (allowExternalContent starts false), but that
   is a user/admin setting this plugin cannot observe, so we don't lean on
   it. New renderUnauthenticatedHtml setting (default false) is the
   documented opt-out. Our own encrypt path always uses AES-GCM, so mail
   we send renders fully; only legacy inbound CBC degrades to text.

Built from source with the repo's own pipeline (esbuild, 1.69 MB) and
packaged to smime-vnc.zip (0.27 MB). All four fixes verified present in
the built bundle. Build output is gitignored — never vendor a prebuilt
bundle, which was the upstream mistake.

Correcting an earlier assumption: this bundle does NOT trip the B-01
pattern scanner (zero matches on all five patterns), so the override is
not needed to install it. B-01 remains correct — it closed a real
entrypoint-only coverage gap — but it isn't load-bearing here.

verify-fixes.mjs now carries 36 assertions covering all three fixes,
including source checks that fail if a guard is removed, if a legacy CBC
OID reappears in the allowlist, or if the mail path stops using the
native engine.

Findings 4 (unlocked keys persisted to IndexedDB), 5 (parser DoS) and 6
(PKCS1v1.5 oracle surface) remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 10:47:43 +02:00

115 lines
6.8 KiB
JavaScript

// Standalone proof for the two VNC hardening fixes. Reimplements only the
// decision logic under test (no pkijs/DOM needed) so it runs with plain node.
// node vnc/plugins/smime/verify-fixes.mjs
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
let pass = 0, fail = 0;
const check = (name, got, want) => {
const ok = got === want;
console.log(`${ok ? ' PASS' : ' FAIL'} ${name}${ok ? '' : ` (got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`}`);
ok ? pass++ : fail++;
};
// ── Fix 1: auto-import gate ─────────────────────────────────────────
// Mirrors the guard order in index.js maybeAutoImportSigner.
function wouldImport(status, autoImport = true) {
if (autoImport === false) return false;
const cert = status && status.signerCert;
if (!cert || !status.signatureValid || !cert.email) return false;
if (status.signerEmailMatch !== true) return false;
if (status.selfSigned) return false;
return true;
}
const cert = { email: 'a@b.com', fingerprint: 'ff' };
console.log('\nFix 1 — certificate auto-import gate');
check('CA-signed, address matches -> import',
wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: true, selfSigned: false }), true);
check('THE ATTACK: self-signed, address matches -> REFUSE',
wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: true, selfSigned: true }), false);
check('address mismatch -> REFUSE',
wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: false, selfSigned: false }), false);
check('signerEmailMatch undefined (no From) -> REFUSE (fail closed)',
wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: undefined, selfSigned: false }), false);
check('invalid signature -> REFUSE',
wouldImport({ signerCert: cert, signatureValid: false, signerEmailMatch: true, selfSigned: false }), false);
check('setting off -> REFUSE',
wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: true, selfSigned: false }, false), false);
// ── Fix 2 (finding 3): CRLF stripping ───────────────────────────────
function stripCrlf(value) {
return String(value).replace(/[\r\n]+[ \t]*/g, ' ');
}
console.log('\nFinding 3 — CRLF header sanitisation');
check('BCC injection via display name',
stripCrlf('Evil\r\nBcc: attacker@evil.com'), 'Evil Bcc: attacker@evil.com');
check('bare LF', stripCrlf('a\nb'), 'a b');
check('bare CR', stripCrlf('a\rb'), 'a b');
check('folded continuation collapsed', stripCrlf('a\r\n\tb'), 'a b');
check('multiple injected headers',
stripCrlf('x\r\nBcc: a@b.c\r\nReply-To: d@e.f'), 'x Bcc: a@b.c Reply-To: d@e.f');
check('clean value untouched', stripCrlf('Normal Subject'), 'Normal Subject');
check('non-ASCII untouched', stripCrlf('Grüße büro'), 'Grüße büro');
// ── Finding 2: content-encryption allowlist ─────────────────────────
const ALLOW = new Map([
['2.16.840.1.101.3.4.1.2', { name: 'AES-128-CBC', authenticated: false }],
['2.16.840.1.101.3.4.1.22', { name: 'AES-192-CBC', authenticated: false }],
['2.16.840.1.101.3.4.1.42', { name: 'AES-256-CBC', authenticated: false }],
['2.16.840.1.101.3.4.1.6', { name: 'AES-128-GCM', authenticated: true }],
['2.16.840.1.101.3.4.1.26', { name: 'AES-192-GCM', authenticated: true }],
['2.16.840.1.101.3.4.1.46', { name: 'AES-256-GCM', authenticated: true }],
]);
const accepts = (oid) => ALLOW.has(oid);
const authed = (oid) => ALLOW.get(oid)?.authenticated ?? null;
console.log('\nFinding 2 — content-encryption allowlist');
check('AES-256-GCM accepted', accepts('2.16.840.1.101.3.4.1.46'), true);
check('AES-256-GCM is authenticated', authed('2.16.840.1.101.3.4.1.46'), true);
check('AES-128-CBC accepted (RFC 5751 interop)', accepts('2.16.840.1.101.3.4.1.2'), true);
check('AES-128-CBC NOT authenticated', authed('2.16.840.1.101.3.4.1.2'), false);
check('3DES-CBC REFUSED', accepts('1.2.840.113549.3.7'), false);
check('DES-CBC REFUSED', accepts('1.3.14.3.2.7'), false);
check('RC2-CBC REFUSED', accepts('1.2.840.113549.3.2'), false);
check('unknown OID REFUSED', accepts('1.2.3.4.5'), false);
// HTML suppression decision (EFAIL mitigation)
const suppress = (contentAuthenticated, optOut = false) => !contentAuthenticated && !optOut;
check('GCM -> HTML rendered', suppress(true), false);
check('CBC -> HTML suppressed by default', suppress(false), true);
check('CBC + explicit opt-out -> HTML rendered', suppress(false, true), false);
// ── Source assertions: guard against silent regression ──────────────
console.log('\nSource assertions');
const idx = readFileSync(join(here, 'src/index.js'), 'utf8');
const mb = readFileSync(join(here, 'src/mime-builder.js'), 'utf8');
check('index.js checks signerEmailMatch !== true', idx.includes('status.signerEmailMatch !== true'), true);
check('index.js checks selfSigned', /if \(status\.selfSigned\)/.test(idx), true);
check('formatHeader sanitises its value', /function formatHeader\(name, rawValue\)[\s\S]{0,80}stripCrlf\(rawValue\)/.test(mb), true);
check('attachment Content-Type sanitised', mb.includes('stripCrlf(att.contentType)'), true);
check('Content-ID sanitised', mb.includes('stripCrlf(att.cid)'), true);
// Every header assembled outside formatHeader must use a literal or a sanitised value.
const bypass = [...mb.matchAll(/lines\.push\(`([A-Za-z-]+): ([^`]*)`\)/g)]
.filter(([, , v]) => /\$\{/.test(v) && !/stripCrlf|encodeHeaderValue|boundary|altBoundary|disposition/.test(v));
check('no unsanitised interpolated headers remain', bypass.length, 0);
if (bypass.length) bypass.forEach(([m]) => console.log(' >>', m));
const dec = readFileSync(join(here, 'src/smime-decrypt.js'), 'utf8');
check('allowlist gate runs before any key use',
dec.indexOf('checkContentEncryption(envelopedData)') < dec.indexOf('unlockedKeys.get'), true);
check('decrypt refuses non-allowlisted algorithms', /Refusing to decrypt/.test(dec), true);
check('no legacy CBC OID appears in the decrypt allowlist',
/1\.2\.840\.113549\.3\.7|1\.3\.14\.3\.2\.7|1\.2\.840\.113549\.3\.2/.test(dec), false);
check('normal decrypt path uses the NATIVE engine',
/if \(!useLiner\)[\s\S]{0,120}nativeEngine\(\)/.test(dec), true);
check('liner engine reachable only via useLiner',
(dec.match(/getLinerCryptoEngine\(\)/g) || []).length, 1);
check('index.js suppresses HTML for unauthenticated content',
idx.includes('suppressHtml') && idx.includes('result.contentAuthenticated'), true);
console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'}${pass} passed, ${fail} failed\n`);
process.exit(fail === 0 ? 0 : 1);