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:
co-authored by
Claude Opus 4.8
parent
d047891ded
commit
a4155aa342
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user