security(browser-mcp): restrict model-callable local file uploads/downloads to safe roots and deny sensitive paths (#1220)

* fix(browser): restrict file transfer paths

* fix(browser): normalize safe transfer paths

* fix(browser): reject sensitive transfer roots
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-12 08:57:33 -04:00
committed by GitHub
parent f7d5d08bbf
commit eb4bbfddf3
4 changed files with 402 additions and 76 deletions
+154 -7
View File
@@ -151,6 +151,8 @@ const DEFAULT_DRAG_STEPS = 5;
const MAX_POINTER_ACTIONS = 25;
const SESSION_START_SETTLE_WINDOW_MS = 250;
const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024;
const MAX_LOCAL_TRANSFER_FILE_BYTES = 10 * 1024 * 1024;
const MAX_LOCAL_TRANSFER_FILES = 10;
const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED';
@@ -177,6 +179,37 @@ const recentDownloads = [];
const interceptSessionsByPageId = new Map();
let browserDownloadSession = null;
let sessionDownloadDir = '';
const SENSITIVE_LOCAL_PATH_SEGMENTS = new Set([
'.ssh',
'.gnupg',
'.aws',
'.azure',
'.kube',
'.docker',
'.npmrc',
'.netrc',
'.pypirc',
'.config',
'.claude',
'.ccs',
]);
const SENSITIVE_LOCAL_FILE_NAMES = new Set([
'.env',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'known_hosts',
'authorized_keys',
'credentials',
'credentials.json',
'config.json',
'settings.json',
'history',
'.bash_history',
'.zsh_history',
'.fish_history',
]);
const MAX_RECENT_REQUESTS = 100;
const MAX_RECENT_DOWNLOADS = 100;
const FETCH_FAIL_ERROR_REASON = 'Failed';
@@ -1438,10 +1471,105 @@ function getSessionDownloadPath() {
return sessionDownloadDir;
}
function splitConfiguredPathRoots(value) {
return String(value || '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => path.resolve(entry));
}
function getDownloadSafeRoots() {
return [
getSessionDownloadPath(),
...splitConfiguredPathRoots(process.env.CCS_BROWSER_DOWNLOAD_ROOTS),
];
}
function getUploadSafeRoots() {
return [
getSessionDownloadPath(),
...splitConfiguredPathRoots(process.env.CCS_BROWSER_UPLOAD_ROOTS),
];
}
function getNearestExistingAncestor(candidatePath) {
let currentPath = candidatePath;
while (!fs.existsSync(currentPath)) {
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
return currentPath;
}
currentPath = parentPath;
}
return currentPath;
}
function resolveExistingRoot(rootPath) {
fs.mkdirSync(rootPath, { recursive: true });
return fs.realpathSync(rootPath);
}
function resolvePathWithRealAncestor(candidatePath) {
const resolvedPath = path.resolve(candidatePath);
const ancestorPath = getNearestExistingAncestor(resolvedPath);
const realAncestorPath = fs.realpathSync(ancestorPath);
const relativeSuffix = path.relative(ancestorPath, resolvedPath);
return relativeSuffix ? path.resolve(realAncestorPath, relativeSuffix) : realAncestorPath;
}
function isPathInsideRoot(candidatePath, rootPath) {
const relativePath = path.relative(rootPath, candidatePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
function findContainingRoot(candidatePath, rootPaths) {
return rootPaths.find((rootPath) => isPathInsideRoot(candidatePath, rootPath)) || '';
}
function getLocalPathSegments(candidatePath, rootPath) {
const rootSegments = path.resolve(rootPath).split(path.sep).filter(Boolean);
const relativePath = path.relative(rootPath, candidatePath);
const relativeSegments = relativePath.split(path.sep).filter(Boolean);
return [...rootSegments, ...relativeSegments];
}
function assertNoSensitiveLocalPathSegments(candidatePath, rootPath, label) {
const segments = getLocalPathSegments(candidatePath, rootPath);
for (const segment of segments) {
const normalizedSegment = segment.toLowerCase();
if (normalizedSegment.startsWith('.') || SENSITIVE_LOCAL_PATH_SEGMENTS.has(normalizedSegment)) {
throw new Error(`${label} cannot include hidden or sensitive path segment: ${segment}`);
}
}
const fileName = path.basename(candidatePath).toLowerCase();
if (SENSITIVE_LOCAL_FILE_NAMES.has(fileName)) {
throw new Error(
`${label} cannot reference sensitive file name: ${path.basename(candidatePath)}`
);
}
}
function ensureWritableDirectory(downloadPath) {
fs.mkdirSync(downloadPath, { recursive: true });
fs.accessSync(downloadPath, fs.constants.W_OK);
return downloadPath;
const resolvedPath = path.resolve(downloadPath);
const candidatePath = resolvePathWithRealAncestor(resolvedPath);
const safeRoots = getDownloadSafeRoots().map(resolveExistingRoot);
const containingRoot = findContainingRoot(candidatePath, safeRoots);
if (!containingRoot) {
throw new Error(
'downloadPath must be inside the browser session download directory or a CCS_BROWSER_DOWNLOAD_ROOTS entry'
);
}
assertNoSensitiveLocalPathSegments(candidatePath, containingRoot, 'downloadPath');
fs.mkdirSync(resolvedPath, { recursive: true });
const realDownloadPath = fs.realpathSync(resolvedPath);
if (!isPathInsideRoot(realDownloadPath, containingRoot)) {
throw new Error('downloadPath cannot traverse outside the allowed download root');
}
fs.accessSync(realDownloadPath, fs.constants.W_OK);
return realDownloadPath;
}
function pushRecentDownload(entry) {
@@ -2418,16 +2546,35 @@ function buildFileInputHandleExpression(selector, nth, frameSelector, pierceShad
}
function validateLocalFiles(files) {
if (files.length > MAX_LOCAL_TRANSFER_FILES) {
throw new Error(`files exceeds maximum of ${MAX_LOCAL_TRANSFER_FILES}`);
}
const safeRoots = getUploadSafeRoots().map(resolveExistingRoot);
return files.map((filePath) => {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`file does not exist: ${resolvedPath}`);
}
const stat = fs.statSync(resolvedPath);
if (!stat.isFile()) {
throw new Error(`file is not a regular file: ${resolvedPath}`);
const realFilePath = fs.realpathSync(resolvedPath);
const containingRoot = findContainingRoot(realFilePath, safeRoots);
if (!containingRoot) {
throw new Error(
'file must be inside the browser session download directory or a CCS_BROWSER_UPLOAD_ROOTS entry'
);
}
return resolvedPath;
assertNoSensitiveLocalPathSegments(realFilePath, containingRoot, 'file');
const stat = fs.statSync(realFilePath);
if (!stat.isFile()) {
throw new Error(`file is not a regular file: ${realFilePath}`);
}
if (stat.size > MAX_LOCAL_TRANSFER_FILE_BYTES) {
throw new Error(
`file exceeds maximum size of ${MAX_LOCAL_TRANSFER_FILE_BYTES} bytes: ${realFilePath}`
);
}
return realFilePath;
});
}