Compare commits

...

4 Commits

Author SHA1 Message Date
Akhilesh Kumar ef303eb84a fix(core): fix workspace path validation with file:// URLs and encoded spaces
Fixed a bug in validateWorkspacePath where splitting by path.delimiter on Unix would break file:// URLs due to colons. Added splitWorkspacePaths helper to handle this. Updated tests to use file:// URLs for encoded paths and added a test case to ensure literal %20 in cwd is not incorrectly resolved.
2026-05-21 17:32:18 +00:00
Akhilesh Kumar 2f4fc17230 fix(core): prevent path traversal / collision in resolveToRealPath
Removed decodeURIComponent from resolveToRealPath for non-file URLs to prevent collisions between paths with literal '%' characters and their decoded counterparts.
2026-05-21 17:24:58 +00:00
Akhilesh Kumar ddec6d5cb6 refactor(core): use global normalizePath in ProjectRegistry
Address code review comments by using normalizePath and resolveToRealPath from paths.ts for consistency and robustness.
2026-05-21 17:08:35 +00:00
Akhilesh Kumar 8916cd742e fix(core): resolve symlinks when normalizing project paths
Project registry identity was based on path.resolve() which does not follow symlinks. This caused different symlink paths to the same physical directory to be treated as different projects, leading to separate session stores.

Fixed by using resolveToRealPath() which correctly resolves symbolic links.

Closes #27278
2026-05-21 16:55:19 +00:00
6 changed files with 45 additions and 24 deletions
@@ -13,6 +13,10 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { ProjectRegistry } from './projectRegistry.js';
import { lock } from 'proper-lockfile';
import {
normalizePath as normalizePathUtil,
resolveToRealPath,
} from '../utils/paths.js';
vi.mock('proper-lockfile');
@@ -23,11 +27,7 @@ describe('ProjectRegistry', () => {
let baseDir2: string;
function normalizePath(p: string): string {
let resolved = path.resolve(p);
if (os.platform() === 'win32') {
resolved = resolved.toLowerCase();
}
return resolved;
return normalizePathUtil(resolveToRealPath(p));
}
beforeEach(() => {
@@ -435,4 +435,20 @@ describe('ProjectRegistry', () => {
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
expect(Object.values(data.projects)).not.toContain('../../etc/passwd');
});
it('resolves symlinks to the same short ID', async () => {
const registry = new ProjectRegistry(registryPath);
await registry.initialize();
const realDir = path.join(tempDir, 'real-project');
fs.mkdirSync(realDir);
const symlinkDir = path.join(tempDir, 'symlink-project');
fs.symlinkSync(realDir, symlinkDir, 'dir');
const id1 = await registry.getShortId(realDir);
const id2 = await registry.getShortId(symlinkDir);
expect(id1).toBe(id2);
});
});
+5 -6
View File
@@ -7,11 +7,14 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { lock } from 'proper-lockfile';
import { z } from 'zod';
import { debugLogger } from '../utils/debugLogger.js';
import { isNodeError } from '../utils/errors.js';
import {
normalizePath as normalizePathUtil,
resolveToRealPath,
} from '../utils/paths.js';
export interface RegistryData {
projects: Record<string, string>;
@@ -93,11 +96,7 @@ export class ProjectRegistry {
}
private normalizePath(projectPath: string): string {
let resolved = path.resolve(projectPath);
if (os.platform() === 'win32') {
resolved = resolved.toLowerCase();
}
return resolved;
return normalizePathUtil(resolveToRealPath(projectPath));
}
private async save(data: RegistryData): Promise<void> {
@@ -468,7 +468,7 @@ describe('ide-connection-utils', () => {
describe('with special characters and encoding', () => {
it('should return true for a URI-encoded path with spaces', () => {
const workspaceDir = path.resolve('/test/my workspace');
const workspacePath = '/test/my%20workspace';
const workspacePath = pathToFileURL(workspaceDir).toString();
const cwd = path.join(workspaceDir, 'sub-dir');
const result = validateWorkspacePath(workspacePath, cwd);
expect(result.isValid).toBe(true);
@@ -476,7 +476,7 @@ describe('ide-connection-utils', () => {
it('should return true for a URI-encoded path with Korean characters', () => {
const workspaceDir = path.resolve('/test/테스트');
const workspacePath = '/test/%ED%85%8C%EC%8A%A4%ED%8A%B8'; // "테스트"
const workspacePath = pathToFileURL(workspaceDir).toString();
const cwd = path.join(workspaceDir, 'sub-dir');
const result = validateWorkspacePath(workspacePath, cwd);
expect(result.isValid).toBe(true);
@@ -494,7 +494,7 @@ describe('ide-connection-utils', () => {
const workspaceDir2 = path.resolve('/test/테스트');
const workspacePath = [
workspaceDir1,
'/test/%ED%85%8C%EC%8A%A4%ED%8A%B8', // "테스트"
pathToFileURL(workspaceDir2).toString(),
].join(path.delimiter);
const cwd = path.join(workspaceDir2, 'sub-dir');
const result = validateWorkspacePath(workspacePath, cwd);
@@ -536,17 +536,18 @@ describe('ide-connection-utils', () => {
expectedValid: true,
},
{
description: 'should return true when workspace has encoded spaces',
workspacePath: path.resolve('test', 'my ws').replace(/ /g, '%20'),
description:
'should return true when workspace has encoded spaces in file:// URL',
workspacePath: pathToFileURL(path.resolve('test', 'my ws')).toString(),
cwd: path.resolve('test', 'my ws'),
expectedValid: true,
},
{
description:
'should return true when cwd needs normalization matching workspace',
'should return false when cwd has literal %20 and workspace has space',
workspacePath: path.resolve('test', 'my ws'),
cwd: path.resolve('test', 'my ws').replace(/ /g, '%20'),
expectedValid: true,
expectedValid: false,
},
])('$description', ({ workspacePath, cwd, expectedValid }) => {
expect(validateWorkspacePath(workspacePath, cwd)).toMatchObject({
@@ -33,6 +33,14 @@ export type ConnectionConfig = {
stdio?: StdioConfig;
};
function splitWorkspacePaths(paths: string): string[] {
if (process.platform === 'win32') {
return paths.split(';');
} else {
return paths.split(/:(?!\/\/)/);
}
}
export function validateWorkspacePath(
ideWorkspacePath: string | undefined,
cwd: string,
@@ -51,8 +59,7 @@ export function validateWorkspacePath(
};
}
const ideWorkspacePaths = ideWorkspacePath
.split(path.delimiter)
const ideWorkspacePaths = splitWorkspacePaths(ideWorkspacePath)
.map((p) => resolveToRealPath(p))
.filter((e) => !!e);
const realCwd = resolveToRealPath(cwd);
+2 -2
View File
@@ -520,9 +520,9 @@ describe('resolveToRealPath', () => {
expected: path.resolve('path', 'to', 'file'),
},
{
description: 'should decode URI components',
description: 'should NOT decode URI components if no file:// protocol',
input: path.resolve('path', 'to', 'some folder').replace(/ /g, '%20'),
expected: path.resolve('path', 'to', 'some folder'),
expected: path.resolve('path', 'to', 'some folder').replace(/ /g, '%20'),
},
{
description: 'should handle both file protocol and encoding',
-2
View File
@@ -418,8 +418,6 @@ export function resolveToRealPath(pathStr: string): string {
if (resolvedPath.startsWith('file://')) {
resolvedPath = fileURLToPath(resolvedPath);
}
resolvedPath = decodeURIComponent(resolvedPath);
} catch {
// Ignore error (e.g. malformed URI), keep path from previous step
}