diff --git a/src/config.ts b/src/config.ts index 63a4ac68..18d3d853 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,6 +40,7 @@ export class Config { public readonly browserstackLocalOptions: Record, public readonly USE_OWN_LOCAL_BINARY_PROCESS: boolean, public readonly REMOTE_MCP: boolean, + public readonly UPLOAD_BASE_DIR: string | undefined, ) {} } @@ -48,6 +49,9 @@ const config = new Config( browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", + process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0 + ? process.env.MCP_UPLOAD_BASE_DIR + : undefined, ); export default config; diff --git a/src/lib/upload-validator.ts b/src/lib/upload-validator.ts new file mode 100644 index 00000000..d516d129 --- /dev/null +++ b/src/lib/upload-validator.ts @@ -0,0 +1,124 @@ +import fs from "fs"; +import path from "path"; + +export interface UploadValidationOptions { + allowedExtensions: readonly string[]; + maxSizeBytes: number; + allowedBaseDir?: string; +} + +/** + * Canonicalizes and validates a user-supplied upload path. Returns the resolved + * absolute path that callers should stream from. Throws on any rule violation. + * + * Rules enforced: + * - Path resolves (via realpath) to an existing regular file + * - File size is within `maxSizeBytes` + * - File extension is in `allowedExtensions` (case-insensitive) + * - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh, + * ~/.aws, .env, etc. even after symlink resolution + * - If `allowedBaseDir` is set, the canonical path must live inside it + */ +export function validateUploadPath( + filePath: string, + options: UploadValidationOptions, +): string { + if (typeof filePath !== "string" || filePath.trim().length === 0) { + throw new Error("Upload rejected: file path is empty."); + } + + let canonical: string; + try { + canonical = fs.realpathSync(path.resolve(filePath)); + } catch { + throw new Error(`File not found at path: ${filePath}`); + } + + let stats: fs.Stats; + try { + stats = fs.statSync(canonical); + } catch { + throw new Error(`File not found at path: ${filePath}`); + } + + if (!stats.isFile()) { + throw new Error( + `Upload rejected: path does not point to a regular file: ${filePath}`, + ); + } + + if (stats.size > options.maxSizeBytes) { + const maxMb = Math.round(options.maxSizeBytes / (1024 * 1024)); + throw new Error( + `Upload rejected: file exceeds maximum allowed size of ${maxMb} MB.`, + ); + } + + const segments = canonical.split(path.sep).filter((s) => s.length > 0); + for (const seg of segments) { + if (seg.startsWith(".") && seg !== "." && seg !== "..") { + throw new Error( + `Upload rejected: path traverses a hidden directory or file ("${seg}"). Move the file to a non-hidden location or set MCP_UPLOAD_BASE_DIR.`, + ); + } + } + + const ext = path.extname(canonical).toLowerCase(); + const allowed = options.allowedExtensions.map((e) => e.toLowerCase()); + if (!allowed.includes(ext)) { + throw new Error( + `Upload rejected: file extension "${ext || "(none)"}" is not in the allowed list (${allowed.join(", ")}).`, + ); + } + + if (options.allowedBaseDir) { + let baseCanonical: string; + try { + baseCanonical = fs.realpathSync(path.resolve(options.allowedBaseDir)); + } catch { + throw new Error( + `Upload rejected: configured MCP_UPLOAD_BASE_DIR does not exist (${options.allowedBaseDir}).`, + ); + } + const baseWithSep = baseCanonical.endsWith(path.sep) + ? baseCanonical + : baseCanonical + path.sep; + if (canonical !== baseCanonical && !canonical.startsWith(baseWithSep)) { + throw new Error( + `Upload rejected: file must be located inside ${baseCanonical}.`, + ); + } + } + + return canonical; +} + +export const APP_BINARY_EXTENSIONS = [ + ".apk", + ".aab", + ".ipa", + ".app", + ".zip", +] as const; + +export const TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS = [ + ".pdf", + ".txt", + ".md", + ".doc", + ".docx", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".csv", + ".xls", + ".xlsx", + ".json", + ".html", + ".zip", +] as const; + +export const ONE_MB = 1024 * 1024; +export const MAX_APP_UPLOAD_BYTES = 4 * 1024 * ONE_MB; // 4 GB — matches BrowserStack app upload limit +export const MAX_ATTACHMENT_UPLOAD_BYTES = 100 * ONE_MB; // 100 MB diff --git a/src/tools/appautomate-utils/native-execution/appautomate.ts b/src/tools/appautomate-utils/native-execution/appautomate.ts index 00a2f8b6..f53255c4 100644 --- a/src/tools/appautomate-utils/native-execution/appautomate.ts +++ b/src/tools/appautomate-utils/native-execution/appautomate.ts @@ -3,6 +3,13 @@ import FormData from "form-data"; import { apiClient } from "../../../lib/apiClient.js"; import { customFuzzySearch } from "../../../lib/fuzzy.js"; import { BrowserStackConfig } from "../../../lib/types.js"; +import { + validateUploadPath, + UploadValidationOptions, + APP_BINARY_EXTENSIONS, + MAX_APP_UPLOAD_BYTES, +} from "../../../lib/upload-validator.js"; +import appConfig from "../../../config.js"; interface Device { device: string; @@ -138,14 +145,14 @@ export async function uploadApp( username: string, password: string, ): Promise { - const filePath = appPath; - - if (!fs.existsSync(filePath)) { - throw new Error(`File not found at path: ${filePath}`); - } + const safePath = validateUploadPath(appPath, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + allowedBaseDir: appConfig.UPLOAD_BASE_DIR, + }); const formData = new FormData(); - formData.append("file", fs.createReadStream(filePath)); + formData.append("file", fs.createReadStream(safePath)); const response = await apiClient.post({ url: "https://api-cloud.browserstack.com/app-automate/upload", @@ -170,13 +177,16 @@ async function uploadFileToBrowserStack( endpoint: string, responseKey: string, config: BrowserStackConfig, + validation: Pick, ): Promise { - if (!fs.existsSync(filePath)) { - throw new Error(`File not found at path: ${filePath}`); - } + const safePath = validateUploadPath(filePath, { + allowedExtensions: validation.allowedExtensions, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + allowedBaseDir: appConfig.UPLOAD_BASE_DIR, + }); const formData = new FormData(); - formData.append("file", fs.createReadStream(filePath)); + formData.append("file", fs.createReadStream(safePath)); const authHeader = "Basic " + @@ -210,6 +220,7 @@ export async function uploadEspressoApp( "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", "app_url", config, + { allowedExtensions: [".apk", ".aab"] }, ); } @@ -223,6 +234,7 @@ export async function uploadEspressoTestSuite( "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", "test_suite_url", config, + { allowedExtensions: [".apk"] }, ); } @@ -236,6 +248,7 @@ export async function uploadXcuiApp( "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app", "app_url", config, + { allowedExtensions: [".ipa"] }, ); } @@ -249,6 +262,7 @@ export async function uploadXcuiTestSuite( "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite", "test_suite_url", config, + { allowedExtensions: [".zip"] }, ); } diff --git a/src/tools/applive-utils/upload-app.ts b/src/tools/applive-utils/upload-app.ts index e370864c..e59cf701 100644 --- a/src/tools/applive-utils/upload-app.ts +++ b/src/tools/applive-utils/upload-app.ts @@ -1,6 +1,12 @@ import { apiClient } from "../../lib/apiClient.js"; import FormData from "form-data"; import fs from "fs"; +import { + validateUploadPath, + APP_BINARY_EXTENSIONS, + MAX_APP_UPLOAD_BYTES, +} from "../../lib/upload-validator.js"; +import appConfig from "../../config.js"; interface UploadResponse { app_url: string; @@ -11,12 +17,14 @@ export async function uploadApp( username: string, password: string, ): Promise { - if (!fs.existsSync(filePath)) { - throw new Error(`File not found at path: ${filePath}`); - } + const safePath = validateUploadPath(filePath, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + allowedBaseDir: appConfig.UPLOAD_BASE_DIR, + }); const formData = new FormData(); - formData.append("file", fs.createReadStream(filePath)); + formData.append("file", fs.createReadStream(safePath)); try { const response = await apiClient.post({ diff --git a/src/tools/testmanagement-utils/upload-file.ts b/src/tools/testmanagement-utils/upload-file.ts index 15ae7f00..a9d662a9 100644 --- a/src/tools/testmanagement-utils/upload-file.ts +++ b/src/tools/testmanagement-utils/upload-file.ts @@ -10,6 +10,12 @@ import { signedUrlMap } from "../../lib/inmemory-store.js"; import { projectIdentifierToId } from "./TCG-utils/api.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { getTMBaseURL } from "../../lib/tm-base-url.js"; +import { + validateUploadPath, + TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, + MAX_ATTACHMENT_UPLOAD_BYTES, +} from "../../lib/upload-validator.js"; +import appConfig from "../../config.js"; /** * Schema for the upload file tool @@ -35,18 +41,14 @@ export async function uploadFile( const { project_identifier, file_path } = args; try { - // Validate file exists - if (!fs.existsSync(file_path)) { - return { - content: [ - { - type: "text", - text: `File ${file_path} does not exist.`, - }, - ], - isError: true, - }; - } + // Canonicalize path and enforce upload safety rules (extension, size, + // hidden-directory traversal, optional base-dir containment). + const safePath = validateUploadPath(file_path, { + allowedExtensions: TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, + maxSizeBytes: MAX_ATTACHMENT_UPLOAD_BYTES, + allowedBaseDir: appConfig.UPLOAD_BASE_DIR, + }); + // Get the project ID const projectIdResponse = await projectIdentifierToId( project_identifier, @@ -54,7 +56,7 @@ export async function uploadFile( ); const formData = new FormData(); - formData.append("attachments[]", fs.createReadStream(file_path)); + formData.append("attachments[]", fs.createReadStream(safePath)); const tmBaseUrl = await getTMBaseURL(config); const uploadUrl = `${tmBaseUrl}/api/v1/projects/${projectIdResponse}/generic/attachments/ai_uploads`; diff --git a/tests/tools/upload-validator.test.ts b/tests/tools/upload-validator.test.ts new file mode 100644 index 00000000..51eed475 --- /dev/null +++ b/tests/tools/upload-validator.test.ts @@ -0,0 +1,191 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + validateUploadPath, + APP_BINARY_EXTENSIONS, + TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, + MAX_APP_UPLOAD_BYTES, + MAX_ATTACHMENT_UPLOAD_BYTES, +} from "../../src/lib/upload-validator"; + +describe("validateUploadPath", () => { + let workDir: string; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "upload-validator-")); + }); + + afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + const write = (name: string, contents = "hello") => { + const p = path.join(workDir, name); + fs.writeFileSync(p, contents); + return p; + }; + + it("accepts a regular file with an allowed extension", () => { + const file = write("app.apk"); + const resolved = validateUploadPath(file, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + }); + expect(resolved).toBe(fs.realpathSync(file)); + }); + + it("rejects an empty path", () => { + expect(() => + validateUploadPath(" ", { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + }), + ).toThrow(/file path is empty/); + }); + + it("rejects a missing file", () => { + expect(() => + validateUploadPath(path.join(workDir, "does-not-exist.apk"), { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + }), + ).toThrow(/File not found/); + }); + + it("rejects a directory", () => { + const dir = fs.mkdtempSync(path.join(workDir, "subdir-")); + expect(() => + validateUploadPath(dir, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + }), + ).toThrow(/regular file/); + }); + + it("rejects a disallowed extension", () => { + const file = write("secrets.pem"); + expect(() => + validateUploadPath(file, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + }), + ).toThrow(/extension ".pem" is not in the allowed list/); + }); + + it("rejects a file with no extension (e.g. /etc/passwd)", () => { + const file = write("passwd"); + expect(() => + validateUploadPath(file, { + allowedExtensions: TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, + maxSizeBytes: MAX_ATTACHMENT_UPLOAD_BYTES, + }), + ).toThrow(/extension "\(none\)" is not in the allowed list/); + }); + + it("rejects a file inside a hidden directory (e.g. ~/.ssh/key.txt)", () => { + const hidden = path.join(workDir, ".ssh"); + fs.mkdirSync(hidden); + const file = path.join(hidden, "id_rsa.txt"); + fs.writeFileSync(file, "secret"); + expect(() => + validateUploadPath(file, { + allowedExtensions: TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, + maxSizeBytes: MAX_ATTACHMENT_UPLOAD_BYTES, + }), + ).toThrow(/hidden directory or file \(".ssh"\)/); + }); + + it("rejects a hidden file (e.g. .env)", () => { + const file = path.join(workDir, ".env.txt"); + fs.writeFileSync(file, "secret"); + expect(() => + validateUploadPath(file, { + allowedExtensions: TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, + maxSizeBytes: MAX_ATTACHMENT_UPLOAD_BYTES, + }), + ).toThrow(/hidden directory or file/); + }); + + it("rejects a symlink that points into a hidden directory", () => { + const hidden = path.join(workDir, ".aws"); + fs.mkdirSync(hidden); + const target = path.join(hidden, "credentials.json"); + fs.writeFileSync(target, "{}"); + const link = path.join(workDir, "harmless.json"); + fs.symlinkSync(target, link); + expect(() => + validateUploadPath(link, { + allowedExtensions: TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, + maxSizeBytes: MAX_ATTACHMENT_UPLOAD_BYTES, + }), + ).toThrow(/hidden directory or file \(".aws"\)/); + }); + + it("rejects a file that exceeds the size limit", () => { + const file = write("app.apk", "x".repeat(10)); + expect(() => + validateUploadPath(file, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: 5, + }), + ).toThrow(/exceeds maximum allowed size/); + }); + + it("enforces allowedBaseDir containment when configured", () => { + const outside = write("app.apk"); + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "allowed-base-")); + try { + expect(() => + validateUploadPath(outside, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + allowedBaseDir: baseDir, + }), + ).toThrow(/must be located inside/); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it("allows files inside allowedBaseDir", () => { + const inside = path.join(workDir, "nested", "app.apk"); + fs.mkdirSync(path.dirname(inside)); + fs.writeFileSync(inside, "data"); + const resolved = validateUploadPath(inside, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + allowedBaseDir: workDir, + }); + expect(resolved).toBe(fs.realpathSync(inside)); + }); + + it("rejects a path that escapes allowedBaseDir via symlink", () => { + const outsideTarget = path.join(workDir, "real.apk"); + fs.writeFileSync(outsideTarget, "data"); + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "allowed-base-")); + try { + const link = path.join(baseDir, "inside.apk"); + fs.symlinkSync(outsideTarget, link); + expect(() => + validateUploadPath(link, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + allowedBaseDir: baseDir, + }), + ).toThrow(/must be located inside/); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it("is case-insensitive on extensions", () => { + const file = write("APP.APK"); + const resolved = validateUploadPath(file, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + }); + expect(resolved).toBe(fs.realpathSync(file)); + }); +});