Files
SRCmail/vnc/VNC-CHANGES.md
T
Bernd RodlerandClaude Opus 4.8 bc5d2a57e8 security(smime): fix audit finding 2 — unauthenticated CBC on decrypt
Upstream applied no content-encryption check at all on decrypt, and ran
every decryption through the liner engine — which registers DES-CBC,
3DES-CBC and RC2-CBC. Those OIDs exist in crypto-engine.js for PKCS#12
password-based encryption; the CMS content path merely reused the same
engine and inherited them. A crafted message could therefore be decrypted
under a broken cipher, and unauthenticated plaintext was handed straight
to the renderer — the EFAIL precondition.

The obvious fix would have been wrong. Accepting only AEAD breaks 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:

1. Allowlist the AES family and refuse everything else, with the gate
   running before any private key is touched. CBC stays for interop;
   DES/3DES/RC2 are refused.

2. Take the mail path off the legacy engine. Normal decryption now uses
   nativeEngine(); the liner engine is reachable only when a genuine
   legacy RSAES-PKCS1-v1_5 key is in play. This removes the weak ciphers
   structurally rather than by policy — native WebCrypto handles RSA-OAEP
   key transport and AES-CBC/GCM content perfectly well.

3. Refuse to render unauthenticated plaintext as HTML. CBC output is
   malleable and HTML is EFAIL's exfiltration channel. The host does block
   remote content by default (allowExternalContent starts false), but that
   is a user/admin setting this plugin cannot observe, so we don't lean on
   it. New renderUnauthenticatedHtml setting (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 degrades to text.

Built from source with the repo's own pipeline (esbuild, 1.69 MB) and
packaged to smime-vnc.zip (0.27 MB). All four fixes verified present in
the built bundle. Build output is gitignored — never vendor a prebuilt
bundle, which was the upstream mistake.

Correcting an earlier assumption: this bundle does NOT trip the B-01
pattern scanner (zero matches on all five patterns), so the override is
not needed to install it. B-01 remains correct — it closed a real
entrypoint-only coverage gap — but it isn't load-bearing here.

verify-fixes.mjs now carries 36 assertions covering all three fixes,
including source checks that fail if a guard is removed, if a legacy CBC
OID reappears in the allowlist, or if the mail path stops using the
native engine.

Findings 4 (unlocked keys persisted to IndexedDB), 5 (parser DoS) and 6
(PKCS1v1.5 oracle surface) remain open.

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

8.8 KiB

VNC-CHANGES — VNCmail+ divergence log

VNCmail+ is a fork of bulwarkmail/webmail (AGPL-3.0). This file records every intentional divergence from upstream so that merging new upstream releases stays a triage exercise, not an archaeology dig.

Rules of the fork

  1. Keep upstream files unmodified whenever possible. Prefer env vars (branding), the vnc/overrides/ layer, and additive files over editing files inside app/, components/, lib/, stores/.
  2. Every edit to an upstream file gets a one-line entry below — path, what, why. No silent edits.
  3. Branches: main tracks upstream releases (kept clean); dev is the VNC integration + Vercel deploy branch; vnc/* are feature branches.
  4. Syncing upstream: git fetch upstream && git merge upstream/main onto main, then merge main into dev. Resolve using this log.

Divergences

Date File / area Change Why
2026-08-03 vnc/ (new) Added VNC customization dir + this log Fork bootstrap
2026-08-03 VNCMAIL-SETUP.md (new) Vercel deploy runbook Deploy on Vercel as project "VNCmail+"

| 2026-08-03 | deploy/k8s/ (new) | k8s manifests + runbook for microk8s deploy | Bulwark is stateful → runs as a container w/ persistent volumes, not Vercel serverless | | 2026-08-03 | .gitignore | ignore deploy/k8s/secret.yaml | keep the real env secret out of git | | 2026-08-03 | public/branding/*.svg (new) | VNCmail wordmark (on-dark + on-light) | VNC logo (placeholder — swap official SVG) | | 2026-08-03 | public/fonts/*.woff2 (new) | DM Sans 400/500/700 + Syne 700/800 (OFL, self-hosted) | VNClagoon typography | | 2026-08-03 | lib/builtin-themes.ts | add builtin-vnclagoon theme (navy+cyan, DM Sans/Syne, @font-face) | VNClagoon brand theme | | 2026-08-03 | lib/admin/types.ts | DEFAULT_THEME_POLICY.defaultThemeIdbuiltin-vnclagoon | make VNClagoon the default theme | | 2026-08-03 | stores/theme-store.ts | default theme/resolvedThemedark | dark-first per VNClagoon styleguide | | 2026-08-03 | lib/builtin-themes.ts | add builtin-src theme (Swiss red on white, light-first) | 2nd brand theme (SRC Advisory); VNClagoon stays default | | 2026-08-03 | public/branding/src-logo.svg (new) | SRC mountain mark | SRC brand (placeholder — swap official) | | 2026-08-03 | lib/plugin-types.ts | add optional logoLightUrl/logoDarkUrl to InstalledTheme | per-theme brand logos | | 2026-08-03 | lib/theme-logo.ts (new) | resolveThemeLogo() helper | pick active theme's logo, fall back to global | | 2026-08-03 | lib/builtin-themes.ts | set logos on vnclagoon (wordmark) + src (mark) | logo switches with the brand theme | | 2026-08-03 | app/(main)/[locale]/login/page.tsx | login logo uses active theme's logo | brand-switch on login | | 2026-08-03 | components/layout/navigation-rail.tsx | nav-rail logo uses active theme's logo | brand-switch in app |

Note: Vercel was tried and abandoned on 2026-08-03. Bulwark writes to a local data dir (/app/data/*); Vercel serverless has a read-only filesystem → crash (ENOENT /var/task/data). VNCmail+ now deploys as a Docker image (ghcr.io/brvncde-dotcom/vncmail-plus-*) on Kubernetes (microk8s) at vncmail.sandbox.vnc.de, with 4 persistent volumes — see deploy/k8s/. A microfrontends integration was also added and reverted the same day.

| 2026-08-03 | lib/stalwart/auth-context.ts | give jmap_stalwart_ctx a 6-hour maxAge (was session-cookie → expired on tab close) | session survival across browser restarts | | 2026-08-03 | lib/builtin-themes.ts | add srcSkin (MD3 component overrides: shape scale, filled buttons, text fields, cards, dialogs, state layers, switches, login card); add @font-face + typography to builtin-src; bump to v1.1.0 | SRC theme: keep colors + fonts, apply MD3 design system | | 2026-08-04 | lib/plugin-sandbox/loader.ts | B-04 security fix — gate hook registration on granted permissions via new HOOK_PERMISSIONS map; refused hooks are skipped, logged and counted | info.hooks is self-reported by the sandbox, so an untrusted plugin could claim onRenderEmailBody and replace any rendered email body without holding email:render-takeover. Consent copy gated what the user was asked, not what the host allowed. | | 2026-08-04 | lib/plugin-sandbox/host-api.ts | export hasPermission() (was module-private) | one source of truth for the permission rule — the loader gate and the RPC gate must not drift apart | | 2026-08-04 | app/api/admin/plugins/route.ts | B-01 — scan all .js/.mjs in the bundle (was entrypoint only); return structured findings + canOverride; allow admin overrideWarnings=true with a plugin.install.scan_override audit entry; echo accepted findings on success | hard-reject on eval(/new Function(/innerHTML = made every crypto plugin uninstallable (minified openpgp.js/pkijs trip it), while only scanning the entrypoint left a trivial bypass. Route is already admin-authenticated, so the scan is defence-in-depth, not a trust boundary. | | 2026-08-04 | vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md (new) | S-01 security audit of bulwarkmail/plugins/smime @ 91085a3 — 9 findings (2 HIGH, 1 MED-HIGH), verdict: fork and fix before shipping | privileged same-origin plugin that handles users' private keys; verdict must precede any deploy | | 2026-08-04 | vnc/plugins/smime/ (new) | fork of the upstream S/MIME plugin, source only — upstream smime.zip deliberately NOT vendored | shipped zip is a 1.77 MB bundle at manifest 1.0.1 while source is 1.0.2, so auditing src/ would not audit what the zip installs. We build from source via npm run package. | | 2026-08-04 | vnc/plugins/smime/src/index.js | audit fix 1 (HIGH)maybeAutoImportSigner now requires signerEmailMatch === true and !selfSigned before trusting a signer cert | upstream gated on signatureValid alone, but smimeVerify runs checkChain:false, so a self-signed cert asserting any address was silently stored as the ENCRYPTION TARGET for it. Both values were already computed and ignored. | | 2026-08-04 | vnc/plugins/smime/src/mime-builder.js | audit fix 3 (MED-HIGH)stripCrlf() applied inside formatHeader + the 3 directly-assembled headers (att.contentType, att.cid, smimeType) | CRLF escaping reached only Subject and filename; display names, Message-ID, In-Reply-To and References were raw — and those are copied from inbound mail on reply/forward, making it remotely reachable header injection | | 2026-08-04 | vnc/plugins/smime/src/smime-decrypt.js | audit fix 2 (HIGH) — content-encryption allowlist (AES-CBC + AES-GCM only, gate runs before any key use); normal decrypt moved to nativeEngine() so the liner engine is reachable only for a genuine legacy RSAES-PKCS1-v1_5 key; return contentAuthenticated | upstream applied NO algorithm check and ran every decrypt through the liner engine, which registers DES-CBC/3DES-CBC/RC2-CBC for PKCS#12 password encryption — the CMS path inherited them. AEAD-only would break interop (RFC 5751 mandates AES-128-CBC), so CBC stays and the weak ciphers go. | | 2026-08-04 | vnc/plugins/smime/src/index.js | audit fix 2 (cont.) — suppress HTML when content is unauthenticated (CBC), text-only, behind new renderUnauthenticatedHtml setting (default false) | CMS EnvelopedData has no MAC, so CBC plaintext is malleable and HTML rendering is EFAIL's exfiltration channel. The host blocks remote content by default but that's a setting the plugin can't observe — don't lean on it. Our own encrypt path is always AES-GCM, so outbound mail renders fully. | | 2026-08-04 | .gitignore | ignore vnc/plugins/smime/{node_modules,dist,smime-vnc.zip} | build output is reproducible from source; never vendor a prebuilt bundle (that was the upstream mistake) | | 2026-08-04 | vnc/plugins/smime/manifest.json | add auth:observe | plugin registers onAfterLogout/onAccountSwitch (real hooks, lib/plugin-hooks.ts:362-363) without declaring the permission; under B-09 the session-key wipe would silently stop running | | 2026-08-04 | vnc/plugins/smime/verify-fixes.mjs (new) | 19 regression assertions for both fixes, incl. source checks that fail if a guard is removed | the source assertion caught an interpolated header manual review had wrongly dismissed as static | | 2026-08-04 | app/(main)/admin/_tabs/plugins.tsx | B-01 (UI) — scanner-findings review panel: holds the rejected file, lists pattern-per-file, offers "Install anyway" / "Cancel"; success message reports how many findings were accepted | without this the override was API-only — an admin uploading a crypto bundle through the web form hit a 400 they could not act on. Also replaces a dead data.warnings read (never returned by the route) with the live findings field. |

(append new rows as you diverge)