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>
130 lines
4.5 KiB
JavaScript
130 lines
4.5 KiB
JavaScript
/**
|
|
* Detect S/MIME content in an email message. Ported from lib/smime/smime-detect.ts.
|
|
* 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 };
|
|
|
|
if (contentType) {
|
|
const ct = contentType.toLowerCase();
|
|
|
|
if (ct.includes('application/pkcs7-mime') || ct.includes('application/x-pkcs7-mime')) {
|
|
if (ct.includes('smime-type=enveloped-data')) {
|
|
const part = findCmsPart(bodyStructure, 'enveloped-data');
|
|
return { type: 'enveloped-data', blobId: part?.blobId, partId: part?.partId, supported: true };
|
|
}
|
|
if (ct.includes('smime-type=signed-data')) {
|
|
const part = findCmsPart(bodyStructure, 'signed-data');
|
|
return { type: 'signed-data', blobId: part?.blobId, partId: part?.partId, supported: true };
|
|
}
|
|
const part = findCmsPart(bodyStructure, null);
|
|
if (part) {
|
|
const partType = inferSmimeTypeFromContentType(part.type || '');
|
|
return {
|
|
type: partType,
|
|
blobId: part.blobId,
|
|
partId: part.partId,
|
|
supported: partType === 'enveloped-data' || partType === 'signed-data',
|
|
};
|
|
}
|
|
}
|
|
|
|
if (ct.includes('multipart/signed') && ct.includes('application/pkcs7-signature')) {
|
|
return { type: 'detached-sig', supported: false };
|
|
}
|
|
}
|
|
|
|
if (bodyStructure) {
|
|
const result = walkBodyStructure(bodyStructure);
|
|
if (result) return result;
|
|
}
|
|
|
|
if (attachments) {
|
|
for (const att of attachments) {
|
|
const type = att.type?.toLowerCase() || '';
|
|
const name = att.name?.toLowerCase() || '';
|
|
|
|
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
|
const smimeType = inferSmimeTypeFromContentType(type);
|
|
return {
|
|
type: smimeType,
|
|
blobId: att.blobId,
|
|
partId: att.partId,
|
|
supported: smimeType === 'enveloped-data' || smimeType === 'signed-data',
|
|
};
|
|
}
|
|
if (name.endsWith('.p7m')) {
|
|
return { type: 'enveloped-data', blobId: att.blobId, partId: att.partId, supported: true };
|
|
}
|
|
if (name.endsWith('.p7s')) {
|
|
return { type: 'detached-sig', blobId: att.blobId, partId: att.partId, supported: false };
|
|
}
|
|
}
|
|
}
|
|
|
|
return noResult;
|
|
}
|
|
|
|
function walkBodyStructure(part, depth = 0) {
|
|
const type = part.type?.toLowerCase() || '';
|
|
|
|
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
|
const smimeType = inferSmimeTypeFromContentType(type);
|
|
return {
|
|
type: smimeType,
|
|
blobId: part.blobId,
|
|
partId: part.partId,
|
|
supported: smimeType === 'enveloped-data' || smimeType === 'signed-data',
|
|
};
|
|
}
|
|
|
|
if (type === 'multipart/signed') {
|
|
if (part.subParts?.some((sp) => sp.type?.toLowerCase().includes('application/pkcs7-signature'))) {
|
|
return { type: 'detached-sig', supported: false };
|
|
}
|
|
}
|
|
|
|
// 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, depth + 1);
|
|
if (result) return result;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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 && depth < MAX_WALK_DEPTH) {
|
|
for (const sub of bodyStructure.subParts) {
|
|
const found = findCmsPart(sub, _smimeType, depth + 1);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function inferSmimeTypeFromContentType(ct) {
|
|
const lower = ct.toLowerCase();
|
|
if (lower.includes('smime-type=enveloped-data')) return 'enveloped-data';
|
|
if (lower.includes('smime-type=signed-data')) return 'signed-data';
|
|
if (lower.includes('application/pkcs7-mime') || lower.includes('application/x-pkcs7-mime')) {
|
|
return 'enveloped-data';
|
|
}
|
|
return null;
|
|
}
|