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
+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 + 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 ──────────────
console.log('\nSource assertions');
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',
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`);
process.exit(fail === 0 ? 0 : 1);