diff --git a/main.js b/main.js
index e9c0c9a..f746a2b 100644
--- a/main.js
+++ b/main.js
@@ -1,4 +1,4 @@
-const { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } = require('electron');
+const { app, BrowserWindow, dialog, ipcMain, Menu, screen, session, shell } = require('electron');
const { Worker } = require('worker_threads');
const path = require('path');
const fs = require('fs');
@@ -251,6 +251,48 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
const STATS_CACHE_PATH = path.join(CLAUDE_DIR, 'stats-cache.json');
const MAX_BUFFER_SIZE = 256 * 1024;
+// --- Path validation for IPC file operations ---
+// Sensitive paths that should never be read/written via the file panel IPC.
+// The file panel intentionally opens arbitrary files (OSC8 hyperlinks from
+// terminal output), so we block known-sensitive locations rather than
+// allowlisting. The primary XSS→file-access chain is mitigated by CSP +
+// DOMPurify; this is defense-in-depth.
+const SENSITIVE_PATH_PATTERNS = [
+ /[/\\]\.ssh[/\\]/i,
+ /[/\\]\.gnupg[/\\]/i,
+ /[/\\]\.aws[/\\]credentials/i,
+ /[/\\]\.env$/i,
+ /[/\\]\.env\.local$/i,
+ /[/\\]\.netrc$/i,
+ /[/\\]\.docker[/\\]config\.json$/i,
+ /[/\\]\.kube[/\\]config$/i,
+];
+
+function isSensitivePath(filePath) {
+ const resolved = path.resolve(filePath);
+ return SENSITIVE_PATH_PATTERNS.some(pattern => pattern.test(resolved));
+}
+
+// Stricter allowlist for memory/plan files that should only be under ~/.claude/
+// or active project directories.
+function isAllowedMemoryPath(filePath) {
+ const resolved = path.resolve(filePath);
+ if (resolved.startsWith(CLAUDE_DIR + path.sep) || resolved === CLAUDE_DIR) return true;
+ for (const [, session] of activeSessions) {
+ if (session.projectPath && resolved.startsWith(session.projectPath + path.sep)) return true;
+ }
+ return false;
+}
+
+// --- Input sanitization for shell command arguments ---
+const SHELL_META_CHARS = /[;&|`$(){}!#\n\r]/;
+function validateShellArg(value, fieldName) {
+ if (!value) return;
+ if (SHELL_META_CHARS.test(value)) {
+ throw new Error(`${fieldName} contains invalid characters`);
+ }
+}
+
// Active PTY sessions
const activeSessions = new Map();
let mainWindow = null;
@@ -857,7 +899,9 @@ ipcMain.on('mcp-diff-response', (_event, sessionId, diffId, action, editedConten
ipcMain.handle('read-file-for-panel', async (_event, filePath) => {
try {
- const content = fs.readFileSync(filePath, 'utf8');
+ const resolved = path.resolve(filePath);
+ if (isSensitivePath(resolved)) return { ok: false, error: 'access to sensitive path denied' };
+ const content = fs.readFileSync(resolved, 'utf8');
return { ok: true, content };
} catch (err) {
return { ok: false, error: err.message };
@@ -867,6 +911,7 @@ ipcMain.handle('read-file-for-panel', async (_event, filePath) => {
ipcMain.handle('save-file-for-panel', async (_event, filePath, content) => {
try {
const resolved = path.resolve(filePath);
+ if (isSensitivePath(resolved)) return { ok: false, error: 'access to sensitive path denied' };
if (!fs.existsSync(resolved)) return { ok: false, error: 'File does not exist' };
fs.writeFileSync(resolved, content, 'utf8');
return { ok: true };
@@ -880,6 +925,7 @@ const fileWatchers = new Map(); // filePath → FSWatcher
ipcMain.handle('watch-file', (_event, filePath) => {
const resolved = path.resolve(filePath);
+ if (isSensitivePath(resolved)) return { ok: false, error: 'access to sensitive path denied' };
if (fileWatchers.has(resolved)) return { ok: true };
try {
let debounce = null;
@@ -1265,9 +1311,8 @@ ipcMain.handle('get-memories', () => {
ipcMain.handle('read-memory', (_event, filePath) => {
try {
const resolved = path.resolve(filePath);
- // Allow paths under ~/.claude/ or any .md file that exists
if (!resolved.endsWith('.md')) return '';
- if (!resolved.startsWith(CLAUDE_DIR) && !fs.existsSync(resolved)) return '';
+ if (!isAllowedMemoryPath(resolved)) return '';
return fs.readFileSync(resolved, 'utf8');
} catch (err) {
console.error('Error reading memory file:', err);
@@ -1280,6 +1325,7 @@ ipcMain.handle('save-memory', (_event, filePath, content) => {
try {
const resolved = path.resolve(filePath);
if (!resolved.endsWith('.md')) return { ok: false, error: 'not a .md file' };
+ if (!isAllowedMemoryPath(resolved)) return { ok: false, error: 'path not allowed' };
if (!fs.existsSync(resolved)) return { ok: false, error: 'file does not exist' };
fs.writeFileSync(resolved, content, 'utf8');
return { ok: true };
@@ -1549,11 +1595,13 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se
if (sessionOptions.dangerouslySkipPermissions) {
claudeCmd += ' --dangerously-skip-permissions';
} else if (sessionOptions.permissionMode) {
+ validateShellArg(sessionOptions.permissionMode, 'permissionMode');
claudeCmd += ` --permission-mode "${sessionOptions.permissionMode}"`;
}
if (sessionOptions.worktree) {
claudeCmd += ' --worktree';
if (sessionOptions.worktreeName) {
+ validateShellArg(sessionOptions.worktreeName, 'worktreeName');
claudeCmd += ` "${sessionOptions.worktreeName}"`;
}
}
@@ -1563,6 +1611,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se
if (sessionOptions.addDirs) {
const dirs = sessionOptions.addDirs.split(',').map(d => d.trim()).filter(Boolean);
for (const dir of dirs) {
+ validateShellArg(dir, 'addDirs');
claudeCmd += ` --add-dir "${dir}"`;
}
}
@@ -2044,6 +2093,16 @@ ipcMain.handle('updater-install', () => {
// --- App lifecycle ---
app.whenReady().then(() => {
+ // Set Content Security Policy
+ session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
+ callback({
+ responseHeaders: {
+ ...details.responseHeaders,
+ 'Content-Security-Policy': ["default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; font-src 'self'"],
+ },
+ });
+ });
+
buildMenu();
createWindow();
startProjectsWatcher();
diff --git a/package-lock.json b/package-lock.json
index 2cfb672..6499652 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"better-sqlite3": "^12.0.0",
+ "dompurify": "^3.3.3",
"electron-log": "^5.3.0",
"electron-updater": "^6.3.0",
"marked": "^17.0.4",
@@ -2411,6 +2412,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@@ -3788,6 +3796,15 @@
"node": ">=8"
}
},
+ "node_modules/dompurify": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
+ "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
diff --git a/package.json b/package.json
index 5d63bd3..95f8904 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"better-sqlite3": "^12.0.0",
+ "dompurify": "^3.3.3",
"electron-log": "^5.3.0",
"electron-updater": "^6.3.0",
"marked": "^17.0.4",
diff --git a/public/index.html b/public/index.html
index 736f6ea..077a5bd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -101,6 +101,7 @@
+
diff --git a/public/viewer-panel.js b/public/viewer-panel.js
index 95d55b3..f12a646 100644
--- a/public/viewer-panel.js
+++ b/public/viewer-panel.js
@@ -280,7 +280,7 @@ class ViewerPanel {
}
if (this.previewMode) {
- this.previewEl.innerHTML = window.marked.parse(newContent);
+ this.previewEl.innerHTML = DOMPurify.sanitize(window.marked.parse(newContent));
}
}
diff --git a/public/viewer-toolbar.js b/public/viewer-toolbar.js
index 8c5b011..afe5935 100644
--- a/public/viewer-toolbar.js
+++ b/public/viewer-toolbar.js
@@ -40,7 +40,7 @@ function flashButtonText(btn, text, duration = 1200) {
function toggleMarkdownPreview({ editorEl, previewEl, toggleBtn, editorView, isPreview, storageKey }) {
if (!isPreview) {
const content = editorView ? editorView.state.doc.toString() : '';
- previewEl.innerHTML = window.marked.parse(content);
+ previewEl.innerHTML = DOMPurify.sanitize(window.marked.parse(content));
editorEl.style.display = 'none';
previewEl.style.display = 'block';
toggleBtn.classList.add('active');