From 21210cca908d85ac7756a8ed774903f92746a68a Mon Sep 17 00:00:00 2001
From: mozluk <160273088+mozluk@users.noreply.github.com>
Date: Wed, 9 Sep 2026 17:45:54 +0300
Subject: [PATCH 1/2] Harden Electron Navigation, Permission Gates, and
Renderer Content Security Policy in Vigil
### Description
This pull request resolves High severity client-side security and privilege escalation findings identified during the workspace audit for the `vigil` desktop application. Previously, arbitrary protocol schemes could reach OS-level handlers via unconstrained `shell.openExternal` calls, renderer sessions lacked a sandbox boundary, permissions were open to default handling, and the renderer omitted a Content Security Policy (CSP). This PR enforces an external URL protocol allowlist, sandboxes the renderer process, registers fail-closed permission and navigation guards across all WebContents, and adds a strict meta CSP.
### Key Changes & Remediations
#### 1. IPC & External Navigation Hardening (`electron/main.ts`)
* **Protocol Allowlist:** Restricted `shell.openExternal` strictly to `ALLOWED_EXTERNAL_PROTOCOLS` (`http:`, `https:`). Non-web schemes (such as `file:`, `ms-msdt:`, or custom protocols) are blocked and warned rather than handed to the host OS.
* **Origin-Checked Internal Navigation:** Added `isInternalUrl` origin verification for `will-navigate` and `will-redirect` to ensure unhandled window navigations cannot load third-party origins into a window with bridge access.
* **Global Navigation Guards:** Enforced `setWindowOpenHandler` on both `mainWindow` and globally within `app.on("web-contents-created")` to intercept and deny popup creation while safely delegating external links to the default browser.
* **Webview Denial:** Intercepted and cancelled `will-attach-webview` events to disallow untrusted guest contexts.
#### 2. Sandbox & Permissions (`electron/main.ts`)
* **Process Sandboxing:** Enabled `sandbox: true` under `BrowserWindow.webPreferences` to restrict OS-level syscall access from compromised renderers.
* **Deny-by-Default Permissions:** Implemented `session.setPermissionRequestHandler` to automatically reject all browser-level permission inquiries (camera, microphone, geolocation, etc.).
#### 3. Renderer Content Security Policy (`electron/renderer/index.html`)
* **Strict Meta CSP:** Introduced a restrictive CSP meta tag enforcing `default-src 'self'`, `object-src 'none'`, `frame-src 'none'`, and `base-uri 'self'` to block remote script injections and untrusted framing[cite: 12].
### How to Review
1. **Scheme Validation:** Check `isSafeExternalUrl` in `electron/main.ts` to confirm only `http:` and `https:` are permitted.
2. **WebContents Interception:** Review `app.on("web-contents-created")` to verify both webview attachment and window creation handlers fail-closed.
3. **CSP Directives:** Inspect `` in `electron/renderer/index.html` to ensure dangerous sinks (`object-src`, `frame-src`) are disabled[cite: 12].
---
electron/main.ts | 428 +++++++++++++++++++++++++++-----------------
renderer/index.html | 72 ++++++--
2 files changed, 314 insertions(+), 186 deletions(-)
diff --git a/electron/main.ts b/electron/main.ts
index 3820786..5faef8b 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -1,168 +1,260 @@
-import { app, BrowserWindow, ipcMain, safeStorage, Notification, shell, nativeImage } from "electron";
-import { ConvexClient } from "convex/browser";
-import { api } from "../convex/_generated/api";
-import path from "path";
-import fs from "fs";
-
-const CONVEX_URL = process.env.CONVEX_URL ?? "https://next-pika-124.convex.cloud";
-const SESSION_FILE = path.join(app.getPath("userData"), "session.enc");
-
-let mainWindow: BrowserWindow | null = null;
-
-// ── Session token storage (encrypted at rest) ──
-
-function readStoredToken(): string | null {
- try {
- if (!fs.existsSync(SESSION_FILE)) return null;
- const encrypted = fs.readFileSync(SESSION_FILE);
- if (!safeStorage.isEncryptionAvailable()) return null;
- return safeStorage.decryptString(encrypted);
- } catch {
- return null;
- }
-}
-
-function storeToken(token: string): void {
- if (!safeStorage.isEncryptionAvailable()) {
- throw new Error("Encryption not available");
- }
- const encrypted = safeStorage.encryptString(token);
- fs.writeFileSync(SESSION_FILE, encrypted);
-}
-
-function clearToken(): void {
- try {
- fs.unlinkSync(SESSION_FILE);
- } catch {
- // file may not exist
- }
-}
-
-// ── IPC handlers ──
-
-ipcMain.handle("store-session", (_event, token: string) => {
- storeToken(token);
- startNotificationSubscription(token);
-});
-
-ipcMain.handle("get-session", () => {
- return readStoredToken();
-});
-
-ipcMain.handle("clear-session", () => {
- clearToken();
- stopNotificationSubscription();
-});
-
-ipcMain.handle("open-external", (_event, url: string) => {
- shell.openExternal(url);
-});
-
-// ── Native notification bridge ──
-
-let notifClient: ConvexClient | null = null;
-let lastSeenTimestamp = Date.now();
-
-function startNotificationSubscription(sessionToken: string) {
- stopNotificationSubscription();
-
- // Set baseline to now so we only fire for truly new notifications
- lastSeenTimestamp = Date.now();
- let isFirstUpdate = true;
-
- notifClient = new ConvexClient(CONVEX_URL);
- notifClient.onUpdate(
- api.queries.getUnreadNotifications,
- { sessionToken },
- (notifications: Array<{ title: string; body: string; timestamp: number }>) => {
- if (!notifications) return;
-
- // Skip the initial snapshot — only fire for subsequent updates
- if (isFirstUpdate) {
- isFirstUpdate = false;
- if (notifications.length > 0) {
- lastSeenTimestamp = Math.max(...notifications.map((n) => n.timestamp));
- }
- return;
- }
-
- for (const n of notifications) {
- if (n.timestamp > lastSeenTimestamp) {
- const notif = new Notification({ title: n.title, body: n.body ?? "" });
- notif.show();
- }
- }
- if (notifications.length > 0) {
- const maxTs = Math.max(...notifications.map((n) => n.timestamp));
- if (maxTs > lastSeenTimestamp) {
- lastSeenTimestamp = maxTs;
- }
- }
- },
- );
-}
-
-function stopNotificationSubscription() {
- if (notifClient) {
- notifClient.close();
- notifClient = null;
- }
-}
-
-// ── Window creation ──
-
-function createWindow() {
- mainWindow = new BrowserWindow({
- width: 1200,
- height: 800,
- backgroundColor: "#09090b",
- titleBarStyle: "hiddenInset",
- icon: path.join(__dirname, "../../resources/glass-icon.png"),
- webPreferences: {
- preload: path.join(__dirname, "../preload/preload.cjs"),
- contextIsolation: true,
- nodeIntegration: false,
- sandbox: false,
- },
- });
-
- if (process.env.ELECTRON_RENDERER_URL) {
- mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
- } else {
- mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
- }
-
-
- mainWindow.on("closed", () => {
- mainWindow = null;
- });
-}
-
-app.whenReady().then(() => {
- if (process.platform === "darwin") {
- app.dock.setIcon(path.join(__dirname, "../../resources/glass-icon.png"));
- }
- createWindow();
-
- // Start notification subscription if we have a stored session
- const token = readStoredToken();
- if (token) {
- startNotificationSubscription(token);
- }
-
- app.on("activate", () => {
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow();
- }
- });
-});
-
-app.on("window-all-closed", () => {
- if (process.platform !== "darwin") {
- app.quit();
- }
-});
-
-app.on("before-quit", () => {
- stopNotificationSubscription();
-});
+import { app, BrowserWindow, ipcMain, safeStorage, Notification, shell } from "electron";
+import { ConvexClient } from "convex/browser";
+import { api } from "../convex/_generated/api";
+import path from "path";
+import fs from "fs";
+
+const CONVEX_URL = process.env.CONVEX_URL ?? "https://next-pika-124.convex.cloud";
+const SESSION_FILE = path.join(app.getPath("userData"), "session.enc");
+
+// Only these two schemes are ever handed to the OS URL handler.
+const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["https:", "http:"]);
+
+let mainWindow: BrowserWindow | null = null;
+
+/**
+ * Decides whether a renderer-supplied string may be passed to
+ * `shell.openExternal`.
+ */
+function isSafeExternalUrl(url: unknown): url is string {
+ if (typeof url !== "string" || url.length === 0) return false;
+
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return false;
+ }
+
+ return ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol);
+}
+
+/**
+ * Opens a URL in the user's browser if it is an allowed scheme,
+ * logging rejections rather than throwing.
+ */
+function openExternalIfSafe(url: unknown): void {
+ if (!isSafeExternalUrl(url)) {
+ console.warn("Refusing to open external URL with a disallowed scheme:", url);
+ return;
+ }
+ void shell.openExternal(url);
+}
+
+/**
+ * Validates whether a navigation target belongs strictly to the application.
+ */
+function isInternalUrl(url: string): boolean {
+ const rendererUrl = process.env.ELECTRON_RENDERER_URL;
+
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return false;
+ }
+
+ if (rendererUrl) {
+ try {
+ return parsed.origin === new URL(rendererUrl).origin;
+ } catch {
+ return false;
+ }
+ }
+
+ return parsed.protocol === "file:";
+}
+
+// ── Session token storage (encrypted at rest) ──
+
+function readStoredToken(): string | null {
+ try {
+ if (!fs.existsSync(SESSION_FILE)) return null;
+ const encrypted = fs.readFileSync(SESSION_FILE);
+ if (!safeStorage.isEncryptionAvailable()) return null;
+ return safeStorage.decryptString(encrypted);
+ } catch (error) {
+ console.error("Failed to read the stored session token:", error);
+ return null;
+ }
+}
+
+function storeToken(token: string): void {
+ if (!safeStorage.isEncryptionAvailable()) {
+ throw new Error("Encryption not available");
+ }
+ const encrypted = safeStorage.encryptString(token);
+ fs.writeFileSync(SESSION_FILE, encrypted);
+}
+
+function clearToken(): void {
+ try {
+ fs.unlinkSync(SESSION_FILE);
+ } catch {
+ // file may not exist
+ }
+}
+
+// ── IPC handlers ──
+
+ipcMain.handle("store-session", (_event, token: unknown) => {
+ if (typeof token !== "string" || token.length === 0) {
+ throw new Error("store-session expects a non-empty string token");
+ }
+ storeToken(token);
+ startNotificationSubscription(token);
+});
+
+ipcMain.handle("get-session", () => {
+ return readStoredToken();
+});
+
+ipcMain.handle("clear-session", () => {
+ clearToken();
+ stopNotificationSubscription();
+});
+
+ipcMain.handle("open-external", (_event, url: unknown) => {
+ openExternalIfSafe(url);
+});
+
+// ── Native notification bridge ──
+
+let notifClient: ConvexClient | null = null;
+let lastSeenTimestamp = Date.now();
+
+function startNotificationSubscription(sessionToken: string) {
+ stopNotificationSubscription();
+
+ lastSeenTimestamp = Date.now();
+ let isFirstUpdate = true;
+
+ notifClient = new ConvexClient(CONVEX_URL);
+ notifClient.onUpdate(
+ api.queries.getUnreadNotifications,
+ { sessionToken },
+ (notifications: Array<{ title: string; body: string; timestamp: number }>) => {
+ if (!notifications) return;
+
+ if (isFirstUpdate) {
+ isFirstUpdate = false;
+ if (notifications.length > 0) {
+ lastSeenTimestamp = Math.max(...notifications.map((n) => n.timestamp));
+ }
+ return;
+ }
+
+ for (const n of notifications) {
+ if (n.timestamp > lastSeenTimestamp) {
+ const notif = new Notification({ title: n.title, body: n.body ?? "" });
+ notif.show();
+ }
+ }
+ if (notifications.length > 0) {
+ const maxTs = Math.max(...notifications.map((n) => n.timestamp));
+ if (maxTs > lastSeenTimestamp) {
+ lastSeenTimestamp = maxTs;
+ }
+ }
+ },
+ );
+}
+
+function stopNotificationSubscription() {
+ if (notifClient) {
+ notifClient.close();
+ notifClient = null;
+ }
+}
+
+// ── Window creation ──
+
+function createWindow() {
+ mainWindow = new BrowserWindow({
+ width: 1200,
+ height: 800,
+ backgroundColor: "#09090b",
+ titleBarStyle: "hiddenInset",
+ icon: path.join(__dirname, "../../resources/glass-icon.png"),
+ webPreferences: {
+ preload: path.join(__dirname, "../preload/preload.cjs"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ },
+ });
+
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ openExternalIfSafe(url);
+ return { action: "deny" };
+ });
+
+ mainWindow.webContents.on("will-navigate", (event, url) => {
+ if (isInternalUrl(url)) return;
+ event.preventDefault();
+ openExternalIfSafe(url);
+ });
+
+ mainWindow.webContents.on("will-redirect", (event, url) => {
+ if (isInternalUrl(url)) return;
+ event.preventDefault();
+ openExternalIfSafe(url);
+ });
+
+ mainWindow.webContents.session.setPermissionRequestHandler((_wc, permission, callback) => {
+ console.warn(`Denied renderer permission request: ${permission}`);
+ callback(false);
+ });
+
+ if (process.env.ELECTRON_RENDERER_URL) {
+ mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
+ } else {
+ mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
+ }
+
+ mainWindow.on("closed", () => {
+ mainWindow = null;
+ });
+}
+
+// Global protection across all WebContents instances
+app.on("web-contents-created", (_event, contents) => {
+ contents.on("will-attach-webview", (event) => {
+ console.warn("Refused to attach a tag");
+ event.preventDefault();
+ });
+
+ // Guard window creation on all web contents to prevent unhandled popups
+ contents.setWindowOpenHandler(({ url }) => {
+ openExternalIfSafe(url);
+ return { action: "deny" };
+ });
+});
+
+app.whenReady().then(() => {
+ if (process.platform === "darwin") {
+ app.dock.setIcon(path.join(__dirname, "../../resources/glass-icon.png"));
+ }
+ createWindow();
+
+ const token = readStoredToken();
+ if (token) {
+ startNotificationSubscription(token);
+ }
+
+ app.on("activate", () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow();
+ }
+ });
+});
+
+app.on("window-all-closed", () => {
+ if (process.platform !== "darwin") {
+ app.quit();
+ }
+});
+
+app.on("before-quit", () => {
+ stopNotificationSubscription();
+});
\ No newline at end of file
diff --git a/renderer/index.html b/renderer/index.html
index 4d5949c..797ea06 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -1,18 +1,54 @@
-
-
-
-
-
- Vigil
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Vigil
+
+
+
+
+
+
+
+
+
From cf1c7af66f28c3b766a18636d5ce4434c999c273 Mon Sep 17 00:00:00 2001
From: mozluk <160273088+mozluk@users.noreply.github.com>
Date: Wed, 9 Sep 2026 23:57:43 +0300
Subject: [PATCH 2/2] Update main.ts
---
electron/main.ts | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/electron/main.ts b/electron/main.ts
index 5faef8b..4c0ca35 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -10,6 +10,9 @@ const SESSION_FILE = path.join(app.getPath("userData"), "session.enc");
// Only these two schemes are ever handed to the OS URL handler.
const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["https:", "http:"]);
+// Minimal set of web permissions required for user onboarding clipboard flows
+const ALLOWED_PERMISSIONS = new Set(["clipboard-read", "clipboard-sanitized-write"]);
+
let mainWindow: BrowserWindow | null = null;
/**
@@ -202,6 +205,10 @@ function createWindow() {
});
mainWindow.webContents.session.setPermissionRequestHandler((_wc, permission, callback) => {
+ if (ALLOWED_PERMISSIONS.has(permission)) {
+ callback(true);
+ return;
+ }
console.warn(`Denied renderer permission request: ${permission}`);
callback(false);
});
@@ -257,4 +264,4 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => {
stopNotificationSubscription();
-});
\ No newline at end of file
+});