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
+27 -2
View File
@@ -1082,12 +1082,17 @@ export const hooks = {
onComposeSend,
onRenderEmailBody,
// 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() {
if (settings().lockOnLogout === false) return;
try { await clearSessionKeys(); } catch (err) { host.log.warn('clearSessionKeys failed', err); }
},
async onAccountSwitch() {
if (settings().lockOnLogout === false) return;
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
// 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); }
// 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;
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)`);
+34 -4
View File
@@ -7,10 +7,32 @@
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 }. */
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 node = parseEntity(text);
const node = parseEntity(text, 0, { parts: 0 });
const out = { html: '', text: '', attachments: [] };
collect(node, out);
// Fallback for non-MIME inner content (e.g. messages signed/encrypted by
@@ -31,7 +53,11 @@ function binaryString(bytes) {
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 headerText = sepMatch ? raw.slice(0, sepMatch.index) : raw;
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: [] };
if (type.startsWith('multipart/') && params.boundary) {
node.children = splitMultipart(body, params.boundary).map(parseEntity);
if (type.startsWith('multipart/') && params.boundary && depth < MAX_DEPTH) {
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;
}
+14 -6
View File
@@ -3,6 +3,11 @@
* 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) {
const noResult = { type: null, supported: false };
@@ -66,7 +71,7 @@ export function detectSmime(contentType, bodyStructure, attachments) {
return noResult;
}
function walkBodyStructure(part) {
function walkBodyStructure(part, depth = 0) {
const type = part.type?.toLowerCase() || '';
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) {
const result = walkBodyStructure(sub);
const result = walkBodyStructure(sub, depth + 1);
if (result) return result;
}
}
@@ -95,15 +103,15 @@ function walkBodyStructure(part) {
return null;
}
function findCmsPart(bodyStructure, _smimeType) {
function findCmsPart(bodyStructure, _smimeType, depth = 0) {
if (!bodyStructure) return null;
const type = bodyStructure.type?.toLowerCase() || '';
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
return bodyStructure;
}
if (bodyStructure.subParts) {
if (bodyStructure.subParts && depth < MAX_WALK_DEPTH) {
for (const sub of bodyStructure.subParts) {
const found = findCmsPart(sub, _smimeType);
const found = findCmsPart(sub, _smimeType, depth + 1);
if (found) return found;
}
}