Skip to content

Commit 6197c5d

Browse files
fix(core): resolve symbolic link directory escape in memory import processor (google-gemini#28233)
1 parent d78f9ae commit 6197c5d

2 files changed

Lines changed: 66 additions & 4 deletions

File tree

packages/core/src/utils/memoryImportProcessor.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
88
import * as fs from 'node:fs/promises';
9+
import * as fsSync from 'node:fs';
10+
import * as os from 'node:os';
911
import * as path from 'node:path';
1012
import { marked } from 'marked';
1113
import { processImports, validateImportPath } from './memoryImportProcessor.js';
@@ -867,5 +869,46 @@ describe('memoryImportProcessor', () => {
867869
);
868870
expect(validateImportPath(dotPath, basePath, [allowedPath])).toBe(true);
869871
});
872+
873+
it('should reject paths that escape allowed directories via symbolic links', () => {
874+
const tmpDir = fsSync.realpathSync(os.tmpdir());
875+
const testRoot = fsSync.mkdtempSync(path.join(tmpDir, 'gemini-test-'));
876+
const allowedDir = path.join(testRoot, 'allowed');
877+
const outsideDir = path.join(testRoot, 'outside');
878+
const symlinkDir = path.join(allowedDir, 'sym_outside');
879+
880+
try {
881+
// Create real directories and files on disk
882+
fsSync.mkdirSync(allowedDir, { recursive: true });
883+
fsSync.mkdirSync(outsideDir, { recursive: true });
884+
fsSync.writeFileSync(path.join(outsideDir, 'sensitive.md'), 'secret');
885+
886+
// Create a symbolic link pointing outside the allowed directory
887+
try {
888+
fsSync.symlinkSync(outsideDir, symlinkDir, 'dir');
889+
} catch (err: unknown) {
890+
if (
891+
process.platform === 'win32' &&
892+
err &&
893+
typeof err === 'object' &&
894+
'code' in err &&
895+
err.code === 'EPERM'
896+
) {
897+
// Skip the test if the user lacks symlink creation privileges on Windows
898+
return;
899+
}
900+
throw err;
901+
}
902+
903+
const importPath = 'sym_outside/sensitive.md';
904+
905+
expect(validateImportPath(importPath, allowedDir, [allowedDir])).toBe(
906+
false,
907+
);
908+
} finally {
909+
// Cleanup
910+
fsSync.rmSync(testRoot, { recursive: true, force: true });
911+
}
912+
});
870913
});
871914
});

packages/core/src/utils/memoryImportProcessor.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import * as fs from 'node:fs/promises';
88
import * as path from 'node:path';
9-
import { isSubpath } from './paths.js';
9+
import { isSubpath, resolveToRealPath } from './paths.js';
1010
import { debugLogger } from './debugLogger.js';
1111

1212
// Simple console logger for import processing
@@ -397,9 +397,28 @@ export function validateImportPath(
397397
return false;
398398
}
399399

400-
const resolvedPath = path.resolve(basePath, importPath);
400+
let resolvedPath: string;
401+
try {
402+
// Canonicalize the path on the actual physical disk to resolve symlinks
403+
resolvedPath = resolveToRealPath(path.resolve(basePath, importPath));
404+
} catch {
405+
// If path resolution fails (e.g., infinite recursion or invalid path), fail-closed and reject it
406+
return false;
407+
}
408+
409+
const realAllowedDirs = allowedDirectories
410+
.map((dir) => {
411+
const trimmed = dir.trim();
412+
if (!trimmed) return null;
413+
try {
414+
return resolveToRealPath(trimmed);
415+
} catch {
416+
return null;
417+
}
418+
})
419+
.filter((dir): dir is string => dir !== null);
401420

402-
return allowedDirectories.some((allowedDir) =>
403-
isSubpath(allowedDir, resolvedPath),
421+
return realAllowedDirs.some((realAllowedDir) =>
422+
isSubpath(realAllowedDir, resolvedPath),
404423
);
405424
}

0 commit comments

Comments
 (0)