-
-
Notifications
You must be signed in to change notification settings - Fork 11
Description
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, 250comments.ts:185, 221, 232, 245pages.ts:172, 213, 237taxonomies.ts:209, 245, 271, 306users.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 exists —
validateFilePath(),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.