From 364689e18e556d1aecd10566bb51e23ab4291dd6 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Mon, 8 Jun 2026 21:06:09 +0200 Subject: [PATCH 1/3] security: validate IPC file paths, sanitize viewer HTML, add CSP Port of upstream doctly/switchboard#27 (author @navedr), adapted to the fork's diverged main.js / viewer panels. - IPC file handlers (read-file-for-panel, save-file-for-panel, watch-file) reject well-known credential paths via isSensitivePath denylist. - read-memory / save-memory confined to ~/.claude or active project paths via isAllowedMemoryPath allowlist (replaces the weak any-existing-.md check). - Viewer markdown rendering sanitized with DOMPurify before innerHTML (viewer-panel.js, viewer-toolbar.js); dompurify vendored via index.html. - Content-Security-Policy header set on the default session. See PR body for two runtime caveats requiring verification before merge. --- ipc-path-validator.js | 77 +++++++++++++++++ main.js | 36 +++++++- package-lock.json | 17 ++++ package.json | 1 + public/index.html | 1 + public/viewer-panel.js | 2 +- public/viewer-toolbar.js | 2 +- test/ipc-path-validator.test.js | 146 ++++++++++++++++++++++++++++++++ 8 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 ipc-path-validator.js create mode 100644 test/ipc-path-validator.test.js diff --git a/ipc-path-validator.js b/ipc-path-validator.js new file mode 100644 index 00000000..5a6a6ce6 --- /dev/null +++ b/ipc-path-validator.js @@ -0,0 +1,77 @@ +// ipc-path-validator.js — path validation helpers for IPC file-access handlers. +// +// Two layers of defense: +// 1. isSensitivePath — denylist for well-known credential / secret files. +// Used by the file-panel handlers (read-file-for-panel, save-file-for-panel, +// watch-file) which intentionally accept arbitrary project paths (OSC 8 +// hyperlinks from terminal output). Blocking a static denylist is lighter +// than allowlisting because the legitimate surface is unbounded. +// +// 2. isAllowedMemoryPath — strict allowlist for memory/plan handlers +// (read-memory, save-memory) that should only touch ~/.claude/ or active +// project directories. +// +// Both helpers resolve the incoming path (path.resolve) before comparing, so +// ../traversal sequences are normalised before any check fires. + +'use strict'; + +const os = require('os'); +const path = require('path'); + +const CLAUDE_DIR = path.join(os.homedir(), '.claude'); + +// Patterns matching well-known credential / secret file locations. +// A match on the resolved absolute path blocks the operation. +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, +]; + +/** + * Returns true when `filePath` resolves to a sensitive credential location. + * + * @param {string} filePath - Absolute or relative path from the renderer. + * @returns {boolean} + */ +function isSensitivePath(filePath) { + const resolved = path.resolve(filePath); + return SENSITIVE_PATH_PATTERNS.some(pattern => pattern.test(resolved)); +} + +/** + * Returns true when `filePath` is allowed for memory/plan read-write operations. + * + * Allowed roots: + * - ~/.claude/ (and ~/.claude itself) + * - any path in `activeProjectPaths` + * + * @param {string} filePath - Absolute or relative path from the renderer. + * @param {string[]} activeProjectPaths - Array of active project root paths. + * @returns {boolean} + */ +function isAllowedMemoryPath(filePath, activeProjectPaths) { + const resolved = path.resolve(filePath); + + // Must start with CLAUDE_DIR + sep, or equal CLAUDE_DIR exactly. + if (resolved === CLAUDE_DIR || resolved.startsWith(CLAUDE_DIR + path.sep)) { + return true; + } + + // Must start with an active project path + sep (strict prefix, not a substring). + for (const projectPath of activeProjectPaths) { + if (projectPath && resolved.startsWith(projectPath + path.sep)) { + return true; + } + } + + return false; +} + +module.exports = { isSensitivePath, isAllowedMemoryPath }; diff --git a/main.js b/main.js index 0467b9d3..3ae6e38c 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron'); +const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, session, shell } = require('electron'); const { Worker } = require('worker_threads'); const { execFile } = require('child_process'); const path = require('path'); @@ -39,6 +39,7 @@ const cleanPtyEnv = Object.fromEntries( const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles'); const { startScheduler } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); +const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath } = require('./ipc-path-validator'); @@ -91,6 +92,15 @@ const MAX_BUFFER_SIZE = 256 * 1024; const activeSessions = new Map(); let mainWindow = null; +// Wrapper that plumbs the live activeSessions map into isAllowedMemoryPath. +function isAllowedMemoryPath(filePath) { + const projectPaths = []; + for (const [, s] of activeSessions) { + if (s.projectPath) projectPaths.push(s.projectPath); + } + return _isAllowedMemoryPath(filePath, projectPaths); +} + // Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId, teardown }) const subagentWatchers = new Map(); let subagentWatcherSeq = 0; @@ -565,7 +575,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 }; @@ -575,6 +587,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 }; @@ -588,6 +601,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; @@ -947,9 +961,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); @@ -962,6 +975,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 }; @@ -1885,6 +1899,20 @@ if (!gotSingleInstanceLock) { }); app.whenReady().then(() => { + // Content Security Policy — restrict what the renderer can load or execute. + // 'unsafe-inline' for style-src is required because the app sets .style.* + // properties via JavaScript and style.css uses data: URIs in background-image. + 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 4da20715..3a1249a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "better-sqlite3": "^12.0.0", + "dompurify": "^3.4.8", "electron-log": "^5.3.0", "electron-updater": "^6.3.0", "marked": "^17.0.4", @@ -2772,6 +2773,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", @@ -4190,6 +4198,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "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 9e80b8d9..4dddade9 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "better-sqlite3": "^12.0.0", + "dompurify": "^3.4.8", "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 d496866f..4fa23617 100644 --- a/public/index.html +++ b/public/index.html @@ -108,6 +108,7 @@ + diff --git a/public/viewer-panel.js b/public/viewer-panel.js index e3a68c83..e40a8a74 100644 --- a/public/viewer-panel.js +++ b/public/viewer-panel.js @@ -344,7 +344,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 e275f6a5..705cd2bc 100644 --- a/public/viewer-toolbar.js +++ b/public/viewer-toolbar.js @@ -44,7 +44,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'); diff --git a/test/ipc-path-validator.test.js b/test/ipc-path-validator.test.js new file mode 100644 index 00000000..398755c2 --- /dev/null +++ b/test/ipc-path-validator.test.js @@ -0,0 +1,146 @@ +// test/ipc-path-validator.test.js — unit tests for the IPC path validation helper +// +// Tests the pure path-validation logic extracted from main.js. +// No Electron, no fs I/O beyond what the module itself does. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('os'); +const path = require('path'); + +const { isSensitivePath, isAllowedMemoryPath } = require('../ipc-path-validator'); + +const HOME = os.homedir(); +const CLAUDE_DIR = path.join(HOME, '.claude'); + +// ── isSensitivePath ─────────────────────────────────────────────────────────── + +test('isSensitivePath: rejects ~/.ssh/id_rsa', () => { + assert.equal(isSensitivePath(path.join(HOME, '.ssh', 'id_rsa')), true); +}); + +test('isSensitivePath: rejects ~/.ssh/ directory', () => { + assert.equal(isSensitivePath(path.join(HOME, '.ssh', 'config')), true); +}); + +test('isSensitivePath: rejects ~/.gnupg/secring.gpg', () => { + assert.equal(isSensitivePath(path.join(HOME, '.gnupg', 'secring.gpg')), true); +}); + +test('isSensitivePath: rejects ~/.aws/credentials', () => { + assert.equal(isSensitivePath(path.join(HOME, '.aws', 'credentials')), true); +}); + +test('isSensitivePath: allows ~/.aws/config (not credentials)', () => { + assert.equal(isSensitivePath(path.join(HOME, '.aws', 'config')), false); +}); + +test('isSensitivePath: rejects ~/.env file at home root', () => { + assert.equal(isSensitivePath(path.join(HOME, '.env')), true); +}); + +test('isSensitivePath: rejects project .env file', () => { + assert.equal(isSensitivePath('/home/user/project/.env'), true); +}); + +test('isSensitivePath: rejects .env.local', () => { + assert.equal(isSensitivePath('/home/user/project/.env.local'), true); +}); + +test('isSensitivePath: allows .env.example (not .env or .env.local)', () => { + assert.equal(isSensitivePath('/home/user/project/.env.example'), false); +}); + +test('isSensitivePath: rejects ~/.netrc', () => { + assert.equal(isSensitivePath(path.join(HOME, '.netrc')), true); +}); + +test('isSensitivePath: rejects ~/.docker/config.json', () => { + assert.equal(isSensitivePath(path.join(HOME, '.docker', 'config.json')), true); +}); + +test('isSensitivePath: rejects ~/.kube/config', () => { + assert.equal(isSensitivePath(path.join(HOME, '.kube', 'config')), true); +}); + +test('isSensitivePath: allows normal project file', () => { + assert.equal(isSensitivePath('/home/user/project/src/main.js'), false); +}); + +test('isSensitivePath: allows ~/.claude/CLAUDE.md', () => { + assert.equal(isSensitivePath(path.join(CLAUDE_DIR, 'CLAUDE.md')), false); +}); + +test('isSensitivePath: rejects traversal to .ssh via .claude path', () => { + // A path like ~/.claude/../.ssh/id_rsa resolves to ~/.ssh/id_rsa + assert.equal(isSensitivePath(path.join(CLAUDE_DIR, '..', '.ssh', 'id_rsa')), true); +}); + +// ── isAllowedMemoryPath ─────────────────────────────────────────────────────── + +test('isAllowedMemoryPath: allows files under ~/.claude/', () => { + const { isAllowedMemoryPath: allowed } = require('../ipc-path-validator'); + assert.equal(allowed(path.join(CLAUDE_DIR, 'CLAUDE.md'), []), true); +}); + +test('isAllowedMemoryPath: allows files deep under ~/.claude/', () => { + assert.equal(isAllowedMemoryPath(path.join(CLAUDE_DIR, 'memory', 'notes.md'), []), true); +}); + +test('isAllowedMemoryPath: allows ~/.claude itself', () => { + assert.equal(isAllowedMemoryPath(CLAUDE_DIR, []), true); +}); + +test('isAllowedMemoryPath: allows file under active project path', () => { + const projectPath = '/home/user/project'; + assert.equal( + isAllowedMemoryPath(path.join(projectPath, 'CLAUDE.md'), [projectPath]), + true, + ); +}); + +test('isAllowedMemoryPath: allows .work-files under active project', () => { + const projectPath = '/home/user/project'; + assert.equal( + isAllowedMemoryPath(path.join(projectPath, '.work-files', 'notes.md'), [projectPath]), + true, + ); +}); + +test('isAllowedMemoryPath: rejects file outside ~/.claude and outside projects', () => { + assert.equal( + isAllowedMemoryPath(path.join(HOME, 'Documents', 'secret.md'), []), + false, + ); +}); + +test('isAllowedMemoryPath: rejects traversal escape from ~/.claude', () => { + // ~/.claude/../Documents/secret.md → ~/Documents/secret.md + assert.equal( + isAllowedMemoryPath(path.join(CLAUDE_DIR, '..', 'Documents', 'secret.md'), []), + false, + ); +}); + +test('isAllowedMemoryPath: rejects file that is a prefix-match but not a subpath', () => { + // /home/user/.claude-evil/file.md must NOT be allowed just because it starts with the same chars + const evil = path.join(HOME, '.claude-evil', 'file.md'); + assert.equal(isAllowedMemoryPath(evil, []), false); +}); + +test('isAllowedMemoryPath: accepts multiple project paths, first matching wins', () => { + const projectA = '/home/user/projectA'; + const projectB = '/home/user/projectB'; + assert.equal( + isAllowedMemoryPath(path.join(projectB, 'plans', 'plan.md'), [projectA, projectB]), + true, + ); +}); + +test('isAllowedMemoryPath: rejects when project list is empty and path is outside ~/.claude', () => { + assert.equal( + isAllowedMemoryPath('/etc/passwd', []), + false, + ); +}); From 26116b77f31dc959b36a865249538c7c2e7aa081 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Mon, 8 Jun 2026 21:13:58 +0200 Subject: [PATCH 2/3] chore(eslint): declare DOMPurify as a renderer global --- eslint.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.js b/eslint.config.js index 7e02cc7d..ff55833c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -132,6 +132,7 @@ const rendererCrossFileGlobals = { // Third-party renderer libs loaded as