fix(plugins): scan all bundle scripts, allow audited scanner override

Two problems with the upload scanner, pulling in opposite directions.

It only scanned the entrypoint, so a bundle with `eval()` in a second
file passed outright — verified against a synthetic bundle whose
vendor/openpgp.js tripped three patterns while index.js stayed clean.

At the same time, a hard 400 on eval()/new Function()/innerHTML= makes
every crypto plugin uninstallable: minified openpgp.js and pkijs
legitimately contain all three. That blocks S/MIME and PGP entirely.

Scan every .js/.mjs in the bundle and return structured findings
({file, patterns[]}) plus canOverride, so the admin can see exactly what
tripped and where. An explicit overrideWarnings=true proceeds and writes
a plugin.install.scan_override audit entry recording which patterns in
which files were accepted — not merely that an override happened.

This route is already admin-authenticated, so the scan is defence in
depth against an accidental or compromised upload, not a trust boundary.
Treating it as the latter is what made crypto plugins uninstallable.

Also log the B-04 and B-01 divergences in vnc/VNC-CHANGES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernd Rodler
2026-08-04 08:57:28 +02:00
co-authored by Claude Opus 4.8
parent ae19ad888b
commit d91db37b34
2 changed files with 52 additions and 7 deletions
+49 -7
View File
@@ -158,15 +158,47 @@ export async function POST(request: NextRequest) {
}
const code = await entryFile.async('string');
// Security: block plugins containing dangerous JS patterns
const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`);
pattern.lastIndex = 0;
// Security: scan for dangerous JS patterns across EVERY script in the
// bundle, not just the entrypoint - a second .js file was previously never
// looked at.
//
// The result is a reviewable finding rather than an unconditional reject.
// Minified crypto libraries (openpgp.js, pkijs) legitimately contain these
// patterns, so a hard block makes S/MIME and PGP plugins uninstallable.
// This route is already admin-authenticated, so the scan is defence in
// depth against an accidental or compromised upload, not a trust boundary:
// an admin may proceed with `overrideWarnings`, and the override is
// recorded in the audit log with the exact findings.
const findings: Array<{ file: string; patterns: string[] }> = [];
for (const [filePath, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
if (ext !== '.js' && ext !== '.mjs') continue;
const source = filePath === root + (manifest.entrypoint as string)
? code
: await entry.async('string');
const hits: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(source)) hits.push(label);
pattern.lastIndex = 0;
}
if (hits.length > 0) {
findings.push({ file: filePath.slice(root.length), patterns: hits });
}
}
if (warnings.length > 0) {
const overrideWarnings = formData.get('overrideWarnings') === 'true';
if (findings.length > 0 && !overrideWarnings) {
const summary = findings
.map(f => `${f.file}: ${f.patterns.join(', ')}`)
.join('; ');
return NextResponse.json(
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
{
error: `Plugin rejected: ${summary}. Review the bundle; if these are expected `
+ `(e.g. a vendored crypto library), re-upload with "overrideWarnings" to proceed.`,
findings,
canOverride: true,
},
{ status: 400 },
);
}
@@ -212,6 +244,16 @@ export async function POST(request: NextRequest) {
await savePlugin(plugin, code);
invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
if (findings.length > 0) {
// Record WHAT was waved through, not merely that an override happened -
// otherwise the audit trail can't answer "which patterns did we accept?".
await auditLog(
'plugin.install.scan_override',
{ id: plugin.id, version: plugin.version, findings },
ip,
);
logger.warn('Plugin installed with scanner override', { id: plugin.id, findings });
}
return NextResponse.json({ plugin });
} catch (error) {
+3
View File
@@ -47,5 +47,8 @@ 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 | 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. |
_(append new rows as you diverge)_