diff --git a/docs/browser-automation.md b/docs/browser-automation.md index a8ceb4ad..759a3837 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -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 diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index a4234c78..cbc8d07a 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -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; }); } diff --git a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts index c31c4b60..a06c3198 100644 --- a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts +++ b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts @@ -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( diff --git a/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts index dae29085..ebf42139 100644 --- a/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts +++ b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts @@ -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' ); }); - });