Files
SRCmail/vnc/plugins/smime/src/mime-builder.js
T
Bernd RodlerandClaude Opus 4.8 f7e487171c security(smime): fork upstream plugin and fix two audit findings
S-01 audited bulwarkmail/plugins/smime @ 91085a3 (2,935 lines). Nine
findings, two HIGH. No backdoor and no exfiltration path anywhere in the
bundle — the problems are trust-model and input-validation gaps. Full
report in vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md.

Fork is source-only. The upstream smime.zip is a 1.77 MB prebuilt bundle
whose manifest reads 1.0.1 while the source reads 1.0.2, so auditing
src/ would not audit what that zip installs. We build from source.

Finding 1 (HIGH) — certificate substitution. maybeAutoImportSigner gated
on signatureValid alone, but smimeVerify runs checkChain:false, so that
only proves "signed by whoever holds this key", not that the claimed
identity is real. Self-sign a cert asserting victim@example.com, send one
signed message, and it was stored as the encryption target for that
address — the user's next Encrypt to the victim went to the attacker.
Now requires signerEmailMatch === true and !selfSigned. Both values were
already computed and displayed as untrusted in the banner; only the
import path ignored them. Tests for `true` explicitly so an undefined
match (missing From header) fails closed.

Finding 3 (MED-HIGH) — CRLF header injection. Escaping reached only
Subject and attachment filename; display names, raw addresses,
Message-ID, In-Reply-To, References and attachment Content-Type were
emitted verbatim, and formatAddress escapes only backslash and quote.
In-Reply-To/References/display names are copied from inbound mail when
replying or forwarding, so the value is attacker-supplied. Sanitising
inside formatHeader covers all 17 call sites by construction; the three
headers assembled directly get stripCrlf explicitly.

Also adds auth:observe to the manifest. The plugin registers
onAfterLogout/onAccountSwitch — real hooks (lib/plugin-hooks.ts:362-363)
— without declaring the permission, so under B-09 the session-key wipe
would silently stop running.

verify-fixes.mjs carries 19 assertions including source checks that fail
if either guard is removed or a new unsanitised interpolated header
appears. That last one immediately caught the interpolated smime-type
Content-Type header, which manual review had dismissed as static.

Finding 2 (unauthenticated CBC accepted on decrypt) is NOT fixed. This
is not safe for real mail yet — sandbox accounts only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 10:00:11 +02:00

292 lines
12 KiB
JavaScript

/**
* Minimal, deterministic MIME builder for outgoing S/MIME messages.
* Ported from lib/smime/mime-builder.ts. All line endings are CRLF.
*/
import { generateUUID } from './util.js';
const CRLF = '\r\n';
/** Build a complete MIME message and return it as a Uint8Array (UTF-8). */
export function buildMimeMessage(input) {
const boundary = generateBoundary();
const lines = [];
lines.push(formatHeader('From', formatAddress(input.from)));
lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
if (input.cc?.length) lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
if (input.inReplyTo) lines.push(formatHeader('In-Reply-To', input.inReplyTo));
if (input.references?.length) lines.push(formatHeader('References', input.references.join(' ')));
lines.push('MIME-Version: 1.0');
const hasText = !!input.textBody;
const hasHtml = !!input.htmlBody;
const hasAttachments = !!input.attachments?.length;
if (!hasAttachments && hasText && !hasHtml) {
lines.push('Content-Type: text/plain; charset=utf-8');
lines.push('Content-Transfer-Encoding: quoted-printable');
lines.push('');
lines.push(quotedPrintableEncode(input.textBody));
} else if (!hasAttachments && hasText && hasHtml) {
const altBoundary = generateBoundary();
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
lines.push('');
lines.push(`--${altBoundary}`);
lines.push('Content-Type: text/plain; charset=utf-8');
lines.push('Content-Transfer-Encoding: quoted-printable');
lines.push('');
lines.push(quotedPrintableEncode(input.textBody));
lines.push(`--${altBoundary}`);
lines.push('Content-Type: text/html; charset=utf-8');
lines.push('Content-Transfer-Encoding: quoted-printable');
lines.push('');
lines.push(quotedPrintableEncode(input.htmlBody));
lines.push(`--${altBoundary}--`);
} else if (!hasAttachments && !hasText && hasHtml) {
lines.push('Content-Type: text/html; charset=utf-8');
lines.push('Content-Transfer-Encoding: quoted-printable');
lines.push('');
lines.push(quotedPrintableEncode(input.htmlBody));
} else if (hasAttachments) {
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
lines.push('');
if (hasText && hasHtml) {
const altBoundary = generateBoundary();
lines.push(`--${boundary}`);
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
lines.push('');
lines.push(`--${altBoundary}`);
lines.push('Content-Type: text/plain; charset=utf-8');
lines.push('Content-Transfer-Encoding: quoted-printable');
lines.push('');
lines.push(quotedPrintableEncode(input.textBody));
lines.push(`--${altBoundary}`);
lines.push('Content-Type: text/html; charset=utf-8');
lines.push('Content-Transfer-Encoding: quoted-printable');
lines.push('');
lines.push(quotedPrintableEncode(input.htmlBody));
lines.push(`--${altBoundary}--`);
} else if (hasText) {
lines.push(`--${boundary}`);
lines.push('Content-Type: text/plain; charset=utf-8');
lines.push('Content-Transfer-Encoding: quoted-printable');
lines.push('');
lines.push(quotedPrintableEncode(input.textBody));
} else if (hasHtml) {
lines.push(`--${boundary}`);
lines.push('Content-Type: text/html; charset=utf-8');
lines.push('Content-Transfer-Encoding: quoted-printable');
lines.push('');
lines.push(quotedPrintableEncode(input.htmlBody));
}
for (const att of input.attachments) {
lines.push(`--${boundary}`);
const disposition = att.cid ? 'inline' : 'attachment';
// VNC: these two lines are assembled directly rather than via
// formatHeader, so stripCrlf has to be applied explicitly. Both carry
// inbound values when forwarding a message (the original part's
// Content-Type and inline-image Content-ID), so both are attacker-
// reachable. `filename` is already neutralised by encodeHeaderValue.
lines.push(`Content-Type: ${stripCrlf(att.contentType)}; name="${encodeHeaderValue(att.filename)}"`);
lines.push(`Content-Disposition: ${disposition}; filename="${encodeHeaderValue(att.filename)}"`);
lines.push('Content-Transfer-Encoding: base64');
if (att.cid) lines.push(`Content-ID: <${stripCrlf(att.cid)}>`);
lines.push('');
lines.push(base64Encode(att.content));
}
lines.push(`--${boundary}--`);
} else {
lines.push('Content-Type: text/plain; charset=utf-8');
lines.push('');
}
return new TextEncoder().encode(lines.join(CRLF));
}
// ── Helpers ──────────────────────────────────────────────────────────
function generateBoundary() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `----=_Part_${hex}`;
}
function formatAddress(addr) {
if (addr.name) {
const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}" <${addr.email}>`;
}
return addr.email;
}
// VNC: strip CR/LF from any header value before it reaches the header block.
//
// Upstream relied on `encodeHeaderValue`, whose Q-encoding neutralises CR/LF as
// a side effect — but it was only applied to Subject and attachment filename.
// Display names, raw addresses, Message-ID, In-Reply-To, References and
// attachment Content-Type all reached `formatHeader` unfiltered, and
// `formatAddress` escapes only backslash and quote. `formatHeader` folds long
// lines but never sanitises, so an embedded CRLF was emitted verbatim and became
// an injected header.
//
// That is remotely reachable: In-Reply-To, References and display names are
// copied from an inbound message when replying or forwarding, so the value is
// attacker-supplied.
//
// Sanitising here rather than at the call sites means every header is covered by
// construction — a future header can't reintroduce the hole by forgetting to
// wrap its value. Folding still inserts legitimate CRLF afterwards; only CR/LF
// arriving *inside* a value is collapsed.
function stripCrlf(value) {
const s = String(value);
// Fold whitespace runs containing CR/LF into a single space: a header value
// cannot legally contain a bare line break, and preserving the surrounding
// text is friendlier than truncating at the first one.
const clean = s.replace(/[\r\n]+[ \t]*/g, ' ');
if (clean !== s) {
// Loud, because this means something upstream handed us a header value it
// should have rejected. Worth seeing in a console during QA.
console.warn('[smime] stripped CR/LF from header value');
}
return clean;
}
function formatHeader(name, rawValue) {
const value = stripCrlf(rawValue);
const full = `${name}: ${value}`;
if (full.length <= 76) return full;
const parts = [];
let remaining = full;
let first = true;
while (remaining.length > 76) {
let breakAt = 76;
const spaceIdx = remaining.lastIndexOf(' ', 76);
if (spaceIdx > (first ? name.length + 2 : 1)) breakAt = spaceIdx;
parts.push(remaining.slice(0, breakAt));
remaining = ' ' + remaining.slice(breakAt).trimStart();
first = false;
}
parts.push(remaining);
return parts.join(CRLF);
}
function encodeHeaderValue(value) {
if (/^[\x20-\x7e]*$/.test(value)) return value;
const encoded = Array.from(new TextEncoder().encode(value))
.map((b) => {
if ((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a)) {
return String.fromCharCode(b);
}
return '=' + b.toString(16).toUpperCase().padStart(2, '0');
})
.join('');
return `=?UTF-8?Q?${encoded}?=`;
}
function formatDate(date) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const d = days[date.getUTCDay()];
const dd = date.getUTCDate();
const m = months[date.getUTCMonth()];
const y = date.getUTCFullYear();
const hh = String(date.getUTCHours()).padStart(2, '0');
const mm = String(date.getUTCMinutes()).padStart(2, '0');
const ss = String(date.getUTCSeconds()).padStart(2, '0');
return `${d}, ${dd} ${m} ${y} ${hh}:${mm}:${ss} +0000`;
}
/**
* Wrap a CMS binary blob in a proper RFC 5322 / S/MIME message.
* Returns a Blob of type message/rfc822.
*/
export function wrapCmsAsSmimeMessage(cmsBlob, input) {
const lines = [];
lines.push(formatHeader('From', formatAddress(input.from)));
lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
if (input.cc?.length) lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
if (input.inReplyTo) lines.push(formatHeader('In-Reply-To', input.inReplyTo));
if (input.references?.length) lines.push(formatHeader('References', input.references.join(' ')));
lines.push('MIME-Version: 1.0');
// VNC: smimeType is plugin-supplied ('signed-data' / 'enveloped-data') rather
// than message-derived, so this is belt-and-braces — but sanitising every
// interpolated header value unconditionally is what makes the rule checkable
// (see verify-fixes.mjs) instead of resting on a per-case judgement call.
lines.push(`Content-Type: application/pkcs7-mime; smime-type=${stripCrlf(input.smimeType)}; name="smime.p7m"`);
lines.push('Content-Transfer-Encoding: base64');
lines.push('Content-Disposition: attachment; filename="smime.p7m"');
// Terminate the header block with a BLANK LINE (CRLFCRLF) before the base64
// body. The body is concatenated as a separate Blob below, so a trailing ''
// in `lines` only yields a single CRLF — gluing the CMS onto the last header.
// A strict parser (Stalwart/mail-parser) then reads the base64 as malformed
// headers and leaves the pkcs7-mime part empty, which surfaces on the
// receiving side as "Invalid ASN.1 data - cannot parse CMS envelope".
const headerBytes = new TextEncoder().encode(lines.join(CRLF) + CRLF + CRLF);
return new Blob([headerBytes, cmsToBase64Blob(cmsBlob)], { type: 'message/rfc822' });
}
function cmsToBase64Blob(data) {
let bytes;
if (data instanceof Uint8Array) bytes = data;
else if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
else bytes = new Uint8Array(0);
const b64 = base64Encode(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
return new Blob([new TextEncoder().encode(b64 + CRLF)]);
}
/** Encode string as quoted-printable (RFC 2045). */
export function quotedPrintableEncode(input) {
const bytes = new TextEncoder().encode(input);
const lines = [];
let line = '';
for (const b of bytes) {
let encoded;
if (b === 0x0d || b === 0x0a) {
encoded = String.fromCharCode(b);
} else if (b === 0x09 || (b >= 0x20 && b <= 0x7e && b !== 0x3d)) {
encoded = String.fromCharCode(b);
} else {
encoded = '=' + b.toString(16).toUpperCase().padStart(2, '0');
}
if (b === 0x0a) {
if (line.endsWith('\r')) line = line.slice(0, -1);
lines.push(line);
line = '';
continue;
}
if (line.length + encoded.length > 75) {
lines.push(line + '=');
line = encoded;
} else {
line += encoded;
}
}
lines.push(line);
return lines.join(CRLF);
}
/** Encode ArrayBuffer as base64 with line breaks at 76 chars. */
export function base64Encode(data) {
const bytes = new Uint8Array(data);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
const b64 = btoa(binary);
const lines = [];
for (let i = 0; i < b64.length; i += 76) lines.push(b64.slice(i, i + 76));
return lines.join(CRLF);
}