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
23 changes: 23 additions & 0 deletions src/utils/path-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { join } from 'node:path';
import { isPathInsideRoot } from './path-safety.js';

describe('isPathInsideRoot', () => {
const root = '/home/user/mercury';

it('allows the workspace root itself', () => {
expect(isPathInsideRoot(root, root)).toBe(true);
});

it('allows paths inside the workspace', () => {
expect(isPathInsideRoot(join(root, 'src/index.ts'), root)).toBe(true);
});

it('rejects prefix-bypass paths (mercury vs mercury-private)', () => {
expect(isPathInsideRoot('/home/user/mercury-private/secret.env', root)).toBe(false);
});

it('rejects parent traversal', () => {
expect(isPathInsideRoot(join(root, '..', 'outside.txt'), root)).toBe(false);
});
});
13 changes: 13 additions & 0 deletions src/utils/path-safety.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { resolve, relative, sep } from 'node:path';

/**
* Returns true when `candidate` resolves to `root` or a path strictly inside it.
* Uses path.relative instead of string prefix checks to avoid prefix bypass
* (e.g. /workspace vs /workspace-private).
*/
export function isPathInsideRoot(candidate: string, root: string): boolean {
const resolvedRoot = resolve(root);
const resolved = resolve(candidate);
const rel = relative(resolvedRoot, resolved);
return rel === '' || (!rel.startsWith('..') && !rel.startsWith(`..${sep}`));
}
5 changes: 3 additions & 2 deletions src/web/api/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ProgrammingMode, ProgrammingModeState } from '../../core/programmi
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname, basename, relative, resolve } from 'node:path';
import { getMercuryHome, loadConfig, getActiveProviders } from '../../utils/config.js';
import { isPathInsideRoot } from '../../utils/path-safety.js';
import { listThreads, loadThread, deleteThread as removeThread, appendMessage } from './chat-history.js';

let webChannel: WebChannel | null = null;
Expand Down Expand Up @@ -261,7 +262,7 @@ chat.get('/api/workspace/tree', (c) => {
const targetDir = subPath ? resolve(rootDir, subPath) : rootDir;

// Security: ensure target is within workspace
if (!targetDir.startsWith(rootDir)) {
if (!isPathInsideRoot(targetDir, rootDir)) {
return c.json({ error: 'Path outside workspace' }, 403);
}

Expand Down Expand Up @@ -315,7 +316,7 @@ chat.get('/api/workspace/file', (c) => {
const fullPath = resolve(rootDir, filePath);

// Security: ensure within workspace
if (!fullPath.startsWith(rootDir)) {
if (!isPathInsideRoot(fullPath, rootDir)) {
return c.json({ error: 'Path outside workspace' }, 403);
}

Expand Down
10 changes: 7 additions & 3 deletions src/web/api/workspace-ide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { execSync, spawn } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync, statSync } from 'node:fs';
import { join, resolve, relative, extname, basename } from 'node:path';
import { getMercuryHome, loadConfig } from '../../utils/config.js';
import { isPathInsideRoot } from '../../utils/path-safety.js';
import { generateText } from 'ai';
import type { ProviderRegistry } from '../../providers/registry.js';

Expand Down Expand Up @@ -36,7 +37,7 @@ function git(args: string, cwd?: string): string {
function isInsideWorkspace(filePath: string): boolean {
const root = getWorkspaceRoot();
const resolved = resolve(root, filePath);
return resolved.startsWith(root);
return isPathInsideRoot(resolved, root);
}

const ide = new Hono();
Expand Down Expand Up @@ -192,6 +193,9 @@ ide.post('/api/git/unstage', async (c) => {
const body = await c.req.json<{ files: string[] }>();
if (!body.files?.length) return c.json({ error: 'files array required' }, 400);
const cwd = getWorkspaceRoot();
for (const f of body.files) {
if (!isInsideWorkspace(f)) return c.json({ error: `File outside workspace: ${f}` }, 403);
}
git(`reset HEAD ${body.files.map(f => `"${f}"`).join(' ')}`, cwd);
return c.json({ success: true });
} catch (err: any) {
Expand Down Expand Up @@ -250,7 +254,7 @@ ide.put('/api/workspace/file', async (c) => {
if (!body.path) return c.json({ error: 'path is required' }, 400);
const root = getWorkspaceRoot();
const fullPath = resolve(root, body.path);
if (!fullPath.startsWith(root)) {
if (!isPathInsideRoot(fullPath, root)) {
return c.json({ error: 'Path outside workspace' }, 403);
}
writeFileSync(fullPath, body.content, 'utf8');
Expand All @@ -271,7 +275,7 @@ ide.post('/api/terminal/exec', async (c) => {

const root = getWorkspaceRoot();
const cwd = body.cwd ? resolve(root, body.cwd) : root;
if (!cwd.startsWith(root)) {
if (!isPathInsideRoot(cwd, root)) {
return c.json({ error: 'cwd outside workspace' }, 403);
}

Expand Down