Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 };
Expand Down Expand Up @@ -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}"`;
}
}
Expand All @@ -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}"`;
}
}
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
<script src="../node_modules/@xterm/addon-web-links/lib/addon-web-links.js"></script>
<script src="../node_modules/@xterm/addon-search/lib/addon-search.js"></script>
<script src="../node_modules/morphdom/dist/morphdom-umd.js"></script>
<script src="../node_modules/dompurify/dist/purify.min.js"></script>
<script src="codemirror-bundle.js"></script>
<script src="icons.js"></script>
<script src="viewer-toolbar.js"></script>
Expand Down
2 changes: 1 addition & 1 deletion public/viewer-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}

Expand Down
2 changes: 1 addition & 1 deletion public/viewer-toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down