From 37ea7ff1a9486fd2e91cadf009a447b76c928bcf Mon Sep 17 00:00:00 2001 From: Vercel Date: Mon, 23 Mar 2026 21:30:55 +0100 Subject: [PATCH] fix(security): enable SSRF protection by default, add path traversal guard on uploads SSRF protection (V3): - Private/localhost URL blocking now active in all environments, not just production - Developers can opt-in with ALLOW_PRIVATE_URLS=true for local WordPress instances - Previously, omitting NODE_ENV=production left the server vulnerable to SSRF Path traversal protection (V1): - Add validateFilePath() call before filesystem access in handleUploadMedia - Uses the existing security utility (src/utils/validation/security.ts) that was defined but never wired up for media uploads - MCP_UPLOAD_BASE_DIR env var restricts uploads to a specific directory (recommended: set to /tmp in Docker deployments) All 72 existing tests pass with zero modifications. Ref: #176 --- src/client/api.ts | 7 ++++--- src/tools/media.ts | 11 +++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/client/api.ts b/src/client/api.ts index e02cd1b..49b264d 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -256,8 +256,9 @@ export class WordPressClient implements IWordPressClient { throw new Error("Only HTTP and HTTPS protocols are allowed"); } - // Prevent localhost/private IP access in production - if (config().app.isProduction) { + // Prevent localhost/private IP access unless explicitly allowed + // Set ALLOW_PRIVATE_URLS=true for local development with a local WordPress + if (process.env.ALLOW_PRIVATE_URLS !== "true") { const hostname = parsed.hostname.toLowerCase(); if ( hostname === "localhost" || @@ -267,7 +268,7 @@ export class WordPressClient implements IWordPressClient { hostname.match(/^172\.(1[6-9]|2[0-9]|3[01])\./) || hostname.match(/^192\.168\./) ) { - throw new Error("Private/localhost URLs not allowed in production"); + throw new Error("Private/localhost URLs not allowed. Set ALLOW_PRIVATE_URLS=true for local development."); } } diff --git a/src/tools/media.ts b/src/tools/media.ts index cf8a898..1b6a572 100644 --- a/src/tools/media.ts +++ b/src/tools/media.ts @@ -3,6 +3,7 @@ import { WordPressClient } from "@/client/api.js"; import type { MCPToolSchema } from "@/types/mcp.js"; import { MediaQueryParams, UpdateMediaRequest, UploadMediaRequest } from "@/types/wordpress.js"; import { getErrorMessage } from "@/utils/error.js"; +import { validateFilePath } from "@/utils/validation/security.js"; import { toolParams } from "./params.js"; /** @@ -223,10 +224,16 @@ export class MediaTools { public async handleUploadMedia(client: WordPressClient, params: Record): Promise { const uploadParams = toolParams(params); try { + // Validate file path to prevent path traversal attacks + // Set MCP_UPLOAD_BASE_DIR to restrict uploads to a specific directory (recommended in Docker) + const allowedBasePath = process.env.MCP_UPLOAD_BASE_DIR || "/"; + const safePath = validateFilePath(uploadParams.file_path, allowedBasePath); + uploadParams.file_path = safePath; + try { - await fs.promises.access(uploadParams.file_path); + await fs.promises.access(safePath); } catch (_error) { - throw new Error(`File not found at path: ${uploadParams.file_path}`); + throw new Error(`File not found at path: ${safePath}`); } const media = await client.uploadMedia(uploadParams);