Skip to content

Commit 27aa2ca

Browse files
authored
Merge pull request #1927 from microsoft/connor4312/msrc-33-cp
Cherry-pick MSRC 102702+103197
2 parents f1d1fe1 + 4b3d74f commit 27aa2ca

File tree

3 files changed

+118
-3
lines changed

3 files changed

+118
-3
lines changed

src/extension/tools/node/applyPatchTool.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { ToolName } from '../common/toolNames';
4242
import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry';
4343
import { IToolsService } from '../common/toolsService';
4444
import { PATCH_PREFIX, PATCH_SUFFIX } from './applyPatch/parseApplyPatch';
45-
import { ActionType, Commit, DiffError, FileChange, identify_files_needed, InvalidContextError, InvalidPatchFormatError, processPatch } from './applyPatch/parser';
45+
import { ActionType, Commit, DiffError, FileChange, identify_files_added, identify_files_needed, InvalidContextError, InvalidPatchFormatError, processPatch } from './applyPatch/parser';
4646
import { EditFileResult, IEditedFile } from './editFileToolResult';
4747
import { canExistingFileBeEdited, createEditConfirmation, logEditToolResult, openDocumentAndSnapshot } from './editFileToolUtils';
4848
import { sendEditNotebookTelemetry } from './editNotebookTool';
@@ -577,7 +577,7 @@ export class ApplyPatchTool implements ICopilotTool<IApplyPatchToolParams> {
577577
prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions<IApplyPatchToolParams>, token: vscode.CancellationToken): vscode.ProviderResult<vscode.PreparedToolInvocation> {
578578
return this.instantiationService.invokeFunction(
579579
createEditConfirmation,
580-
identify_files_needed(options.input.input).map(f => URI.file(f)),
580+
[...identify_files_needed(options.input.input), ...identify_files_added(options.input.input)].map(f => URI.file(f)),
581581
() => '```\n' + options.input.input + '\n```',
582582
);
583583
}

src/extension/tools/node/editFileToolUtils.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,68 @@ const platformConfirmationRequiredPaths = (
567567
: []
568568
).concat(allPlatformPatterns).map(p => glob.parse(p));
569569

570+
/**
571+
* Validates that a path doesn't contain suspicious characters that could be used
572+
* to bypass security checks on Windows (e.g., NTFS Alternate Data Streams, invalid chars).
573+
* Throws an error if the path is suspicious.
574+
*/
575+
export function assertPathIsSafe(fsPath: string, _isWindows = isWindows): void {
576+
if (fsPath.includes('\0')) {
577+
throw new Error(`Path contains null bytes: ${fsPath}`);
578+
}
579+
580+
if (!_isWindows) {
581+
return;
582+
}
583+
584+
// Check for NTFS Alternate Data Streams (ADS)
585+
const colonIndex = fsPath.indexOf(':', 2);
586+
if (colonIndex !== -1) {
587+
throw new Error(`Path contains invalid characters (alternate data stream): ${fsPath}`);
588+
}
589+
590+
// Check for invalid Windows filename characters
591+
const invalidChars = /[<>"|?*]/;
592+
const pathAfterDrive = fsPath.length > 2 ? fsPath.substring(2) : fsPath;
593+
if (invalidChars.test(pathAfterDrive)) {
594+
throw new Error(`Path contains invalid characters: ${fsPath}`);
595+
}
596+
597+
// Check for named pipes or device paths
598+
if (fsPath.startsWith('\\\\.') || fsPath.startsWith('\\\\?')) {
599+
throw new Error(`Path is a reserved device path: ${fsPath}`);
600+
}
601+
602+
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
603+
604+
// Check for trailing dots and spaces on path components (Windows quirk)
605+
const parts = fsPath.split('\\');
606+
for (const part of parts) {
607+
if (part.length === 0) {
608+
continue;
609+
}
610+
611+
// Reserved device names. Would error on edit, but fail explicitly
612+
if (reserved.test(part)) {
613+
throw new Error(`Reserved device name in path: ${fsPath}`);
614+
}
615+
616+
// Check for trailing dots or spaces
617+
if (part.endsWith('.') || part.endsWith(' ')) {
618+
throw new Error(`Path contains invalid trailing characters: ${fsPath}`);
619+
}
620+
621+
// Check for 8.3 short filename pattern
622+
const tildeIndex = part.indexOf('~');
623+
if (tildeIndex !== -1) {
624+
const afterTilde = part.substring(tildeIndex + 1);
625+
if (afterTilde.length > 0 && /^\d/.test(afterTilde)) {
626+
throw new Error(`Path appears to use short filename format (8.3 names): ${fsPath}. Please use the full path.`);
627+
}
628+
}
629+
}
630+
}
631+
570632
const enum ConfirmationCheckResult {
571633
NoConfirmation,
572634
NoPermissions,
@@ -612,6 +674,8 @@ function makeUriConfirmationChecker(configuration: IConfigurationService, worksp
612674
let ok = true;
613675
let fsPath = uri.fsPath;
614676

677+
assertPathIsSafe(fsPath);
678+
615679
if (platformConfirmationRequiredPaths.some(p => p(fsPath))) {
616680
return ConfirmationCheckResult.SystemFile;
617681
}
@@ -635,6 +699,8 @@ function makeUriConfirmationChecker(configuration: IConfigurationService, worksp
635699
if (uri.scheme === Schemas.file) {
636700
try {
637701
const linked = await realpath(uri.fsPath);
702+
assertPathIsSafe(linked);
703+
638704
if (linked !== uri.fsPath) {
639705
toCheck.push(URI.file(linked));
640706
}

src/extension/tools/node/test/editFileToolUtils.spec.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { createTextDocumentData, IExtHostDocumentData, setDocText } from '../../
1414
import { URI } from '../../../../util/vs/base/common/uri';
1515
import { WorkspaceEdit } from '../../../../vscodeTypes';
1616
import { applyEdits as applyTextEdits } from '../../../prompt/node/intents';
17-
import { applyEdit, ContentFormatError, MultipleMatchesError, NoChangeError, NoMatchError } from '../editFileToolUtils';
17+
import { applyEdit, assertPathIsSafe, ContentFormatError, MultipleMatchesError, NoChangeError, NoMatchError } from '../editFileToolUtils';
1818

1919
describe('replace_string_in_file - applyEdit', () => {
2020
let workspaceEdit: WorkspaceEdit;
@@ -353,3 +353,52 @@ describe('replace_string_in_file - applyEdit', () => {
353353
).toBe(output);
354354
});
355355
});
356+
357+
358+
describe('assertPathIsSafe (Windows scenarios)', () => {
359+
// Force Windows checks by passing true for _isWindows
360+
test('accepts normal path', () => {
361+
expect(() => assertPathIsSafe('C:\\Users\\me\\project\\file.txt', true)).not.toThrow();
362+
});
363+
364+
test('rejects null byte', () => {
365+
expect(() => assertPathIsSafe('C:\\Users\\me\\proje\0ct\\file.txt', true)).toThrow();
366+
});
367+
368+
test('rejects ADS suffix', () => {
369+
expect(() => assertPathIsSafe('C:\\Users\\me\\project\\file.txt:$I30:$INDEX_ALLOCATION', true)).toThrow();
370+
});
371+
372+
test('rejects additional colon in component', () => {
373+
expect(() => assertPathIsSafe('C:\\Users\\me\\file:name.txt', true)).toThrow();
374+
});
375+
376+
test('rejects invalid characters', () => {
377+
expect(() => assertPathIsSafe('C:\\Users\\me\\proj>ect\\file.txt', true)).toThrow();
378+
});
379+
380+
test('rejects device path prefix \\?\\', () => {
381+
// This should be treated as reserved device path
382+
expect(() => assertPathIsSafe('\\\\?\\C:\\Users\\me\\file.txt', true)).toThrow();
383+
});
384+
385+
test('rejects reserved device name component', () => {
386+
expect(() => assertPathIsSafe('C:\\Users\\me\\CON\\file.txt', true)).toThrow();
387+
});
388+
389+
test('rejects trailing dot in component', () => {
390+
expect(() => assertPathIsSafe('C:\\Users\\me\\folder.\\file.txt', true)).toThrow();
391+
});
392+
393+
test('rejects trailing space in component', () => {
394+
expect(() => assertPathIsSafe('C:\\Users\\me\\folder \\file.txt', true)).toThrow();
395+
});
396+
397+
test('rejects 8.3 short filename pattern', () => {
398+
expect(() => assertPathIsSafe('C:\\Users\\me\\VSCODE~1\\settings.json', true)).toThrow();
399+
});
400+
401+
test('allows tilde without digit', () => {
402+
expect(() => assertPathIsSafe('C:\\Users\\me\\my~folder\\file.txt', true)).not.toThrow();
403+
});
404+
});

0 commit comments

Comments
 (0)