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
+43
View File
@@ -142,5 +142,48 @@ if (at < 0) {
/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`);
process.exit(fail === 0 ? 0 : 1);