High severity8.1NVD Advisory· Published Mar 27, 2026· Updated Mar 31, 2026
CVE-2026-33989
CVE-2026-33989
Description
Mobile Next is an MCP server for mobile development and automation. Prior to version 0.0.49, the @mobilenext/mobile-mcp server contains a Path Traversal vulnerability in the mobile_save_screenshot and mobile_start_screen_recording tools. The saveTo and output parameters were passed directly to filesystem operations without validation, allowing an attacker to write files outside the intended workspace. Version 0.0.49 fixes the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@mobilenext/mobile-mcpnpm | < 0.0.49 | 0.0.49 |
Affected products
1Patches
1f5e322959031fix: fix path traversal in save screenshot and record video (#296)
2 files changed · +89 −2
src/server.ts+14 −2 modified@@ -14,6 +14,10 @@ import { PNG } from "./png"; import { isScalingAvailable, Image } from "./image-utils"; import { Mobilecli } from "./mobilecli"; import { MobileDevice } from "./mobile-device"; +import { validateOutputPath, validateFileExtension } from "./utils"; + +const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"]; +const ALLOWED_RECORDING_EXTENSIONS = [".mp4"]; interface MobilecliDevice { id: string; @@ -581,10 +585,13 @@ export const createMcpServer = (): McpServer => { "Save a screenshot of the mobile device to a file", { device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - saveTo: z.string().describe("The path to save the screenshot to"), + saveTo: z.string().describe("The path to save the screenshot to. Filename must end with .png, .jpg, or .jpeg"), }, { destructiveHint: true }, async ({ device, saveTo }) => { + validateFileExtension(saveTo, ALLOWED_SCREENSHOT_EXTENSIONS, "save_screenshot"); + validateOutputPath(saveTo); + const robot = getRobotFromDevice(device); const screenshot = await robot.getScreenshot(); @@ -694,11 +701,16 @@ export const createMcpServer = (): McpServer => { "Start recording the screen of a mobile device. The recording runs in the background until stopped with mobile_stop_screen_recording. Returns the path where the recording will be saved.", { device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - output: z.string().optional().describe("The file path to save the recording to. If not provided, a temporary path will be used."), + output: z.string().optional().describe("The file path to save the recording to. Filename must end with .mp4. If not provided, a temporary path will be used."), timeLimit: z.coerce.number().optional().describe("Maximum recording duration in seconds. The recording will stop automatically after this time."), }, { destructiveHint: true }, async ({ device, output, timeLimit }) => { + if (output) { + validateFileExtension(output, ALLOWED_RECORDING_EXTENSIONS, "start_screen_recording"); + validateOutputPath(output); + } + getRobotFromDevice(device); if (activeRecordings.has(device)) {
src/utils.ts+75 −0 modified@@ -1,3 +1,6 @@ +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; import { ActionableError } from "./robot"; export function validatePackageName(packageName: string): void { @@ -11,3 +14,75 @@ export function validateLocale(locale: string): void { throw new ActionableError(`Invalid locale: "${locale}"`); } } + +function getAllowedRoots(): string[] { + const roots = [ + os.tmpdir(), + process.cwd(), + ]; + + // macOS /tmp is a symlink to /private/tmp, add both to be safe + if (process.platform === "darwin") { + roots.push("/tmp"); + roots.push("/private/tmp"); + } + + return roots.map(r => path.resolve(r)); +} + +function isPathUnderRoot(filePath: string, root: string): boolean { + const relative = path.relative(root, filePath); + if (relative === "") { + return false; + } + + if (path.isAbsolute(relative)) { + return false; + } + + if (relative.startsWith("..")) { + return false; + } + + return true; +} + +export function validateFileExtension(filePath: string, allowedExtensions: string[], toolName: string): void { + const ext = path.extname(filePath).toLowerCase(); + if (!allowedExtensions.includes(ext)) { + throw new ActionableError(`${toolName} requires a ${allowedExtensions.join(", ")} file extension, got: "${ext || "(none)"}"`); + } +} + +function resolveWithSymlinks(filePath: string): string { + const resolved = path.resolve(filePath); + const dir = path.dirname(resolved); + const filename = path.basename(resolved); + + try { + return path.join(fs.realpathSync(dir), filename); + } catch { + return resolved; + } +} + +export function validateOutputPath(filePath: string): void { + const resolved = resolveWithSymlinks(filePath); + const allowedRoots = getAllowedRoots(); + const isWindows = process.platform === "win32"; + + const isAllowed = allowedRoots.some(root => { + if (isWindows) { + return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase()); + } + + return isPathUnderRoot(resolved, root); + }); + + if (!isAllowed) { + const dir = path.dirname(resolved); + throw new ActionableError( + `"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.` + ); + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/mobile-next/mobile-mcp/commit/f5e32295903128c1e71cf915ae6c0b76c7b0153bnvdPatchWEB
- github.com/mobile-next/mobile-mcp/security/advisories/GHSA-3p2m-h2v6-g9mxnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-3p2m-h2v6-g9mxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33989ghsaADVISORY
- github.com/mobile-next/mobile-mcp/releases/tag/0.0.49nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.