Skip to content

fix: close prefix-confusion and symlink bypasses in path allowlist - #104

Open
zcaceres wants to merge 1 commit into
mainfrom
fix/path-allowlist-bypasses
Open

fix: close prefix-confusion and symlink bypasses in path allowlist#104
zcaceres wants to merge 1 commit into
mainfrom
fix/path-allowlist-bypasses

Conversation

@zcaceres

Copy link
Copy Markdown
Owner

Summary

Closes two real bypasses in the MD_ALLOWED_PATHS / MD_SHARE_DIR path allowlist:

  1. Prefix confusion in isWithinDirectory. src/utils.ts used normPath.startsWith(normDir), so '/srv/share-evil/secret.txt'.startsWith('/srv/share') returned true. A sibling directory whose name shares a prefix with the allowed dir bypassed the check.
  2. No symlink resolution in assertPathAllowed. path.resolve + path.normalize don't follow symlinks, so an attacker who can write into the allowed directory (the intended use case for MD_SHARE_DIR) could plant a symlink to ~/.ssh/id_rsa, ~/.aws/credentials, etc., and exfiltrate via an LLM-triggered tool call.

Both bugs only affect operators who set MD_ALLOWED_PATHS / MD_SHARE_DIR — exactly the audience trusting the allowlist for safety. Default installs (no env var = no allowlist) are unaffected.

Context from the original report: #99. The PR's headline claim ("filePath is passed unchecked to markitdown") was already addressed by the existing assertPathAllowed call in Markdownify.toMarkdown, but the two sub-bugs above were left open. This PR closes them with the minimum surface area.

Changes

src/utils.ts

  • Rewrite isWithinDirectory to use path.relative and reject results that start with .. or are absolute (the absolute case handles Windows cross-drive comparisons).
  • Add realpathOrAncestor helper that resolves symlinks on the longest existing ancestor of a path and re-joins any non-existent suffix.
  • assertPathAllowed now realpath-resolves both the input path and the allowed directories before the containment check.

src/utils.test.ts

  • Sibling-prefix rejection test for isWithinDirectory.
  • Three new assertPathAllowed tests: sibling-prefix rejection, symlink escape, and a realpath-aware happy path (also covers macOS /tmp/private/tmp).

No new dependencies, no new env vars, no behavior change for users without the allowlist set.

Test plan

  • bun test src/utils.test.ts — 95 pass, 0 fail
  • bun run build — clean compile
  • Manual smoke against the MCP server with MD_ALLOWED_PATHS set:
    • File inside the dir → succeeds
    • Sibling-prefix path (/tmp/allowed-evil/x) → rejected
    • Symlink in the dir pointing to /etc/hosts → rejected

isWithinDirectory used startsWith, so `/srv/share-evil` passed a
`startsWith('/srv/share')` check. Rewrite using path.relative and reject
results starting with `..` or that are absolute (Windows cross-drive).

assertPathAllowed resolved with path.resolve+normalize but never followed
symlinks. An attacker who can write into the allowed directory (the
intended use case for MD_SHARE_DIR) could plant a symlink to ~/.ssh/id_rsa
and exfiltrate via an LLM tool call. Realpath both the input and the
allowed dirs before the containment check; walk up parents when the leaf
doesn't exist yet so dir-level symlinks are still caught.

Adds regression tests for sibling-prefix rejection, symlink escape, and
the realpath-aware happy path (also fixes macOS /tmp -> /private/tmp).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant