Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class Config {
public readonly browserstackLocalOptions: Record<string, any>,
public readonly USE_OWN_LOCAL_BINARY_PROCESS: boolean,
public readonly REMOTE_MCP: boolean,
public readonly UPLOAD_BASE_DIR: string | undefined,
) {}
}

Expand All @@ -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;
124 changes: 124 additions & 0 deletions src/lib/upload-validator.ts
Original file line number Diff line number Diff line change
@@ -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
34 changes: 24 additions & 10 deletions src/tools/appautomate-utils/native-execution/appautomate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -138,14 +145,14 @@ export async function uploadApp(
username: string,
password: string,
): Promise<string> {
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<UploadResponse>({
url: "https://api-cloud.browserstack.com/app-automate/upload",
Expand All @@ -170,13 +177,16 @@ async function uploadFileToBrowserStack(
endpoint: string,
responseKey: string,
config: BrowserStackConfig,
validation: Pick<UploadValidationOptions, "allowedExtensions">,
): Promise<string> {
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 " +
Expand Down Expand Up @@ -210,6 +220,7 @@ export async function uploadEspressoApp(
"https://api-cloud.browserstack.com/app-automate/espresso/v2/app",
"app_url",
config,
{ allowedExtensions: [".apk", ".aab"] },
);
}

Expand All @@ -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"] },
);
}

Expand All @@ -236,6 +248,7 @@ export async function uploadXcuiApp(
"https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app",
"app_url",
config,
{ allowedExtensions: [".ipa"] },
);
}

Expand All @@ -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"] },
);
}

Expand Down
16 changes: 12 additions & 4 deletions src/tools/applive-utils/upload-app.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,12 +17,14 @@ export async function uploadApp(
username: string,
password: string,
): Promise<UploadResponse> {
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<UploadResponse>({
Expand Down
28 changes: 15 additions & 13 deletions src/tools/testmanagement-utils/upload-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,26 +41,22 @@ 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,
config,
);

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`;
Expand Down
Loading
Loading