-
Notifications
You must be signed in to change notification settings - Fork 50
fix(lib): fail-closed upload containment — default UPLOAD_BASE_DIR to CWD #366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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(); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [High] Fail-closed default is vacuous when cwd is With cwd Suggestion: after canonicalizing, if the base dir was defaulted (not explicitly configured) and equals the filesystem root ( 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}).`, | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Suggestion: branch the message on 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; | ||
|
|
||
| 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, | ||
|
|
@@ -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)", () => { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Suggestion: mock 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(" ", { | ||
|
|
@@ -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)); | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
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_DIRdocumented nowhereThis flips the default from "no containment" to "cwd-only": users uploading from
~/Downloadswhile the server runs with a project cwd (Cursor/VS Code) will start getting rejections, andMCP_UPLOAD_BASE_DIRappears 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