Skip to content
Closed
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: 3 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ const config = new Config(
browserstackLocalOptions,
process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true",
process.env.REMOTE_MCP === "true",
// Fail-closed: uploads are contained to the working directory unless the
// user explicitly widens the boundary via MCP_UPLOAD_BASE_DIR (PMAA-107).
process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
? process.env.MCP_UPLOAD_BASE_DIR
: undefined,
: process.cwd(),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Undocumented breaking default; MCP_UPLOAD_BASE_DIR documented nowhere

This flips the default from "no containment" to "cwd-only": users uploading from ~/Downloads while the server runs with a project cwd (Cursor/VS Code) will start getting rejections, and MCP_UPLOAD_BASE_DIR appears in no README or feature-flag doc.

Suggestion: document the env var (README env table + FEATURE-FLAGS doc) and call out the behavior change in the next release notes.

Reviewer: stack:code-review

);

export default config;
36 changes: 18 additions & 18 deletions src/lib/upload-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export interface UploadValidationOptions {
* - 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
* - The canonical path must live inside `allowedBaseDir` (defaults to the
* process working directory when not provided — containment is fail-closed)
*/
export function validateUploadPath(
filePath: string,
Expand Down Expand Up @@ -71,23 +72,22 @@ export function validateUploadPath(
);
}

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}.`,
);
}
const allowedBaseDir = options.allowedBaseDir ?? process.cwd();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Fail-closed default is vacuous when cwd is /

With cwd /, baseCanonical is "/", baseWithSep is "/", and every absolute path passes startsWith("/") — containment silently allows the entire filesystem. Claude Desktop on macOS launches stdio MCP servers with cwd /, so the fail-closed guarantee fails in one of the most common deployments this fix targets.

Suggestion: after canonicalizing, if the base dir was defaulted (not explicitly configured) and equals the filesystem root (path.parse(baseCanonical).root === baseCanonical), reject with "working directory is the filesystem root; set MCP_UPLOAD_BASE_DIR to enable uploads". Add a cwd=/ test.

Reviewer: stack:code-review

let baseCanonical: string;
try {
baseCanonical = fs.realpathSync(path.resolve(allowedBaseDir));
} catch {
throw new Error(
`Upload rejected: configured MCP_UPLOAD_BASE_DIR does not exist (${allowedBaseDir}).`,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Misleading error when the base dir was defaulted

If the env var is unset and realpathSync fails (e.g. deleted cwd), this blames "configured MCP_UPLOAD_BASE_DIR" even though the user configured nothing.

Suggestion: branch the message on options.allowedBaseDir !== undefined ("configured MCP_UPLOAD_BASE_DIR does not exist" vs "working directory could not be resolved").

Reviewer: stack:code-review

);
}
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}. Set MCP_UPLOAD_BASE_DIR to allow uploads from a different directory.`,
);
}

return canonical;
Expand Down
28 changes: 27 additions & 1 deletion tests/tools/upload-validator.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
validateUploadPath,
APP_BINARY_EXTENSIONS,
Expand Down Expand Up @@ -32,10 +32,35 @@ describe("validateUploadPath", () => {
const resolved = validateUploadPath(file, {
allowedExtensions: APP_BINARY_EXTENSIONS,
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
allowedBaseDir: workDir,
});
expect(resolved).toBe(fs.realpathSync(file));
});

it("defaults containment to the process working directory (fail-closed)", () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Test relies on tmpdir being outside vitest's cwd

This passes only because os.tmpdir() happens to be outside the repo cwd; on a runner with TMPDIR inside the workspace it fails spuriously, and with cwd / it passes vacuously.

Suggestion: mock process.cwd() to a sibling temp dir, as the companion test below already does.

Reviewer: stack:code-review

const outside = write("app.apk");
expect(() =>
validateUploadPath(outside, {
allowedExtensions: APP_BINARY_EXTENSIONS,
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
}),
).toThrow(/must be located inside/);
});

it("allows files inside the working directory when no base dir is set", () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(workDir);
try {
const file = write("app.apk");
const resolved = validateUploadPath(file, {
allowedExtensions: APP_BINARY_EXTENSIONS,
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
});
expect(resolved).toBe(fs.realpathSync(file));
} finally {
cwdSpy.mockRestore();
}
});

it("rejects an empty path", () => {
expect(() =>
validateUploadPath(" ", {
Expand Down Expand Up @@ -185,6 +210,7 @@ describe("validateUploadPath", () => {
const resolved = validateUploadPath(file, {
allowedExtensions: APP_BINARY_EXTENSIONS,
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
allowedBaseDir: workDir,
});
expect(resolved).toBe(fs.realpathSync(file));
});
Expand Down