mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
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:
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user