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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,37 @@ import {
} from './constants';

function normalizeDirectoryPath(path: string): string {
return path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
const normalizedSeparators = path.trim().replace(/\//g, '\\').replace(/\\+$/, '');
const driveMatch = /^([a-zA-Z]:)(?:\\(.*))?$/.exec(normalizedSeparators);
const hasRoot = normalizedSeparators.startsWith('\\');
const prefix = driveMatch?.[1]?.toLowerCase() ?? (hasRoot ? '\\' : '');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential allowlist bypass for UNC paths: \\server\share is the root of a UNC path, but this normalizer stores the prefix as only \ and then treats both server and share as ordinary segments. That lets .. pop the share name, which does not match Windows path semantics.

For example, with allowedWorkingDirectories: ['\\server\share'], a requested working directory like \\server\other\..\share\secret is normalized here as \\server\share\secret and passes the allowlist, while Windows resolves it under \\server\other\share\secret, outside the allowed share. Could we preserve \\server\share as the UNC prefix/root when resolving .., and add a resolveCommandContext test for this case?

const body = driveMatch ? (driveMatch[2] ?? '') : normalizedSeparators.replace(/^\\+/, '');
const resolvedSegments: string[] = [];

for (const segment of body.split(/\\+/)) {
if (!segment || segment === '.') {
continue;
}

if (segment === '..') {
const lastSegment = resolvedSegments[resolvedSegments.length - 1];
if (lastSegment && lastSegment !== '..') {
resolvedSegments.pop();
} else if (!prefix) {
resolvedSegments.push(segment);
}
continue;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

resolvedSegments.push(segment.toLowerCase());
}

const normalizedBody = resolvedSegments.join('\\');
if (!prefix) {
return normalizedBody;
}

return normalizedBody ? `${prefix}\\${normalizedBody}` : prefix;
}

function isWithinAllowedDirectory(path: string, allowlist: string[]): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,33 @@ describe('resolveCommandContext', () => {
).rejects.toThrow('Working directory is outside the allowed scope');
});

it('rejects working directories that escape the allowlist with parent segments', async () => {
setLocale('en-US');
const config = {
...baseConfig,
allowedWorkingDirectories: ['D:/allowed'],
};

await expect(
resolveCommandContext(
{ command: 'dir', workingDirectory: 'D:/allowed/../other' },
config
)
).rejects.toThrow('Working directory is outside the allowed scope');
});

it('rejects relative working directories that escape with consecutive parent segments', async () => {
setLocale('en-US');
const config = {
...baseConfig,
allowedWorkingDirectories: ['foo'],
};

await expect(
resolveCommandContext({ command: 'dir', workingDirectory: '../../foo' }, config)
).rejects.toThrow('Working directory is outside the allowed scope');
});

it('accepts command inside allowed directories', async () => {
const config = {
...baseConfig,
Expand Down
Loading