security(smime): fix finding 5 (parser DoS) and harden finding 4

Finding 5 — the MIME parser runs on attacker-controlled input: the inner
content recovered after decrypt/verify is whatever the sender put there.
Upstream had no depth limit on nested multiparts and no size cap anywhere.

Verified against the unpatched upstream parser with the same input:

  UPSTREAM CRASHED: RangeError - Maximum call stack size exceeded
  UPSTREAM: 65MB accepted (no size cap)

So this was a live decrypt-time DoS reachable by anyone who can send mail.

Caps added: depth 20, parts 500, bytes 64 MB — generous enough that no
legitimate message comes close (real mail nests 3-4 levels). Past a limit a
subtree degrades to a leaf rather than throwing, so one pathological branch
doesn't discard the legitimate parts above it. Oversize input is refused
outright rather than truncated: half a MIME tree parses into misleading
nonsense, and showing part of a message is worse than saying no. Both
bodyStructure walkers in smime-detect.js are capped too — those run on
server-supplied structure BEFORE any decrypt/verify gate.

Finding 4 — hardened, not eliminated, per the agreed scope. Unlocked
CryptoKeys still live in durable IndexedDB rather than memory; moving them
would mean refactoring how the plugin shares state across iframes and
risking the unlock->decrypt path just verified.

What changed instead:

- Removed the lockOnLogout opt-out from the logout/account-switch wipes. A
  non-extractable key cannot be exported but can still be USED, so a handle
  outliving the session lets anyone with the browser profile decrypt mail
  without knowing the passphrase. That is not a preference to toggle off.
- Added a best-effort wipe on pagehide and beforeunload to narrow the window
  in which a usable handle exists on disk. Best-effort by nature: an
  IndexedDB write may not complete during teardown and neither event fires
  on a crash — which is precisely why the boot wipe in activate() remains
  the load-bearing control.
- Deliberately NOT wiping on visibilitychange: tabbing away would drop the
  unlock and force a passphrase re-entry every time, which trains users into
  turning S/MIME off entirely.
- Dropped the now-dead lockOnLogout setting from the manifest. A toggle that
  silently does nothing is worse than no toggle.

Tests: 49 unit assertions + 28 round trip. The round trip now feeds genuinely
hostile MIME through the real parser (5000-level nesting, 5000 siblings,
65 MB) and still confirms a normal multipart/alternative parses correctly.
Full crypto round trip unchanged and passing, so neither fix broke S/MIME.

Findings 6, 7, 8 and 9 remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernd Rodler
2026-08-04 11:57:18 +02:00
co-authored by Claude Opus 4.8
parent d047891ded
commit a4155aa342
7 changed files with 161 additions and 19 deletions
+3 -1
View File
@@ -21,8 +21,10 @@ Forked to `vnc/plugins/smime/` — **source only; the upstream zip was deliberat
| 1 · Certificate substitution | ✅ **Fixed** — auto-import now requires `signerEmailMatch === true` **and** `!selfSigned` | | 1 · Certificate substitution | ✅ **Fixed** — auto-import now requires `signerEmailMatch === true` **and** `!selfSigned` |
| 3 · CRLF header injection | ✅ **Fixed** — sanitised inside `formatHeader` (covers all 17 call sites) plus the 3 headers assembled directly | | 3 · CRLF header injection | ✅ **Fixed** — sanitised inside `formatHeader` (covers all 17 call sites) plus the 3 headers assembled directly |
| 2 · Unauthenticated CBC on decrypt | ✅ **Fixed** — content-encryption allowlist + native engine on the mail path + HTML suppressed for unauthenticated plaintext | | 2 · Unauthenticated CBC on decrypt | ✅ **Fixed** — content-encryption allowlist + native engine on the mail path + HTML suppressed for unauthenticated plaintext |
| 5 · Parser DoS | ✅ **Fixed** — depth (20), part (500) and size (64 MB) caps. Upstream crashes with `RangeError: Maximum call stack size exceeded` on the same input; patched code survives |
| 4 · Unlocked keys on disk | ⚠️ **Hardened, not eliminated**`lockOnLogout` opt-out removed (a security control shouldn't be user-disableable), plus best-effort `pagehide`/`beforeunload` wipe. Keys still live in IndexedDB; the boot wipe remains load-bearing |
| — · `auth:observe` | ✅ **Added** to the manifest, so the session-key wipe survives `B-09` | | — · `auth:observe` | ✅ **Added** to the manifest, so the session-key wipe survives `B-09` |
| 4, 5, 6, 7, 8, 9 | ⛔ Open — see the findings table | | 6, 7, 8, 9 | ⛔ Open — see the findings table |
Regression tests: `vnc/plugins/smime/verify-fixes.mjs`**36 assertions**, `node vnc/plugins/smime/verify-fixes.mjs`. Covers the attack case for finding 1, CRLF variants for finding 3, the algorithm allowlist and HTML-suppression decision for finding 2, plus source assertions that fail if any guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught the interpolated `smime-type` Content-Type header (`mime-builder.js:216`), which manual review had wrongly dismissed as a static string. Regression tests: `vnc/plugins/smime/verify-fixes.mjs`**36 assertions**, `node vnc/plugins/smime/verify-fixes.mjs`. Covers the attack case for finding 1, CRLF variants for finding 3, the algorithm allowlist and HTML-suppression decision for finding 2, plus source assertions that fail if any guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught the interpolated `smime-type` Content-Type header (`mime-builder.js:216`), which manual review had wrongly dismissed as a static string.
-6
View File
@@ -38,12 +38,6 @@
"description": "When a validly signed message is opened, remember the signer's certificate so you can later send them encrypted mail without importing it manually.", "description": "When a validly signed message is opened, remember the signer's certificate so you can later send them encrypted mail without importing it manually.",
"default": true "default": true
}, },
"lockOnLogout": {
"type": "boolean",
"label": "Lock keys on logout",
"description": "Wipe all unlocked private keys from memory when you sign out or switch accounts. Leave on unless you have a specific reason not to.",
"default": true
},
"renderUnauthenticatedHtml": { "renderUnauthenticatedHtml": {
"type": "boolean", "type": "boolean",
"label": "Render HTML in legacy-encrypted mail", "label": "Render HTML in legacy-encrypted mail",
+43
View File
@@ -142,5 +142,48 @@ if (at < 0) {
/Refusing to decrypt/.test(msg) ? 'refused by allowlist' : 'refused earlier: ' + msg.slice(0, 60)); /Refusing to decrypt/.test(msg) ? 'refused by allowlist' : 'refused earlier: ' + msg.slice(0, 60));
} }
console.log('\n9. Finding 5 — hostile MIME against the REAL parser');
const { parseMime } = await import('./src/mime-parse.js');
// Deeply nested multipart. Upstream recursed once per level with no cap; 5000
// levels is comfortably past the JS stack limit.
function nest(levels) {
let body = 'Content-Type: text/plain\r\n\r\ninnermost\r\n';
for (let i = levels; i > 0; i--) {
const b = `b${i}`;
body = `Content-Type: multipart/mixed; boundary="${b}"\r\n\r\n`
+ `--${b}\r\n${body}\r\n--${b}--\r\n`;
}
return new TextEncoder().encode(body);
}
let survived = false, note = '';
try { parseMime(nest(5000)); survived = true; note = 'parsed without stack overflow'; }
catch (e) { note = e.message.slice(0, 70); survived = !/Maximum call stack|too much recursion/i.test(e.message); }
check('5000-level nesting does not blow the stack', survived, note);
// Wide fan-out: many sibling parts at one level.
const wideB = 'w';
let wide = `Content-Type: multipart/mixed; boundary="${wideB}"\r\n\r\n`;
for (let i = 0; i < 5000; i++) wide += `--${wideB}\r\nContent-Type: text/plain\r\n\r\np${i}\r\n`;
wide += `--${wideB}--\r\n`;
let wideOk = false, wideNote = '';
try { parseMime(new TextEncoder().encode(wide)); wideOk = true; wideNote = 'part budget held'; }
catch (e) { wideNote = e.message.slice(0, 70); }
check('5000 sibling parts handled', wideOk, wideNote);
// Oversize input is refused rather than silently truncated.
let refusedBig = false;
try { parseMime(new Uint8Array(65 * 1024 * 1024)); }
catch (e) { refusedBig = /Refusing to parse/.test(e.message); }
check('oversize message REFUSED (not truncated)', refusedBig);
// And a legitimate message still parses correctly after all that.
const normal = parseMime(new TextEncoder().encode(
'Content-Type: multipart/alternative; boundary="x"\r\n\r\n'
+ '--x\r\nContent-Type: text/plain\r\n\r\nhello plain\r\n'
+ '--x\r\nContent-Type: text/html\r\n\r\n<p>hello html</p>\r\n--x--\r\n'));
check('normal multipart/alternative still parses',
normal.text.includes('hello plain') && normal.html.includes('hello html'));
console.log(`\n${fail === 0 ? 'ROUND TRIP OK' : 'FAILURES'}${pass} passed, ${fail} failed\n`); console.log(`\n${fail === 0 ? 'ROUND TRIP OK' : 'FAILURES'}${pass} passed, ${fail} failed\n`);
process.exit(fail === 0 ? 0 : 1); process.exit(fail === 0 ? 0 : 1);
+27 -2
View File
@@ -1082,12 +1082,17 @@ export const hooks = {
onComposeSend, onComposeSend,
onRenderEmailBody, onRenderEmailBody,
// Wipe unlocked keys from the shared session store on sign-out / account switch. // Wipe unlocked keys from the shared session store on sign-out / account switch.
//
// VNC (audit finding 4): the `lockOnLogout` opt-out was removed. Unlocked keys
// live in DURABLE IndexedDB, not memory, so this wipe is the only thing that
// stops a usable key handle outliving the session on disk. A non-extractable
// key can't be exported but can still be USED — anyone with the browser
// profile could decrypt mail without ever knowing the passphrase. That is not
// a preference to be toggled off.
async onAfterLogout() { async onAfterLogout() {
if (settings().lockOnLogout === false) return;
try { await clearSessionKeys(); } catch (err) { host.log.warn('clearSessionKeys failed', err); } try { await clearSessionKeys(); } catch (err) { host.log.warn('clearSessionKeys failed', err); }
}, },
async onAccountSwitch() { async onAccountSwitch() {
if (settings().lockOnLogout === false) return;
try { await clearSessionKeys(); } catch (err) { host.log.warn('clearSessionKeys failed', err); } try { await clearSessionKeys(); } catch (err) { host.log.warn('clearSessionKeys failed', err); }
}, },
}; };
@@ -1108,7 +1113,27 @@ export async function activate(api) {
} }
// Enforce session scope for unlocked keys: wipe any left over from a prior // Enforce session scope for unlocked keys: wipe any left over from a prior
// app session at boot (mirrors the native "in-memory, cleared on reload"). // app session at boot (mirrors the native "in-memory, cleared on reload").
//
// VNC (audit finding 4): this boot wipe is the load-bearing one. Because
// unlocked handles sit in durable IndexedDB rather than memory, it is what
// guarantees a handle surviving a crash or force-quit is destroyed before
// anything can use it.
try { await clearSessionKeys(); } catch (err) { api.log.warn('S/MIME: clearSessionKeys failed', err); } try { await clearSessionKeys(); } catch (err) { api.log.warn('S/MIME: clearSessionKeys failed', err); }
// VNC (audit finding 4): also wipe on the way out, to narrow the window in
// which a usable handle exists on disk at all. Best-effort by nature — an
// IndexedDB write may not complete during teardown, and neither event fires on
// a crash — which is exactly why the boot wipe above still has to exist.
//
// `pagehide` is used alongside `beforeunload` because Safari and mobile
// browsers often skip the latter. Deliberately NOT wiping on
// `visibilitychange`: tabbing away would drop the unlock and force a
// passphrase re-entry every time, which trains users into turning S/MIME off.
const wipeOnExit = () => { try { clearSessionKeys(); } catch { /* teardown, best effort */ } };
try {
window.addEventListener('pagehide', wipeOnExit);
window.addEventListener('beforeunload', wipeOnExit);
} catch { /* no window (non-browser test context) */ }
let keyCount = 0; let keyCount = 0;
try { keyCount = (await listKeyRecords()).length; } catch (err) { api.log.warn('S/MIME: listKeyRecords failed', err); } try { keyCount = (await listKeyRecords()).length; } catch (err) { api.log.warn('S/MIME: listKeyRecords failed', err); }
api.log.info(`S/MIME plugin activated (${keyCount} key${keyCount === 1 ? '' : 's'} imported)`); api.log.info(`S/MIME plugin activated (${keyCount} key${keyCount === 1 ? '' : 's'} imported)`);
+34 -4
View File
@@ -7,10 +7,32 @@
const decoder = new TextDecoder('utf-8', { fatal: false }); const decoder = new TextDecoder('utf-8', { fatal: false });
// ─── VNC: resource limits (audit finding 5) ────────────────────────────
//
// This parser runs on attacker-controlled input: the inner MIME recovered after
// decrypt/verify is whatever the sender put there. Upstream had no depth limit
// on nested multiparts and no size cap anywhere, so a crafted message could
// blow the stack or exhaust memory — a decrypt-time DoS reachable by anyone who
// can send you mail.
//
// Limits are generous enough that no legitimate message hits them: real mail
// nests maybe 3-4 levels (mixed > alternative > related), and 64 MB is far above
// any sane attachment set surviving base64 in a single message.
const MAX_DEPTH = 20;
const MAX_PARTS = 500;
const MAX_BYTES = 64 * 1024 * 1024;
/** Parse raw inner MIME bytes into { html, text, attachments }. */ /** Parse raw inner MIME bytes into { html, text, attachments }. */
export function parseMime(bytes) { export function parseMime(bytes) {
if (bytes.length > MAX_BYTES) {
// Refuse rather than truncate: half a MIME tree parses into misleading
// nonsense, and silently showing part of a message is worse than saying no.
throw new Error(
`Refusing to parse: message exceeds ${Math.round(MAX_BYTES / 1024 / 1024)} MB`,
);
}
const text = binaryString(bytes); const text = binaryString(bytes);
const node = parseEntity(text); const node = parseEntity(text, 0, { parts: 0 });
const out = { html: '', text: '', attachments: [] }; const out = { html: '', text: '', attachments: [] };
collect(node, out); collect(node, out);
// Fallback for non-MIME inner content (e.g. messages signed/encrypted by // Fallback for non-MIME inner content (e.g. messages signed/encrypted by
@@ -31,7 +53,11 @@ function binaryString(bytes) {
return s; return s;
} }
function parseEntity(raw) { // VNC: `depth` and the shared `budget` bound the recursion (finding 5). Past
// either limit the node is returned as a leaf rather than throwing, so a
// pathological subtree degrades to "not rendered" instead of failing the whole
// message — the parts above it are still legitimate and worth showing.
function parseEntity(raw, depth = 0, budget = { parts: 0 }) {
const sepMatch = raw.match(/\r?\n\r?\n/); const sepMatch = raw.match(/\r?\n\r?\n/);
const headerText = sepMatch ? raw.slice(0, sepMatch.index) : raw; const headerText = sepMatch ? raw.slice(0, sepMatch.index) : raw;
const body = sepMatch ? raw.slice(sepMatch.index + sepMatch[0].length) : ''; const body = sepMatch ? raw.slice(sepMatch.index + sepMatch[0].length) : '';
@@ -44,8 +70,12 @@ function parseEntity(raw) {
const node = { type, params, cte, disposition, headers, body, children: [] }; const node = { type, params, cte, disposition, headers, body, children: [] };
if (type.startsWith('multipart/') && params.boundary) { if (type.startsWith('multipart/') && params.boundary && depth < MAX_DEPTH) {
node.children = splitMultipart(body, params.boundary).map(parseEntity); for (const seg of splitMultipart(body, params.boundary)) {
if (budget.parts >= MAX_PARTS) break;
budget.parts += 1;
node.children.push(parseEntity(seg, depth + 1, budget));
}
} }
return node; return node;
} }
+14 -6
View File
@@ -3,6 +3,11 @@
* Checks Content-Type, JMAP bodyStructure, and attachment metadata. * Checks Content-Type, JMAP bodyStructure, and attachment metadata.
*/ */
// VNC (audit finding 5): cap for the two bodyStructure walkers below. No real
// message nests anywhere near this; a crafted one could otherwise recurse until
// the stack gives out, before any decrypt/verify gate has run.
const MAX_WALK_DEPTH = 20;
export function detectSmime(contentType, bodyStructure, attachments) { export function detectSmime(contentType, bodyStructure, attachments) {
const noResult = { type: null, supported: false }; const noResult = { type: null, supported: false };
@@ -66,7 +71,7 @@ export function detectSmime(contentType, bodyStructure, attachments) {
return noResult; return noResult;
} }
function walkBodyStructure(part) { function walkBodyStructure(part, depth = 0) {
const type = part.type?.toLowerCase() || ''; const type = part.type?.toLowerCase() || '';
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) { if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
@@ -85,9 +90,12 @@ function walkBodyStructure(part) {
} }
} }
if (part.subParts) { // VNC (finding 5): bound the walk. bodyStructure comes from the JMAP server,
// but a deeply-nested structure — hostile, or just a server that parsed a
// crafted message loosely — reaches here BEFORE any decrypt/verify gate.
if (part.subParts && depth < MAX_WALK_DEPTH) {
for (const sub of part.subParts) { for (const sub of part.subParts) {
const result = walkBodyStructure(sub); const result = walkBodyStructure(sub, depth + 1);
if (result) return result; if (result) return result;
} }
} }
@@ -95,15 +103,15 @@ function walkBodyStructure(part) {
return null; return null;
} }
function findCmsPart(bodyStructure, _smimeType) { function findCmsPart(bodyStructure, _smimeType, depth = 0) {
if (!bodyStructure) return null; if (!bodyStructure) return null;
const type = bodyStructure.type?.toLowerCase() || ''; const type = bodyStructure.type?.toLowerCase() || '';
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) { if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
return bodyStructure; return bodyStructure;
} }
if (bodyStructure.subParts) { if (bodyStructure.subParts && depth < MAX_WALK_DEPTH) {
for (const sub of bodyStructure.subParts) { for (const sub of bodyStructure.subParts) {
const found = findCmsPart(sub, _smimeType); const found = findCmsPart(sub, _smimeType, depth + 1);
if (found) return found; if (found) return found;
} }
} }
+40
View File
@@ -82,6 +82,21 @@ check('GCM -> HTML rendered', suppress(true), false);
check('CBC -> HTML suppressed by default', suppress(false), true); check('CBC -> HTML suppressed by default', suppress(false), true);
check('CBC + explicit opt-out -> HTML rendered', suppress(false, true), false); check('CBC + explicit opt-out -> HTML rendered', suppress(false, true), false);
// ── Finding 5: parser resource limits ───────────────────────────────
const MAX_DEPTH = 20, MAX_PARTS = 500;
// Mirrors the bounded recursion in parseEntity: past MAX_DEPTH a multipart is
// treated as a leaf; past MAX_PARTS siblings are dropped.
function walk(depth, budget) {
if (depth >= MAX_DEPTH) return { depth, recursed: false };
if (budget.parts >= MAX_PARTS) return { depth, recursed: false };
budget.parts += 1;
return walk(depth + 1, budget);
}
console.log('\nFinding 5 — parser resource limits');
check('recursion stops at MAX_DEPTH', walk(0, { parts: 0 }).depth, MAX_DEPTH);
check('part budget stops further recursion', walk(0, { parts: MAX_PARTS }).recursed, false);
check('deep-but-legal nesting still reaches the cap', walk(16, { parts: 0 }).depth, MAX_DEPTH);
// ── Source assertions: guard against silent regression ────────────── // ── Source assertions: guard against silent regression ──────────────
console.log('\nSource assertions'); console.log('\nSource assertions');
const idx = readFileSync(join(here, 'src/index.js'), 'utf8'); const idx = readFileSync(join(here, 'src/index.js'), 'utf8');
@@ -110,5 +125,30 @@ check('liner engine reachable only via useLiner',
check('index.js suppresses HTML for unauthenticated content', check('index.js suppresses HTML for unauthenticated content',
idx.includes('suppressHtml') && idx.includes('result.contentAuthenticated'), true); idx.includes('suppressHtml') && idx.includes('result.contentAuthenticated'), true);
// finding 5
const mp = readFileSync(join(here, 'src/mime-parse.js'), 'utf8');
const det = readFileSync(join(here, 'src/smime-detect.js'), 'utf8');
check('mime-parse caps depth/parts/bytes',
/MAX_DEPTH/.test(mp) && /MAX_PARTS/.test(mp) && /MAX_BYTES/.test(mp), true);
check('parseEntity threads depth + budget',
/function parseEntity\(raw, depth = 0, budget/.test(mp), true);
check('parseEntity guards on depth before recursing',
/params\.boundary && depth < MAX_DEPTH/.test(mp), true);
check('no unbounded .map(parseEntity) left', /\.map\(parseEntity\)/.test(mp), false);
check('both bodyStructure walkers are depth-capped',
(det.match(/depth < MAX_WALK_DEPTH/g) || []).length, 2);
// finding 4 hardening
check('lockOnLogout opt-out removed from wipe paths',
/settings\(\)\.lockOnLogout === false\) return/.test(idx), false);
check('exit wipe registered (pagehide + beforeunload)',
idx.includes("addEventListener('pagehide'") && idx.includes("addEventListener('beforeunload'"), true);
check('boot wipe still present', /clearSessionKeys\(\)/.test(idx), true);
const mani = JSON.parse(readFileSync(join(here, 'manifest.json'), 'utf8'));
check('dead lockOnLogout setting removed from manifest',
'lockOnLogout' in mani.settingsSchema, false);
check('auth:observe still declared (B-09 safety)',
mani.permissions.includes('auth:observe'), true);
console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'}${pass} passed, ${fail} failed\n`); console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'}${pass} passed, ${fail} failed\n`);
process.exit(fail === 0 ? 0 : 1); process.exit(fail === 0 ? 0 : 1);