Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import { PNG } from "./png";
import { isScalingAvailable, Image } from "./image-utils";
import { Mobilecli } from "./mobilecli";
import { MobileDevice } from "./mobile-device";
import { validateOutputPath, validateFileExtension } from "./utils";
import { validateOutputPath, validateInputPath, validateFileExtension } from "./utils";

const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"];
const ALLOWED_RECORDING_EXTENSIONS = [".mp4"];
const ALLOWED_APP_EXTENSIONS = [".apk", ".ipa", ".zip", ".app"];

interface MobilecliDevice {
id: string;
Expand Down Expand Up @@ -368,6 +369,8 @@ export const createMcpServer = (): McpServer => {
},
{ destructiveHint: true },
async ({ device, path }) => {
validateFileExtension(path, ALLOWED_APP_EXTENSIONS, "install_app");
validateInputPath(path);
const robot = getRobotFromDevice(device);
await robot.installApp(path);
return `Installed app from ${path}`;
Expand Down
10 changes: 9 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function resolveWithSymlinks(filePath: string): string {
}
}

export function validateOutputPath(filePath: string): void {
function validatePathAgainstAllowedRoots(filePath: string, label: string): void {
const resolved = resolveWithSymlinks(filePath);
const allowedRoots = getAllowedRoots();
const isWindows = process.platform === "win32";
Expand All @@ -86,3 +86,11 @@ export function validateOutputPath(filePath: string): void {
);
}
}

export function validateOutputPath(filePath: string): void {
validatePathAgainstAllowedRoots(filePath, "output");
}

export function validateInputPath(filePath: string): void {
validatePathAgainstAllowedRoots(filePath, "input");
}
84 changes: 84 additions & 0 deletions test/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert";
import path from "node:path";
import fs from "node:fs";

import { validateFileExtension } from "../src/utils";
import { ActionableError } from "../src/robot";

const ALLOWED_APP_EXTENSIONS = [".apk", ".ipa", ".zip", ".app"];

describe("validateInputPath (new: prevents CWE-22 in install_app)", () => {
let validateInputPath: (filePath: string) => void;

before(() => {
const utils = require("../src/utils");
validateInputPath = utils.validateInputPath;
if (!validateInputPath) {
throw new Error("validateInputPath is not exported from src/utils.ts — the fix has not been applied yet");
}
});

it("should allow paths under cwd", () => {
const filePath = path.join(process.cwd(), "test-input-file.apk");
fs.writeFileSync(filePath, "fake-apk");
try {
assert.doesNotThrow(() => validateInputPath(filePath));
} finally {
fs.unlinkSync(filePath);
}
});

it("should reject paths outside allowed roots like /etc", () => {
assert.throws(() => validateInputPath("/etc/passwd"), ActionableError);
});

it("should reject path traversal attempts via ../ from cwd", () => {
const filePath = path.join(process.cwd(), "..", "..", "etc", "passwd");
assert.throws(() => validateInputPath(filePath), ActionableError);
});

it("should reject absolute paths to /usr", () => {
assert.throws(() => validateInputPath("/usr/local/bin/malicious.apk"), ActionableError);
});

it("should reject paths under /home or /Users outside cwd", () => {
const otherUser = path.join("/Users", "otheruser", "malware.apk");
assert.throws(() => validateInputPath(otherUser), ActionableError);
});

it("should reject paths to root filesystem", () => {
assert.throws(() => validateInputPath("/malicious.apk"), ActionableError);
});
});

describe("validateFileExtension for install_app", () => {
it("should accept .apk files", () => {
assert.doesNotThrow(() => validateFileExtension("myapp.apk", ALLOWED_APP_EXTENSIONS, "install_app"));
});

it("should accept .ipa files", () => {
assert.doesNotThrow(() => validateFileExtension("myapp.ipa", ALLOWED_APP_EXTENSIONS, "install_app"));
});

it("should accept .zip files", () => {
assert.doesNotThrow(() => validateFileExtension("myapp.zip", ALLOWED_APP_EXTENSIONS, "install_app"));
});

it("should accept .app paths", () => {
assert.doesNotThrow(() => validateFileExtension("MyApp.app", ALLOWED_APP_EXTENSIONS, "install_app"));
});

it("should reject other extensions", () => {
assert.throws(
() => validateFileExtension("script.sh", ALLOWED_APP_EXTENSIONS, "install_app"),
ActionableError,
);
});

it("should reject files with no extension", () => {
assert.throws(
() => validateFileExtension("noext", ALLOWED_APP_EXTENSIONS, "install_app"),
ActionableError,
);
});
});