Skip to content

Security: path traversal, missing input validation, and SSRF protection bypass #176

@juliendoclot

Description

@juliendoclot

Security Audit Report

I deployed mcp-wordpress in production (Docker, behind Traefik with IP allowlist) and performed a full security audit of the codebase (v3.1.13). The architecture is solid and well-structured, but I found 3 vulnerabilities where existing security code is defined but not applied. Patches are suggested below.


V1 — CRITICAL: Path traversal in wp_upload_media

Files: src/tools/media.ts (line 224), src/client/operations/media.ts (line 53)

Problem: handleUploadMedia accepts an arbitrary file_path from the MCP client and passes it directly to fs.promises.access() without validation. An attacker controlling the MCP client (e.g., via prompt injection) could read any file on the filesystem and upload it to WordPress.

// Current code — no validation
const uploadParams = toolParams<UploadMediaRequest & { file_path: string }>(params);
await fs.promises.access(uploadParams.file_path); // accepts /etc/passwd, ~/.ssh/id_rsa, etc.

The fix exists in the codebase: validateFilePath() in src/utils/validation/security.ts does exactly what's needed (path normalization + base directory check) — it's just never called.

Suggested patch:

// src/tools/media.ts
+import { validateFilePath } from "@/utils/validation/security.js";

 public async handleUploadMedia(client: WordPressClient, params: Record<string, unknown>): Promise<unknown> {
   const uploadParams = toolParams<UploadMediaRequest & { file_path: string }>(params);
+  const allowedBasePath = process.env.MCP_UPLOAD_BASE_DIR || "/tmp";
+  uploadParams.file_path = validateFilePath(uploadParams.file_path, allowedBasePath);

Same fix should be applied in src/client/operations/media.ts as defense-in-depth.


V2 — CRITICAL: Missing runtime validation in most tool handlers

Files: comments.ts, pages.ts, taxonomies.ts, users.ts, media.ts

Problem: These handlers use unsafe TypeScript casts (params as { id: number }) instead of runtime validation. While PostHandlers correctly uses validatePostParams() + sanitizeHtml(), the other handlers skip validation entirely.

Affected locations (15 occurrences):

  • media.ts:206, 250
  • comments.ts:185, 221, 232, 245
  • pages.ts:172, 213, 237
  • taxonomies.ts:209, 245, 271, 306
  • users.ts:235, 337

The fix exists in the codebase: ToolSchemas.idParams in InputValidator.ts (line 303-306) validates IDs as positive integers — it's just never used in these handlers.

Suggested patch (same pattern for all files):

+import { z } from "zod";
+
+const IdSchema = z.object({
+  id: z.number().int().positive("ID must be a positive integer"),
+});

 public async handleGetComment(client: WordPressClient, params: Record<string, unknown>): Promise<unknown> {
-  const { id } = params as { id: number };
+  const { id } = IdSchema.parse(params);

With additional schemas for force: z.boolean().optional(), reassign: z.number().int().positive().optional(), include_content: z.boolean().optional() where needed.


V3 — HIGH: SSRF protection disabled outside production

File: src/client/api.ts (line 259)

Problem: validateAndSanitizeUrl() only blocks private/localhost IPs when config().app.isProduction === true. In development, staging, or when NODE_ENV is unset (the default), there is no SSRF protection. An attacker could target internal services (169.254.169.254 for cloud metadata, 127.0.0.1:6379 for Redis, etc.).

// Current code — protection only in production
if (config().app.isProduction) {
  // block private IPs...
}

Suggested patch:

-// Prevent localhost/private IP access in production
-if (config().app.isProduction) {
+// Prevent localhost/private IP access in all environments
+// Set ALLOW_PRIVATE_URLS=true for local development with a local WordPress instance
+if (process.env.ALLOW_PRIVATE_URLS !== "true") {
   const hostname = parsed.hostname.toLowerCase();
   // ... existing IP checks ...
-  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 dev.");

Additional findings (lower severity)

# Severity Description File
V4 HIGH wp_create_application_password returns password in cleartext in MCP stream site.ts:255
V5 HIGH request() accepts absolute URLs if endpoint starts with http (SSRF vector) api.ts:540
V6 MEDIUM MIME type checked by extension only, not magic bytes operations/media.ts:144
V7 MEDIUM Config file with cleartext secrets, no file permission check ServerConfiguration.ts
V8 LOW Math.random() used for OAuth state (not cryptographically secure) auth.ts:285

What's done well

  • Zero hardcoded secrets, log sanitization for passwords/tokens
  • Minimal dependencies (4 production deps, 0 known vulnerabilities)
  • Clean Docker setup — multi-stage build, non-root user (UID 1001), tini init
  • Rate limiting, circuit breaker, retry with exponential backoff
  • Security code existsvalidateFilePath(), SecuritySchemas, SSRF checks — they just need to be wired up

Environment

  • mcp-wordpress v3.1.13
  • Node.js 20 (Alpine Docker)
  • Deployed behind Traefik with Anthropic IP allowlist

Happy to submit a PR if that would be more helpful. Great project overall — the security foundations are solid, just a few gaps in applying them consistently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions