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>
16 KiB
S/MIME plugin security audit — bulwarkmail/plugins/smime
Task: S-01 · Date: 2026-08-04 · Auditor: Claude Opus 4.8 (findings independently verified against host source)
Subject: github.com/bulwarkmail/plugins @ 91085a3, smime/ — 2,935 lines of source across 13 modules
Runs as: privileged (same-origin) tier — full DOM, network, IndexedDB and WebCrypto access; handles users' private keys
Verdict
Do not ship as-is. Fork and fix findings 1–3 first.
This is not malicious code and shows no sign of a backdoor. The cryptographic primitives are handled competently — PBKDF2-SHA256 at 600k iterations, AES-256-GCM wrapping, every private-key import non-extractable, no network egress anywhere in the bundle. The problems are trust-model and input-validation gaps, all fixable, two of them in a handful of lines.
The single most reassuring property: there is no exfiltration path. Across all 13 modules there is no fetch, XMLHttpRequest, WebSocket, sendBeacon, new Image, .src =, EventSource, dynamic import(), or URL literal of any kind. This matters more than usual because privileged tier is same-origin — the manifest's lack of http:fetch would not have constrained it, so the absence had to be verified in code rather than inferred from permissions.
Remediation status (updated 2026-08-04)
Forked to vnc/plugins/smime/ — source only; the upstream zip was deliberately not vendored (see Supply chain below).
| # | Status |
|---|---|
| 1 · Certificate substitution | ✅ Fixed — auto-import now requires signerEmailMatch === true and !selfSigned |
| 3 · CRLF header injection | ✅ Fixed — sanitised inside formatHeader (covers all 17 call sites) plus the 3 headers assembled directly |
| 2 · Unauthenticated CBC on decrypt | ✅ Fixed — content-encryption allowlist + native engine on the mail path + HTML suppressed for unauthenticated plaintext |
| 5 · Parser DoS | ✅ Fixed — depth (20), part (500) and size (64 MB) caps. Upstream crashes with RangeError: Maximum call stack size exceeded on the same input; patched code survives |
| 4 · Unlocked keys on disk | ⚠️ Hardened, not eliminated — lockOnLogout opt-out removed (a security control shouldn't be user-disableable), plus best-effort pagehide/beforeunload wipe. Keys still live in IndexedDB; the boot wipe remains load-bearing |
— · auth:observe |
✅ Added to the manifest, so the session-key wipe survives B-09 |
| 6, 7, 8, 9 | ⛔ Open — see the findings table |
Regression tests: vnc/plugins/smime/verify-fixes.mjs — 36 assertions, node vnc/plugins/smime/verify-fixes.mjs. Covers the attack case for finding 1, CRLF variants for finding 3, the algorithm allowlist and HTML-suppression decision for finding 2, plus source assertions that fail if any guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught the interpolated smime-type Content-Type header (mime-builder.js:216), which manual review had wrongly dismissed as a static string.
How finding 2 was fixed, and why not the obvious way
The tempting fix — accept only AEAD — would have broken most real S/MIME mail. RFC 5751 makes AES-128-CBC the MUST-implement content cipher, Outlook and Thunderbird default to CBC, and AES-GCM in CMS (RFC 5084) is barely deployed. An AEAD-only allowlist is a functionality catastrophe wearing a security fix's clothes.
Three layers instead:
- Allowlist the AES family, refuse everything else (
smime-decrypt.js). CBC stays for interop; DES-CBC (56-bit), 3DES-CBC and RC2-CBC are refused. The gate runs before any private key is touched. - Take the mail path off the legacy engine. Those weak OIDs are registered in
crypto-engine.jsfor PKCS#12 password-based encryption; the CMS content path merely reused the same engine and inherited them. Normal decryption now usesnativeEngine(), and the liner engine is reachable only when a legacy RSAES-PKCS1-v1_5 key is genuinely in play. This removes the weak ciphers structurally, not just by policy. - Refuse to render unauthenticated plaintext as HTML. CBC output is malleable, and HTML rendering is EFAIL's exfiltration channel. The host does block remote content by default (
allowExternalContentstartsfalse,email-viewer.tsx:780) — but that is a user/admin setting the plugin cannot observe, so we don't lean on it.renderUnauthenticatedHtml(default false) is the documented opt-out. Our own encrypt path always uses AES-GCM, so mail we send renders fully; only legacy inbound CBC mail degrades to text.
Build provenance
Built from the forked source with the repo's own pipeline (npm run build → esbuild → dist/index.js, 1.69 MB) and packaged to smime-vnc.zip (0.27 MB, well under the 5 MB ceiling). All four fixes verified present in the built bundle.
Worth noting against an earlier assumption: this bundle does not trip the B-01 pattern scanner — zero matches on all five patterns. B-01 remains correct (it closed a real entrypoint-only coverage gap, and openpgp.js for the PGP plugin may yet need the override) but it is not required to install this plugin.
Remaining risk: findings 4 (unlocked keys persisted to IndexedDB), 5 (parser DoS) and 6 (PKCS1v1.5 oracle surface) are open. Suitable for sandbox use; findings 4 and 5 should be closed before real mailboxes.
Findings
| # | Severity | Finding | Location |
|---|---|---|---|
| 1 | HIGH | Certificate substitution via auto-import of unvalidated self-signed certs | index.js:361-383 |
| 2 | HIGH | Unauthenticated CBC ciphers accepted on decrypt (EFAIL precondition) | smime-decrypt.js:258-269, crypto-engine.js:114-135 |
| 3 | MED-HIGH | CRLF header injection — escaping covers 2 of ~6 insertion points | mime-builder.js:115-139 |
| 4 | MEDIUM | Unlocked key handles persisted to durable IndexedDB, not memory | key-storage.js:106-109 |
| 5 | MEDIUM | MIME parser: unbounded recursion + no size caps (DoS) | mime-parse.js:47-50, smime-detect.js:88-109 |
| 6 | MEDIUM | RSAES-PKCS1-v1_5 decrypt via JS polyfill (Marvin-class oracle surface) |
pkcs12.js:196-201, smime-decrypt.js:51-65 |
| 7 | LOW | Signer-cert lookup falls back to a blind heuristic for SKI-addressed CMS | smime-verify.js:158-161 |
| 8 | LOW | classifyCapabilities defaults canSign/canEncrypt to true when KU/EKU absent |
certificate-utils.js:170-191 |
| 9 | LOW | PKCS#12 passphrase via charCodeAt — mangles non-Latin1 passphrases |
pkcs12.js:18-23 |
1. Certificate substitution — HIGH
smime-verify.js:44 verifies with checkChain: false — cryptographic signature only, no trust anchor. This is a defensible design choice, and the module correctly computes both selfSigned (line 81) and signerEmailMatch (lines 74-77), returning them for the UI banner.
But maybeAutoImportSigner ignores both:
// index.js:361-364
async function maybeAutoImportSigner(status) {
if (settings().autoImportSignerCerts === false) return;
const cert = status && status.signerCert;
if (!cert || !status.signatureValid || !cert.email) return;
It then calls savePublicCert(...), storing the certificate as the encryption target for the email address the certificate claims. autoImportSignerCerts defaults to true.
certificate-utils.js:134-167 collects the identity email from either the legacy Subject E= attribute or the SAN rfc822Name, treating both as equally authoritative — and for a self-signed cert both are entirely self-asserted.
Attack: generate a self-signed certificate asserting victim@vnc.biz, sign any message with it, send it to the user. On open, the signature verifies (it is internally consistent), the cert is silently stored as the encryption key for victim@vnc.biz. A later user-initiated "Encrypt" to that address encrypts to the attacker's key instead of the recipient's. The user sees an encrypted-send confirmation; the legitimate recipient cannot read it, and anyone holding the attacker's key who obtains the ciphertext can.
Fix (small — the data is already computed): require !selfSigned and signerEmailMatch === true before auto-import; once S-06's trust store exists, require chain validation instead. Ideally require explicit user confirmation before a certificate becomes an encryption target.
2. Unauthenticated CBC on decrypt — HIGH
There is no content-encryption-algorithm allowlist anywhere in the decrypt path (verified by exhaustive grep — no reference to contentEncryptionAlgorithm, GCM, or CBC in smime-decrypt.js). The decrypt call passes attacker-supplied CMS straight to pkijs:
// smime-decrypt.js:262-263
return withLinerEngine(async () => {
const cryptoEngine = getLinerCryptoEngine();
And that engine deliberately widens the accepted set to legacy unauthenticated ciphers:
// crypto-engine.js:121-123
case OID_DES_EDE3_CBC: return { name: 'DES-EDE3-CBC', length: 192 };
case OID_DES_CBC: return { name: 'DES-CBC', length: 64 };
case OID_RC2_CBC: return { name: 'RC2-CBC', length: 128 };
CMS EnvelopedData carries no MAC; only AEAD modes provide integrity. Native AES-CBC is likewise accepted, since nothing inspects the algorithm at all. Decrypted bytes are returned to the renderer with no authenticity gate — the precondition for EFAIL direct-exfiltration and CBC-gadget attacks. End-to-end exploitability additionally depends on the host's HTML sanitiser blocking external resource loads, which is a separate control and should not be the only one.
Fix: allowlist AEAD content encryption (AES-GCM) on decrypt. If legacy CBC must be supported for old archived mail, gate it behind an explicit per-message user opt-in and never render its output as HTML.
3. CRLF header injection — MED-HIGH
encodeHeaderValue (mime-builder.js:141-152) does neutralise CR/LF — but only as a side effect of Q-encoding, and it is applied to just Subject and attachment filename. It is not applied to display names, raw addresses, Message-ID, In-Reply-To, References, or attachment Content-Type.
// mime-builder.js:115-121 — escapes only backslash and quote
function formatAddress(addr) {
if (addr.name) {
const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}" <${addr.email}>`;
}
return addr.email;
}
formatHeader (123-139) performs line folding only — no sanitisation. A display name containing \r\n is emitted verbatim into the header block.
Remote path: In-Reply-To / References / display names are typically copied from an inbound message when replying or forwarding, so the injected value is attacker-supplied. Impact ranges from spoofed Reply-To to added recipients, depending on whether the submission path passes explicit envelopeRecipients (the host's sendRawEmail accepts them; whether the plugin supplies them should be confirmed).
Fix: strip or encode CR/LF in one place — inside formatHeader — so every header value is covered by construction rather than per-call-site.
4. Unlocked keys persisted to disk — MEDIUM
key-storage.js:106-109 writes unlocked, non-extractable CryptoKey handles into the durable smime-plugin-store IndexedDB. Raw key material is never exposed (non-extractable survives structured clone), but the usable handle is on disk and survives tab close and browser restart unless wiped.
The wipe hooks are real — onAfterLogout and onAccountSwitch exist in our host at lib/plugin-hooks.ts:362-363 (an earlier reviewer flagged these as possibly fictional; they are not). Two caveats stand:
- The
lockOnLogoutsetting (default true) lets a user disable the wipe entirely. - If the wipe does not run — crash, killed tab, failed transaction — the handle persists, and anyone with the browser profile can decrypt mail without the passphrase.
This also contradicts the host's own plugin guidance in WRITING_PLUGINS.md §8 ("in memory only"), and it is weaker than the native implementation this replaced, which kept unlocked keys in an in-memory Map. The trade buys cross-iframe sharing between the settings slot and the background hooks.
5–9
Recursion in parseEntity (mime-parse.js:47-50) and both smime-detect.js walkers (88-109) is depth-unbounded; binaryString (mime-parse.js:28-32) concatenates byte-by-byte with no size cap. ReDoS specifically was checked across every regex and is clean — all linear. Finding 6 is an accepted-risk interop trade needing documentation rather than removal. Findings 7–9 are correctness/robustness issues.
Verified clean
| Area | Result |
|---|---|
| Network egress | None across all 13 modules |
eval / new Function / dynamic code |
None |
| XSS / DOM injection | No innerHTML, document.write, or dangerouslySetInnerHTML; cert fields render as React text children (auto-escaped) |
| Passphrase handling | Local scope only; never persisted to storage, api.storage, cookies, or logs |
api.storage.* (plaintext localStorage) |
Non-secret prefs and verification metadata only — no keys, no passphrases |
| Key wrapping | PBKDF2-SHA256 600k → AES-256-GCM; 32-byte salt, 12-byte IV |
| Key extractability | extractable: false at all five importKey sites |
| Encrypt path | Hardcoded AES-GCM + RSA-OAEP/SHA-256 — no downgrade negotiation, no RC2/DES reachable |
| Polyfill scoping | sign/encrypt/verify use nativeEngine() exclusively; the JS polyfill is never used for signing or randomness |
| Engine restore | withLinerEngine uses try/finally — no leaked global polyfill state |
| Fingerprints | SHA-256 (not SHA-1) |
| Backdoors / hardcoded keys / TODO markers | None |
Supply-chain findings
Do not ship the marketplace zip. smime.zip in the repo contains a single 1.77 MB bundled index.js — not readable source — and its manifest reads 1.0.1 while the repo source is 1.0.2. The shipped artifact is stale relative to the code, so auditing src/ would not audit what that zip installs.
Build from source ourselves via the repo's own npm run package (esbuild bundle of src/ plus pkijs/asn1js/pvtsutils/webcrypto-liner). That way the artifact corresponds to the audited code and we control it.
Interaction with our own changes
B-04is compatible. The plugin declaresemail:render-takeoverandemail:send, which our gate requires foronRenderEmailBodyandonComposeSend. It will load correctly.B-09carries a real hazard here. The plugin registersonAfterLogoutandonAccountSwitchbut its manifest requests noauth:observe. IfB-09gates auth hooks on that permission, the session-key wipe silently stops running — turning finding 4 from a caveat into a live exposure. This is a concrete instance of the over-gating risk noted inB-09, and it is security-relevant: add the permission to the fork's manifest, or exempt these hooks deliberately.B-01is exercised by this bundle — 1.77 MB of minified pkijs/webcrypto-liner will trip the pattern scanner, which is exactly the case the override exists for.
Recommendation
- Fork
smime/intovnc/plugins/smime/— do not vendor the upstream zip. - Fix findings 1, 2, 3 before any user imports a key. Findings 1 and 3 are small, localised changes; finding 2 is an allowlist.
- Add
auth:observeto the forked manifest so the wipe survivesB-09. - Document findings 4 and 6 as accepted risks with rationale, or fix 4 by moving session keys back to memory and accepting the cross-iframe cost.
- Re-audit the diff after fixes, then proceed to the self-signed-certificate spike.
Findings 1–3 are why S-01 was scheduled before A-01. All three would have shipped.