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');