Skip to content

fix(security): SSRF protection always-on + path traversal guard on uploads#177

Open
juliendoclot wants to merge 1 commit intodocdyhr:mainfrom
juliendoclot:fix/security-ssrf-and-path-traversal
Open

fix(security): SSRF protection always-on + path traversal guard on uploads#177
juliendoclot wants to merge 1 commit intodocdyhr:mainfrom
juliendoclot:fix/security-ssrf-and-path-traversal

Conversation

@juliendoclot
Copy link

@juliendoclot juliendoclot commented Mar 23, 2026

Summary

Two targeted security fixes that wire up existing security code. Zero test modifications — all 72 unit tests + 110 security tests pass unchanged.

Changes

V3 — SSRF protection enabled by default (src/client/api.ts)

Before: Private/localhost URL blocking only active when NODE_ENV=production
After: Active by default in all environments. Opt-out with ALLOW_PRIVATE_URLS=true for local WordPress instances.

-// Prevent localhost/private IP access in production
-if (config().app.isProduction) {
+// Prevent localhost/private IP access unless explicitly allowed
+if (process.env.ALLOW_PRIVATE_URLS !== "true") {

Why: A MCP server in dev/staging is often exposed the same way as in production (Docker, tunnels). SSRF to 169.254.169.254 (cloud metadata) or localhost:6379 (Redis) works regardless of NODE_ENV.

V1 — Path traversal guard on media uploads (src/tools/media.ts)

Before: handleUploadMedia passes user-supplied file_path directly to fs.promises.access() without validation
After: Calls validateFilePath() from src/utils/validation/security.ts (already in the codebase, just not wired up for uploads)

+import { validateFilePath } from "@/utils/validation/security.js";
+
+const allowedBasePath = process.env.MCP_UPLOAD_BASE_DIR || "/";
+const safePath = validateFilePath(uploadParams.file_path, allowedBasePath);

Why: Without validation, a compromised MCP client could read arbitrary files (/etc/shadow, ~/.ssh/id_rsa) and upload them to WordPress. The validateFilePath() function normalizes the path and ensures it stays within the allowed directory.

MCP_UPLOAD_BASE_DIR defaults to / (no restriction beyond traversal protection) to maintain backwards compatibility. In Docker deployments, setting it to /tmp is recommended.

Test plan

  • npm run build — compiles cleanly
  • npm test — 72/72 tests pass (zero modifications to existing tests)
  • npm run test:security — 110/110 security tests pass
  • Pre-commit hooks pass (eslint, prettier, typecheck, security validation)
  • Pre-push hooks pass (full test suite + security audit)

Ref: #176

Summary by Sourcery

Tighten security around outbound requests and media uploads by enabling SSRF protections by default and validating upload file paths to prevent traversal attacks.

Bug Fixes:

  • Enable private/localhost URL blocking for all environments by default with an explicit ALLOW_PRIVATE_URLS escape hatch.
  • Validate media upload file paths against a configurable base directory to prevent path traversal and restrict accessible files.

…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: docdyhr#176
@juliendoclot juliendoclot requested a review from docdyhr as a code owner March 23, 2026 20:31
@sourcery-ai
Copy link

sourcery-ai bot commented Mar 23, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Enables SSRF protection against private/localhost URLs in all environments by default and wires in a centralized path traversal validator for media uploads, introducing env‑flag opt-outs/config without changing existing tests.

Sequence diagram for validated media upload path traversal protection

sequenceDiagram
  actor MCPClient
  participant MediaTools
  participant SecurityValidation
  participant FS as FSPromises
  participant WordPressClient

  MCPClient->>MediaTools: handleUploadMedia(client, params)
  MediaTools->>MediaTools: uploadParams = toolParams(params)
  MediaTools->>MediaTools: allowedBasePath = MCP_UPLOAD_BASE_DIR or "/"
  MediaTools->>SecurityValidation: validateFilePath(uploadParams.file_path, allowedBasePath)
  SecurityValidation-->>MediaTools: safePath
  MediaTools->>MediaTools: uploadParams.file_path = safePath

  MediaTools->>FSPromises: access(safePath)
  FSPromises-->>MediaTools: success or error
  alt file not accessible
    MediaTools-->>MCPClient: Error("File not found at path: " + safePath)
  else file accessible
    MediaTools->>WordPressClient: uploadMedia(uploadParams)
    WordPressClient-->>MediaTools: media
    MediaTools-->>MCPClient: media
  end
Loading

Sequence diagram for SSRF protection on WordPressClient requests

sequenceDiagram
  participant Caller
  participant WordPressClient
  participant URLParser

  Caller->>WordPressClient: request(url)
  WordPressClient->>URLParser: parse(url)
  URLParser-->>WordPressClient: parsed

  WordPressClient->>WordPressClient: ensure protocol is http or https
  alt invalid protocol
    WordPressClient-->>Caller: Error("Only HTTP and HTTPS protocols are allowed")
  else valid protocol
    WordPressClient->>WordPressClient: check ALLOW_PRIVATE_URLS env var
    alt ALLOW_PRIVATE_URLS !== "true"
      WordPressClient->>WordPressClient: hostname = parsed.hostname.toLowerCase()
      WordPressClient->>WordPressClient: evaluate private ip and localhost patterns
      alt hostname is private or localhost
        WordPressClient-->>Caller: Error("Private/localhost URLs not allowed. Set ALLOW_PRIVATE_URLS=true for local development.")
      else hostname is public
        WordPressClient->>WordPressClient: proceed with HTTP request
        WordPressClient-->>Caller: response
      end
    else ALLOW_PRIVATE_URLS === "true"
      WordPressClient->>WordPressClient: skip private hostname checks
      WordPressClient->>WordPressClient: proceed with HTTP request
      WordPressClient-->>Caller: response
    end
  end
Loading

Class diagram for MediaTools and WordPressClient security changes

classDiagram
  class MediaTools {
    +handleUploadMedia(client, params) Promise
  }

  class WordPressClient {
    +uploadMedia(uploadParams) Promise
    +request(url) Promise
  }

  class SecurityValidation {
    +validateFilePath(filePath, allowedBasePath) string
  }

  class FSModule {
    +access(path) Promise
  }

  class Environment {
    +MCP_UPLOAD_BASE_DIR string
    +ALLOW_PRIVATE_URLS string
  }

  MediaTools --> WordPressClient : uses
  MediaTools --> SecurityValidation : calls validateFilePath
  MediaTools --> FSModule : calls access
  MediaTools --> Environment : reads MCP_UPLOAD_BASE_DIR

  WordPressClient --> Environment : reads ALLOW_PRIVATE_URLS
  WordPressClient --> WordPressClient : validates protocol and hostname
  SecurityValidation <.. WordPressClient : complements SSRF protection conceptually
Loading

File-Level Changes

Change Details Files
Always-on SSRF protection for outbound WordPress HTTP requests with explicit env-based opt-out.
  • Change private/localhost URL blocking condition to be active unless ALLOW_PRIVATE_URLS is set to "true" instead of only in production mode.
  • Preserve existing hostname checks for localhost, loopback, and private IP ranges while updating the error message to describe the new ALLOW_PRIVATE_URLS escape hatch.
  • Maintain existing URL scheme validation (HTTP/HTTPS) and request flow while tightening default security posture for all environments.
src/client/api.ts
Add path traversal validation and optional base directory restriction for media upload file paths.
  • Import and use the existing validateFilePath helper to sanitize and constrain uploadParams.file_path before any filesystem operations.
  • Introduce MCP_UPLOAD_BASE_DIR environment variable (defaulting to "/") to define an allowed base directory for uploads, recommended as /tmp in Docker deployments.
  • Use the validated safePath for fs.promises.access checks and for error messages before passing uploadParams to client.uploadMedia, avoiding direct use of the original unvalidated path.
src/tools/media.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In handleUploadMedia, consider wrapping the validateFilePath call in a try/catch and translating its error into a clearer, tool-level error message so callers don’t see low-level validation details directly.
  • The ALLOW_PRIVATE_URLS toggle currently disables all private IP blocking; consider a more granular option (e.g., allowing only localhost/loopback while still blocking RFC1918 and metadata ranges) to better support local dev without fully dropping SSRF protections.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `handleUploadMedia`, consider wrapping the `validateFilePath` call in a try/catch and translating its error into a clearer, tool-level error message so callers don’t see low-level validation details directly.
- The `ALLOW_PRIVATE_URLS` toggle currently disables all private IP blocking; consider a more granular option (e.g., allowing only `localhost`/loopback while still blocking RFC1918 and metadata ranges) to better support local dev without fully dropping SSRF protections.

## Individual Comments

### Comment 1
<location path="src/client/api.ts" line_range="263-264" />
<code_context>
+      // 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" ||
</code_context>
<issue_to_address>
**🚨 suggestion (security):** IPv6 localhost and other private ranges are not covered by the current hostname checks.

Current checks only cover IPv4 localhost/RFC1918, not IPv6 (`::1`, link-local, or other private IPv6 ranges). Consider extending the logic to also treat `::1`, `localhost6`, and relevant IPv6 private/link-local patterns as private so IPv6 local-only endpoints aren’t inadvertently allowed.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines 263 to 264
if (
hostname === "localhost" ||
Copy link

Choose a reason for hiding this comment

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

🚨 suggestion (security): IPv6 localhost and other private ranges are not covered by the current hostname checks.

Current checks only cover IPv4 localhost/RFC1918, not IPv6 (::1, link-local, or other private IPv6 ranges). Consider extending the logic to also treat ::1, localhost6, and relevant IPv6 private/link-local patterns as private so IPv6 local-only endpoints aren’t inadvertently allowed.

@juliendoclot
Copy link
Author

Thanks for the review @sourcery-ai!

Re: wrapping validateFilePath in try/catch — Good point. The current validateFilePath() throws a WordPressAPIError with a clear message ("Invalid file path: access denied", code PATH_TRAVERSAL_ATTEMPT), which gets caught by the existing outer try/catch and wrapped as "Failed to upload media: Invalid file path: access denied". I think that's already clear enough for callers, but happy to add an extra catch if the maintainer prefers.

Re: granular ALLOW_PRIVATE_URLS — Agreed this would be a nice improvement (e.g., allow loopback but block RFC1918 + cloud metadata 169.254.x.x). I kept this PR minimal on purpose — a follow-up could add ALLOW_LOOPBACK_ONLY=true as a middle ground. Happy to do that in a separate PR if there's interest.

Re: IPv6 coverage::1 is already checked on line 265 of the original code (unchanged in this PR). You're right that fe80::/10 (link-local) and other IPv6 private ranges aren't covered, but that's a pre-existing gap, not introduced by this change. Could be addressed in a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant