Skip to content

Commit d72c8d7

Browse files
connor4312Connor Peet
andauthored
Cherry-pick MSRC 102702+103197 (microsoft#1926)
* Merge pull request #5 from devdiv-microsoft/release/msrc/103197 edits: avoid windows/ntfs path workarounds in edit guards * edits: check files_added in patch for edit guards (#4) Co-authored-by: Connor Peet <connor@peet.io> --------- Co-authored-by: Connor Peet <connor@xbox.com>
1 parent 1c494de commit d72c8d7

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
@@ -43,7 +43,7 @@ import { ToolName } from '../common/toolNames';
4343
import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry';
4444
import { IToolsService } from '../common/toolsService';
4545
import { PATCH_PREFIX, PATCH_SUFFIX } from './applyPatch/parseApplyPatch';
46-
import { ActionType, Commit, DiffError, FileChange, identify_files_needed, InvalidContextError, InvalidPatchFormatError, processPatch } from './applyPatch/parser';
46+
import { ActionType, Commit, DiffError, FileChange, identify_files_added, identify_files_needed, InvalidContextError, InvalidPatchFormatError, processPatch } from './applyPatch/parser';
4747
import { EditFileResult, IEditedFile } from './editFileToolResult';
4848
import { canExistingFileBeEdited, createEditConfirmation, formatDiffAsUnified, logEditToolResult, openDocumentAndSnapshot } from './editFileToolUtils';
4949
import { sendEditNotebookTelemetry } from './editNotebookTool';
@@ -592,7 +592,7 @@ export class ApplyPatchTool implements ICopilotTool<IApplyPatchToolParams> {
592592
}
593593

594594
async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions<IApplyPatchToolParams>, token: vscode.CancellationToken): Promise<vscode.PreparedToolInvocation> {
595-
const uris = identify_files_needed(options.input.input).map(f => URI.file(f));
595+
const uris = [...identify_files_needed(options.input.input), ...identify_files_added(options.input.input)].map(f => URI.file(f));
596596

597597
return this.instantiationService.invokeFunction(
598598
createEditConfirmation,

src/extension/tools/node/editFileToolUtils.tsx

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

632+
/**
633+
* Validates that a path doesn't contain suspicious characters that could be used
634+
* to bypass security checks on Windows (e.g., NTFS Alternate Data Streams, invalid chars).
635+
* Throws an error if the path is suspicious.
636+
*/
637+
export function assertPathIsSafe(fsPath: string, _isWindows = isWindows): void {
638+
if (fsPath.includes('\0')) {
639+
throw new Error(`Path contains null bytes: ${fsPath}`);
640+
}
641+
642+
if (!_isWindows) {
643+
return;
644+
}
645+
646+
// Check for NTFS Alternate Data Streams (ADS)
647+
const colonIndex = fsPath.indexOf(':', 2);
648+
if (colonIndex !== -1) {
649+
throw new Error(`Path contains invalid characters (alternate data stream): ${fsPath}`);
650+
}
651+
652+
// Check for invalid Windows filename characters
653+
const invalidChars = /[<>"|?*]/;
654+
const pathAfterDrive = fsPath.length > 2 ? fsPath.substring(2) : fsPath;
655+
if (invalidChars.test(pathAfterDrive)) {
656+
throw new Error(`Path contains invalid characters: ${fsPath}`);
657+
}
658+
659+
// Check for named pipes or device paths
660+
if (fsPath.startsWith('\\\\.') || fsPath.startsWith('\\\\?')) {
661+
throw new Error(`Path is a reserved device path: ${fsPath}`);
662+
}
663+
664+
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
665+
666+
// Check for trailing dots and spaces on path components (Windows quirk)
667+
const parts = fsPath.split('\\');
668+
for (const part of parts) {
669+
if (part.length === 0) {
670+
continue;
671+
}
672+
673+
// Reserved device names. Would error on edit, but fail explicitly
674+
if (reserved.test(part)) {
675+
throw new Error(`Reserved device name in path: ${fsPath}`);
676+
}
677+
678+
// Check for trailing dots or spaces
679+
if (part.endsWith('.') || part.endsWith(' ')) {
680+
throw new Error(`Path contains invalid trailing characters: ${fsPath}`);
681+
}
682+
683+
// Check for 8.3 short filename pattern
684+
const tildeIndex = part.indexOf('~');
685+
if (tildeIndex !== -1) {
686+
const afterTilde = part.substring(tildeIndex + 1);
687+
if (afterTilde.length > 0 && /^\d/.test(afterTilde)) {
688+
throw new Error(`Path appears to use short filename format (8.3 names): ${fsPath}. Please use the full path.`);
689+
}
690+
}
691+
}
692+
}
693+
632694
const enum ConfirmationCheckResult {
633695
NoConfirmation,
634696
NoPermissions,
@@ -674,6 +736,8 @@ function makeUriConfirmationChecker(configuration: IConfigurationService, worksp
674736
let ok = true;
675737
let fsPath = uri.fsPath;
676738

739+
assertPathIsSafe(fsPath);
740+
677741
if (platformConfirmationRequiredPaths.some(p => p(fsPath))) {
678742
return ConfirmationCheckResult.SystemFile;
679743
}
@@ -697,6 +761,8 @@ function makeUriConfirmationChecker(configuration: IConfigurationService, worksp
697761
if (uri.scheme === Schemas.file) {
698762
try {
699763
const linked = await realpath(uri.fsPath);
764+
assertPathIsSafe(linked);
765+
700766
if (linked !== uri.fsPath) {
701767
toCheck.push(URI.file(linked));
702768
}

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)