mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 14:21:20 +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:
@@ -1,6 +1,6 @@
|
||||
# Browser Automation
|
||||
|
||||
Last Updated: 2026-04-19
|
||||
Last Updated: 2026-05-11
|
||||
|
||||
CCS provides browser automation through two separate runtime paths:
|
||||
|
||||
@@ -127,6 +127,8 @@ CCS still supports environment-variable overrides for backward compatibility.
|
||||
| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir |
|
||||
| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory |
|
||||
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override |
|
||||
| `CCS_BROWSER_UPLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for local files that browser upload tools may read |
|
||||
| `CCS_BROWSER_DOWNLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for caller-provided browser download directories |
|
||||
|
||||
If an override is active, Browser status surfaces should report that the current session is being
|
||||
managed externally by environment variables.
|
||||
@@ -144,6 +146,22 @@ Config-backed Browser Attach always passes an explicit DevTools port to the runt
|
||||
effective value is the default `9222`. Metadata-based port discovery is preserved only for the
|
||||
legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set.
|
||||
|
||||
### Browser File Transfer Safety
|
||||
|
||||
Claude Browser Attach file-transfer tools intentionally use a deny-by-default filesystem boundary:
|
||||
|
||||
- Downloads without an explicit `downloadPath` go to a CCS-created temporary session directory.
|
||||
- A caller-provided `downloadPath` must be inside that temporary session directory or inside one of
|
||||
the directories listed in `CCS_BROWSER_DOWNLOAD_ROOTS`.
|
||||
- Local upload and drag-and-drop files must be inside the temporary session download directory or
|
||||
inside one of the directories listed in `CCS_BROWSER_UPLOAD_ROOTS`.
|
||||
- Hidden path segments and common secret locations/files, such as `.ssh`, `.aws`, `.ccs`,
|
||||
`.claude`, `.env`, and private-key filenames, are rejected even inside an allowed root.
|
||||
- Each file-transfer call is limited to 10 files, and each local file must be at most 10 MiB.
|
||||
|
||||
Set upload/download roots only to purpose-built scratch directories. Do not point these variables at
|
||||
your home directory, a source checkout with secrets, or a real cloud/tooling config directory.
|
||||
|
||||
## Managed Runtime Files
|
||||
|
||||
- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,55 +48,59 @@ describe('ccs-browser MCP server - advanced interactions', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const responses = await runMcpRequests(pages, [
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 901,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_drag_files',
|
||||
arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 902,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_drag_files',
|
||||
arguments: {
|
||||
selector: '#frame-dropzone',
|
||||
files: [invoicePath],
|
||||
frameSelector: '#upload-frame',
|
||||
const responses = await runMcpRequests(
|
||||
pages,
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 901,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_drag_files',
|
||||
arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 903,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_drag_files',
|
||||
arguments: {
|
||||
selector: '#shadow-dropzone',
|
||||
files: [receiptPath],
|
||||
pierceShadow: true,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 902,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_drag_files',
|
||||
arguments: {
|
||||
selector: '#frame-dropzone',
|
||||
files: [invoicePath],
|
||||
frameSelector: '#upload-frame',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 904,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_drag_files',
|
||||
arguments: {
|
||||
selector: '#cancel-dropzone',
|
||||
files: [invoicePath],
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 903,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_drag_files',
|
||||
arguments: {
|
||||
selector: '#shadow-dropzone',
|
||||
files: [receiptPath],
|
||||
pierceShadow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 904,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_drag_files',
|
||||
arguments: {
|
||||
selector: '#cancel-dropzone',
|
||||
files: [invoicePath],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 901))).toContain(
|
||||
'status: files-dropped'
|
||||
@@ -184,7 +188,8 @@ describe('ccs-browser MCP server - advanced interactions', () => {
|
||||
arguments: { selector: '#reject-dropzone', files: [okPath] },
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 904))).toContain(
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness';
|
||||
import { mkdirSync, realpathSync } from 'node:fs';
|
||||
import { delimiter } from 'node:path';
|
||||
import {
|
||||
runMcpRequests,
|
||||
getResponseText,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
tmpdir,
|
||||
join,
|
||||
} from './browser-mcp-test-harness';
|
||||
import type { MockPageState } from './browser-mcp-test-harness';
|
||||
|
||||
describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
@@ -60,9 +69,15 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
{ responseTimeoutMs: 12000 }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser');
|
||||
expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv');
|
||||
expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled');
|
||||
expect(getResponseText(responses.find((message) => message.id === 57))).toContain(
|
||||
'scope: browser'
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 58))).toContain(
|
||||
'suggestedFilename: report.csv'
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 60))).toContain(
|
||||
'status: canceled'
|
||||
);
|
||||
expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow');
|
||||
expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1');
|
||||
});
|
||||
@@ -93,6 +108,60 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('restricts caller-provided download paths to configured safe roots', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-download-root-'));
|
||||
const allowedPath = join(tempDir, 'reports');
|
||||
const outsidePath = join(tmpdir(), 'ccs-browser-outside-downloads');
|
||||
const sensitiveRoot = join(tempDir, '.aws');
|
||||
const sensitiveDownloadPath = join(sensitiveRoot, 'reports');
|
||||
|
||||
const responses = await runMcpRequests(
|
||||
[{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports', browser: {} }],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 615,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_download_behavior',
|
||||
arguments: { behavior: 'accept', downloadPath: allowedPath },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 616,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_download_behavior',
|
||||
arguments: { behavior: 'accept', downloadPath: outsidePath },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 619,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_download_behavior',
|
||||
arguments: { behavior: 'accept', downloadPath: sensitiveDownloadPath },
|
||||
},
|
||||
},
|
||||
],
|
||||
{ childEnv: { CCS_BROWSER_DOWNLOAD_ROOTS: `${sensitiveRoot}${delimiter}${tempDir}` } }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 615))).toContain(
|
||||
`downloadPath: ${realpathSync(allowedPath)}`
|
||||
);
|
||||
const rejectedResponse = responses.find((message) => message.id === 616);
|
||||
expect((rejectedResponse?.result as { isError?: boolean }).isError).toBe(true);
|
||||
expect(getResponseText(rejectedResponse)).toContain(
|
||||
'downloadPath must be inside the browser session download directory or a CCS_BROWSER_DOWNLOAD_ROOTS entry'
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 619))).toContain(
|
||||
'downloadPath cannot include hidden or sensitive path segment: .aws'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects browser_cancel_download for completed downloads', async () => {
|
||||
const pages: MockPageState[] = [
|
||||
{
|
||||
@@ -144,7 +213,9 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
{ responseTimeoutMs: 12000 }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed');
|
||||
expect(getResponseText(responses.find((message) => message.id === 611))).toContain(
|
||||
'status: completed'
|
||||
);
|
||||
const response = responses.find((message) => message.id === 612);
|
||||
expect((response?.result as { isError?: boolean }).isError).toBe(true);
|
||||
expect(getResponseText(response)).toContain(
|
||||
@@ -188,7 +259,9 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
{ responseTimeoutMs: 12000 }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed');
|
||||
expect(getResponseText(responses.find((message) => message.id === 62))).toContain(
|
||||
'status: observed'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => {
|
||||
@@ -279,23 +352,30 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1');
|
||||
expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame');
|
||||
expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true');
|
||||
expect(getResponseText(responses.find((message) => message.id === 64))).toContain(
|
||||
'pageIndex: 1'
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 65))).toContain(
|
||||
'frameSelector: #upload-frame'
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 66))).toContain(
|
||||
'pierceShadow: true'
|
||||
);
|
||||
|
||||
expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([
|
||||
invoicePath,
|
||||
receiptPath,
|
||||
]);
|
||||
expect(
|
||||
(pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles
|
||||
).toEqual([realpathSync(invoicePath), realpathSync(receiptPath)]);
|
||||
expect(
|
||||
(pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles
|
||||
).toEqual([invoicePath]);
|
||||
).toEqual([realpathSync(invoicePath)]);
|
||||
expect(
|
||||
(pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles
|
||||
).toEqual([receiptPath]);
|
||||
(pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState)
|
||||
.assignedFiles
|
||||
).toEqual([realpathSync(receiptPath)]);
|
||||
});
|
||||
|
||||
it('uses pageId for browser_set_file_input when provided', async () => {
|
||||
@@ -340,14 +420,85 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] },
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1');
|
||||
expect(getResponseText(responses.find((message) => message.id === 68))).toContain(
|
||||
'pageIndex: 1'
|
||||
);
|
||||
expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([
|
||||
assetPath,
|
||||
realpathSync(assetPath),
|
||||
]);
|
||||
expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined();
|
||||
expect(
|
||||
(pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects file uploads outside configured roots and sensitive files inside configured roots', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-root-'));
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-outside-'));
|
||||
const outsidePath = join(outsideDir, 'secret.txt');
|
||||
const sensitiveDir = join(tempDir, '.ssh');
|
||||
const sensitivePath = join(sensitiveDir, 'id_rsa');
|
||||
const sensitiveRootPath = join(sensitiveDir, 'public.txt');
|
||||
writeFileSync(outsidePath, 'outside');
|
||||
mkdirSync(sensitiveDir, { recursive: true });
|
||||
writeFileSync(sensitivePath, 'private-key');
|
||||
writeFileSync(sensitiveRootPath, 'not-a-key');
|
||||
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Upload Guard',
|
||||
currentUrl: 'https://example.com/',
|
||||
fileInputs: {
|
||||
'#real-file-input': { kind: 'file' },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 617,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { selector: '#real-file-input', files: [outsidePath] },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 618,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { selector: '#real-file-input', files: [sensitivePath] },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 620,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { selector: '#real-file-input', files: [sensitiveRootPath] },
|
||||
},
|
||||
},
|
||||
],
|
||||
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: `${sensitiveDir}${delimiter}${tempDir}` } }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 617))).toContain(
|
||||
'file must be inside the browser session download directory or a CCS_BROWSER_UPLOAD_ROOTS entry'
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 618))).toContain(
|
||||
'file cannot include hidden or sensitive path segment: .ssh'
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 620))).toContain(
|
||||
'file cannot include hidden or sensitive path segment: .ssh'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => {
|
||||
@@ -393,10 +544,16 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] },
|
||||
arguments: {
|
||||
pageIndex: 0,
|
||||
pageId: 'page-1',
|
||||
selector: '#real-file-input',
|
||||
files: [okPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 69))).toContain(
|
||||
@@ -409,5 +566,4 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||
'pageIndex and pageId cannot be used together'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user