Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 2 of the sequence). A remote page could previously inherit the privileged preload bridge: the main window had no navigation guard or popup handler, IPC handlers accepted any sender, every Electron permission was granted to everyone, and shell:openPath accepted an arbitrary renderer-supplied target. - New app/security.js (plain-Node testable) centralizes the policy: app-page identity, navigation/popup denial, deny-by-default permissions, external-URL validation, IPC sender guard, payload budgets, field validators, and a produced-files registry. - Every window (main, region overlay, capture worker) is sandboxed, denies all navigation away from its own page, denies every popup, and refuses webview attachment. - Every IPC channel now rejects events that are not from the main window's top frame on index.html, rejects non-plain/oversized argument bags, and channels with risky inputs validate ids, enums, names, and sizes (path-traversal and prototype-pollution guards included). - Permissions are deny-by-default; the only grant in the app is display capture (+media) for the dedicated capture-worker page. The display-media handler also verifies the requesting frame. - shell:openPath/showItemInFolder are gone. Replacements are intent-specific: openProduced (only files the main process produced this session), revealLinkedArchive (path read from the store, not the renderer), and openExternal (parsed, scheme-checked http(s)/mailto only). - archive:peek removed: unused, and it let the renderer read arbitrary archives by path. - export:run only accepts output directories that came from a dialog pick or remembered settings; anything else re-prompts. - Renderer: description links never navigate; http(s)/mailto open externally via the validated handler. - 12 new security regression tests: hostile navigation targets, permission matrix, sender spoofing (wrong window/subframe/navigated/disposed frames), oversized payloads, traversal/pollution attempts, produced-file registry, and source-level guards (no blanket grants, no generic shell channels, sandbox on every window). Verified: 215 unit tests pass; startup smoke, unit-workflows, sample artifacts, and build-release E2E pass; click self-test still reaches source: stream with markers 3/3 and burst 8/8 under the deny-by-default policy (arm/debounce remain the known pre-existing failures, untouched here); UI screenshot confirms the sandboxed renderer boots. Co-Authored-By: Claude Fable 5 <[email protected]>
303 lines
9.2 KiB
JavaScript
303 lines
9.2 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Privilege-boundary policy for every renderer surface.
|
|
*
|
|
* This module is deliberately loadable under plain Node (no electron
|
|
* require at module scope) so the decision logic is unit-testable:
|
|
* - which URLs count as our own app pages,
|
|
* - whether a navigation/popup may proceed (it may not),
|
|
* - which permission a renderer may be granted (display capture for the
|
|
* dedicated capture worker only),
|
|
* - whether an IPC event comes from the trusted main-window frame,
|
|
* - which external URLs may be handed to shell.openExternal,
|
|
* - which filesystem paths the renderer may ask the shell to open
|
|
* (only files the main process itself produced).
|
|
*/
|
|
|
|
const path = require('node:path');
|
|
const { pathToFileURL } = require('node:url');
|
|
|
|
const RENDERER_DIR = path.join(__dirname, 'renderer');
|
|
|
|
const APP_PAGES = {
|
|
main: pathToFileURL(path.join(RENDERER_DIR, 'index.html')).href,
|
|
region: pathToFileURL(path.join(RENDERER_DIR, 'region.html')).href,
|
|
captureWorker: pathToFileURL(path.join(RENDERER_DIR, 'capture-worker.html')).href,
|
|
};
|
|
|
|
/** Normalize a URL for identity comparison: drop query/hash, decode path. */
|
|
function normalizeAppUrl(rawUrl) {
|
|
let url;
|
|
try {
|
|
url = new URL(String(rawUrl));
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (url.protocol !== 'file:') return null;
|
|
let pathname;
|
|
try {
|
|
pathname = decodeURIComponent(url.pathname);
|
|
} catch {
|
|
pathname = url.pathname;
|
|
}
|
|
// Windows drive letters may differ in case between loadFile and senderFrame.
|
|
if (process.platform === 'win32') pathname = pathname.toLowerCase();
|
|
return pathname;
|
|
}
|
|
|
|
function isAppPageUrl(rawUrl, page) {
|
|
const expected = APP_PAGES[page];
|
|
if (!expected) return false;
|
|
const a = normalizeAppUrl(rawUrl);
|
|
const b = normalizeAppUrl(expected);
|
|
return a !== null && a === b;
|
|
}
|
|
|
|
/**
|
|
* Navigation policy: a privileged window may only ever stay on the exact
|
|
* page it was created with. Everything else — remote URLs, other local
|
|
* files, javascript:, data: — is denied.
|
|
*/
|
|
function navigationAllowed(targetUrl, page) {
|
|
return isAppPageUrl(targetUrl, page);
|
|
}
|
|
|
|
/**
|
|
* Permission policy for Electron sessions. Display capture (and the media
|
|
* permission that getDisplayMedia consults) is granted only to the dedicated
|
|
* hidden capture-worker page. Everything else is denied for everyone,
|
|
* including our own main window.
|
|
*/
|
|
function permissionAllowed(permission, requestingUrl) {
|
|
if (permission === 'media' || permission === 'display-capture') {
|
|
return isAppPageUrl(requestingUrl, 'captureWorker');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* External-link policy: only well-formed http(s) and mailto URLs may reach
|
|
* shell.openExternal. Returns the normalized URL string or null.
|
|
*/
|
|
function validateExternalUrl(rawUrl) {
|
|
if (typeof rawUrl !== 'string' || rawUrl.length > 2048) return null;
|
|
let url;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (url.protocol === 'https:' || url.protocol === 'http:') {
|
|
if (!url.hostname) return null;
|
|
return url.href;
|
|
}
|
|
if (url.protocol === 'mailto:') return url.href;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* IPC sender guard: an invoke event is trusted only when it originates from
|
|
* the current main window's top frame, and that frame is our index.html.
|
|
* Destroyed frames, subframes, other windows, and navigated-away frames are
|
|
* all rejected.
|
|
*/
|
|
function makeIpcSenderGuard({ getMainWebContents }) {
|
|
return function trustedSender(event) {
|
|
if (!event || typeof event !== 'object') return false;
|
|
const expected = getMainWebContents();
|
|
if (!expected || event.sender !== expected) return false;
|
|
const frame = event.senderFrame;
|
|
if (!frame) return false;
|
|
try {
|
|
if (frame.parent) return false; // top frame only
|
|
return isAppPageUrl(frame.url, 'main');
|
|
} catch {
|
|
// Accessing a disposed WebFrameMain throws — reject.
|
|
return false;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cheap recursive payload budget: sums string lengths (and key counts) with
|
|
* early exit. Protects handlers from absurd payloads without JSON.stringify
|
|
* on hundred-megabyte image saves.
|
|
*/
|
|
function payloadWithinBudget(value, maxChars, depth = 0) {
|
|
let budget = maxChars;
|
|
|
|
const walk = (v, d) => {
|
|
if (budget < 0 || d > 16) return false;
|
|
if (v == null) return true;
|
|
const t = typeof v;
|
|
if (t === 'string') {
|
|
budget -= v.length;
|
|
return budget >= 0;
|
|
}
|
|
if (t === 'number' || t === 'boolean') {
|
|
budget -= 8;
|
|
return budget >= 0;
|
|
}
|
|
if (t === 'function' || t === 'symbol' || t === 'bigint') return false;
|
|
if (Array.isArray(v)) {
|
|
if (v.length > 100000) return false;
|
|
for (const item of v) if (!walk(item, d + 1)) return false;
|
|
return true;
|
|
}
|
|
if (t === 'object') {
|
|
const keys = Object.keys(v);
|
|
if (keys.length > 4096) return false;
|
|
for (const key of keys) {
|
|
budget -= key.length;
|
|
if (budget < 0) return false;
|
|
if (!walk(v[key], d + 1)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return walk(value, depth);
|
|
}
|
|
|
|
/** True for plain-object argument bags (what every IPC channel expects). */
|
|
function isPlainArgs(args) {
|
|
if (args === undefined || args === null) return true;
|
|
if (typeof args !== 'object' || Array.isArray(args)) return false;
|
|
const proto = Object.getPrototypeOf(args);
|
|
return proto === Object.prototype || proto === null;
|
|
}
|
|
|
|
// ---- field validators (used by main.js per-channel checks) ----------------
|
|
|
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
|
|
const check = {
|
|
/** Guide/step/folder/template identifiers and snapshot/trash entry names:
|
|
* single path segment, no separators, no dot-dot, sane length. */
|
|
id(v) {
|
|
return typeof v === 'string' && ID_RE.test(v) && !v.includes('..');
|
|
},
|
|
optionalId(v) {
|
|
return v === null || v === undefined || check.id(v);
|
|
},
|
|
string(v, max = 4096) {
|
|
return typeof v === 'string' && v.length <= max;
|
|
},
|
|
optionalString(v, max = 4096) {
|
|
return v === null || v === undefined || check.string(v, max);
|
|
},
|
|
bool(v) {
|
|
return typeof v === 'boolean';
|
|
},
|
|
number(v, min = -1e15, max = 1e15) {
|
|
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
|
},
|
|
optionalNumber(v, min, max) {
|
|
return v === null || v === undefined || check.number(v, min, max);
|
|
},
|
|
oneOf(v, values) {
|
|
return values.includes(v);
|
|
},
|
|
base64(v, maxChars = 192 * 1024 * 1024) {
|
|
return typeof v === 'string' && v.length <= maxChars && /^[A-Za-z0-9+/=\r\n]*$/.test(v.slice(0, 4096));
|
|
},
|
|
optionalBase64(v, maxChars) {
|
|
return v === null || v === undefined || check.base64(v, maxChars);
|
|
},
|
|
/** Single filesystem name (template/snapshot/trash entries): no path
|
|
* separators, no traversal, no NUL, printable, bounded length. */
|
|
fileName(v, max = 160) {
|
|
return (
|
|
typeof v === 'string' &&
|
|
v.trim().length > 0 &&
|
|
v.length <= max &&
|
|
!/[/\\\0]/.test(v) &&
|
|
!v.includes('..') &&
|
|
v !== '.' &&
|
|
// eslint-disable-next-line no-control-regex
|
|
!/[\x00-\x1f]/.test(v)
|
|
);
|
|
},
|
|
optionalFileName(v, max) {
|
|
return v === null || v === undefined || check.fileName(v, max);
|
|
},
|
|
/** settings keyPath: dotted segments, no prototype-pollution segments. */
|
|
settingsKeyPath(v) {
|
|
if (typeof v !== 'string' || v.length === 0 || v.length > 200) return false;
|
|
const segments = v.split('.');
|
|
return segments.every(
|
|
(segment) =>
|
|
/^[A-Za-z0-9_-]+$/.test(segment) &&
|
|
!['__proto__', 'constructor', 'prototype'].includes(segment)
|
|
);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Registry of files the main process itself produced (export outputs,
|
|
* previews) and may therefore re-open on renderer request. Bounded LRU.
|
|
*/
|
|
class ProducedFiles {
|
|
constructor(limit = 256) {
|
|
this.limit = limit;
|
|
this.paths = new Set();
|
|
}
|
|
|
|
key(p) {
|
|
const resolved = path.resolve(String(p));
|
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
}
|
|
|
|
add(p) {
|
|
if (!p || typeof p !== 'string') return;
|
|
const key = this.key(p);
|
|
this.paths.delete(key);
|
|
this.paths.add(key);
|
|
while (this.paths.size > this.limit) {
|
|
this.paths.delete(this.paths.values().next().value);
|
|
}
|
|
}
|
|
|
|
has(p) {
|
|
if (!p || typeof p !== 'string') return false;
|
|
return this.paths.has(this.key(p));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attach the navigation/popup policy to a BrowserWindow. `page` names the
|
|
* app page this window is allowed to display (key of APP_PAGES).
|
|
*/
|
|
function installWindowSecurity(win, page) {
|
|
const contents = win.webContents;
|
|
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
|
contents.on('will-navigate', (event, url) => {
|
|
if (!navigationAllowed(url, page)) event.preventDefault();
|
|
});
|
|
contents.on('will-frame-navigate', (details) => {
|
|
if (!navigationAllowed(details.url, page) && typeof details.preventDefault === 'function') {
|
|
details.preventDefault();
|
|
}
|
|
});
|
|
// Defense in depth: if a disallowed document somehow starts loading,
|
|
// never let it attach webviews either.
|
|
contents.on('will-attach-webview', (event) => event.preventDefault());
|
|
}
|
|
|
|
module.exports = {
|
|
APP_PAGES,
|
|
ProducedFiles,
|
|
check,
|
|
installWindowSecurity,
|
|
isAppPageUrl,
|
|
isPlainArgs,
|
|
makeIpcSenderGuard,
|
|
navigationAllowed,
|
|
normalizeAppUrl,
|
|
payloadWithinBudget,
|
|
permissionAllowed,
|
|
validateExternalUrl,
|
|
};
|