mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(desktop): stop sandboxed preload from requiring a local module (#2111)
* fix(desktop): stop sandboxed preload from requiring a local module
The preload imported PASEO_BROWSER_PROFILE_PARTITION from the local module
./features/browser-profile.js. The preload runs in Electron's sandbox and is
tsc-compiled (not bundled), so that import emits require("./features/
browser-profile.js") at the top of preload.js. A sandboxed preload cannot
require a local module, so the require throws before
contextBridge.exposeInMainWorld runs. window.paseoDesktop is then undefined,
the app no longer detects itself as the desktop host, and it never starts the
built-in daemon (users had to run `paseo start` by hand).
The import was added in 0.1.108; 0.1.107 is clean, which matches the reports
that downgrading fixes it. See #2103.
Inline the one value the preload needs (the partition string) so it no longer
loads any local module, and keep browser-profile.ts as the source of truth for
the main process. Add preload-sandbox.test.ts, which parses preload.ts with the
TypeScript compiler API and asserts the only runtime module load is "electron"
(covering value imports, side-effect imports, re-exports, require, dynamic
import, and import-equals; type-only imports are ignored), plus a drift check
that the inlined literal matches the canonical constant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(desktop): clearer preload drift-check failure message
Assert the inlined PASEO_BROWSER_PROFILE_PARTITION is present before comparing its value, so a missing/renamed constant reports "not found" instead of a misleading value-drift mismatch (Greptile feedback on #2111).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
99
packages/desktop/src/preload-sandbox.test.ts
Normal file
99
packages/desktop/src/preload-sandbox.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PASEO_BROWSER_PROFILE_PARTITION } from "./features/browser-profile.js";
|
||||
|
||||
// The preload runs inside Electron's sandbox and is tsc-compiled (not bundled), so at
|
||||
// runtime it may only load Electron's sandbox allowlist. Any other module (local or
|
||||
// third-party) emits a require() that the sandbox rejects synchronously, aborting the
|
||||
// preload before contextBridge.exposeInMainWorld runs and leaving window.paseoDesktop
|
||||
// undefined. That regression (0.1.108, #2103) is what this test guards against.
|
||||
const SANDBOX_ALLOWLIST = new Set(["electron"]);
|
||||
|
||||
const preloadPath = join(dirname(fileURLToPath(import.meta.url)), "preload.ts");
|
||||
|
||||
// Collect every module specifier that survives to emitted JavaScript as a runtime load.
|
||||
// Type-only imports/exports are erased by tsc and are therefore ignored.
|
||||
function runtimeModuleSpecifiers(source: string): string[] {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
"preload.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
/* setParentNodes */ true,
|
||||
);
|
||||
const specifiers: string[] = [];
|
||||
|
||||
const record = (node: ts.Expression | undefined): void => {
|
||||
if (node && ts.isStringLiteralLike(node)) {
|
||||
specifiers.push(node.text);
|
||||
}
|
||||
};
|
||||
|
||||
const isTypeOnlyImport = (node: ts.ImportDeclaration): boolean => {
|
||||
const clause = node.importClause;
|
||||
if (!clause) {
|
||||
// Side-effect import: `import "./x.js"` — always a runtime load.
|
||||
return false;
|
||||
}
|
||||
if (clause.isTypeOnly) {
|
||||
return true;
|
||||
}
|
||||
const bindings = clause.namedBindings;
|
||||
// `import { type A, type B } from "x"` erases entirely; a default or namespace
|
||||
// binding, or any value-named binding, keeps the module at runtime.
|
||||
return (
|
||||
clause.name === undefined &&
|
||||
bindings !== undefined &&
|
||||
ts.isNamedImports(bindings) &&
|
||||
bindings.elements.length > 0 &&
|
||||
bindings.elements.every((element) => element.isTypeOnly)
|
||||
);
|
||||
};
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
if (!isTypeOnlyImport(node)) {
|
||||
record(node.moduleSpecifier);
|
||||
}
|
||||
} else if (ts.isExportDeclaration(node) && !node.isTypeOnly && node.moduleSpecifier) {
|
||||
record(node.moduleSpecifier);
|
||||
} else if (
|
||||
ts.isImportEqualsDeclaration(node) &&
|
||||
ts.isExternalModuleReference(node.moduleReference)
|
||||
) {
|
||||
record(node.moduleReference.expression);
|
||||
} else if (ts.isCallExpression(node)) {
|
||||
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
|
||||
const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
||||
if (isRequire || isDynamicImport) {
|
||||
record(node.arguments[0]);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
describe("preload sandbox safety", () => {
|
||||
it("only loads Electron's sandbox allowlist at runtime", () => {
|
||||
const source = readFileSync(preloadPath, "utf8");
|
||||
const disallowed = runtimeModuleSpecifiers(source).filter(
|
||||
(specifier) => !SANDBOX_ALLOWLIST.has(specifier),
|
||||
);
|
||||
expect(disallowed).toEqual([]);
|
||||
});
|
||||
|
||||
it("inlines the browser profile partition instead of importing it", () => {
|
||||
const source = readFileSync(preloadPath, "utf8");
|
||||
const match = source.match(/const\s+PASEO_BROWSER_PROFILE_PARTITION\s*=\s*"([^"]+)"/);
|
||||
expect(
|
||||
match,
|
||||
"PASEO_BROWSER_PROFILE_PARTITION not found as a double-quoted string literal in preload.ts",
|
||||
).not.toBeNull();
|
||||
expect(match![1]).toBe(PASEO_BROWSER_PROFILE_PARTITION);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||
import { PASEO_BROWSER_PROFILE_PARTITION } from "./features/browser-profile.js";
|
||||
|
||||
// This preload runs in Electron's sandbox and is tsc-compiled (not bundled), so it MUST
|
||||
// NOT emit any runtime module load other than "electron" — a require() of a local or
|
||||
// third-party module throws and aborts the preload before exposeInMainWorld runs, leaving
|
||||
// window.paseoDesktop undefined (the 0.1.108 regression, #2103). Keep this literal in sync
|
||||
// with PASEO_BROWSER_PROFILE_PARTITION in features/browser-profile.ts; preload-sandbox.test.ts
|
||||
// guards both the no-local-import rule and this drift. Type-only imports are fine (erased at emit).
|
||||
const PASEO_BROWSER_PROFILE_PARTITION = "persist:paseo-browser";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user