Skip to content

Bug: validatePathWithinRoot allows reading files outside project root via symlinks #527

@sulthonzh

Description

@sulthonzh

Bug: validatePathWithinRoot allows reading files outside project root via symlinks

Reproduction

#!/bin/bash
# End-to-end: symlink inside project → file outside root → leaked via MCP
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT

mkdir -p "$TMPDIR/project/src" "$TMPDIR/secrets"

# Sensitive file OUTSIDE the project root
echo 'DATABASE_URL="postgres://admin:password@prod-db:5432"' > "$TMPDIR/secrets/config.ts"

# Symlink INSIDE the project pointing outside
ln -s "$TMPDIR/secrets/config.ts" "$TMPDIR/project/src/database.config.ts"

# A legit source file so indexing has something to scan
echo 'export function hello() { return "world"; }' > "$TMPDIR/project/src/index.ts"

cd "$TMPDIR/project"
codegraph init -i    # indexes the project (follows symlink, stores "src/database.config.ts")

# Later, an MCP agent calls codegraph_explore or codegraph_node with includeCode=true.
# validatePathWithinRoot resolves "src/database.config.ts" to an in-root path,
# but readFileSync follows the symlink to $TMPDIR/secrets/config.ts.

Run with: bash poc.sh
Expected: Symlinks pointing outside the project root should be rejected — their content should not be readable through MCP tools.
Actual: The symlinked file is indexed and its contents are served to MCP agents, leaking files outside the project root.

Environment

  • Version: 02935d7 (latest main as of 2025-05-28)
  • Node: v25.9.0
  • OS: macOS (arm64)

Root Cause

validatePathWithinRoot (src/utils.ts:57) uses only path.resolve to check containment, which resolves .. lexically but does not resolve symlinks. A symlink inside the project pointing to a file outside the root passes the check because the logical path appears within the root.

The codebase already has isPathWithinRootReal (src/utils.ts:97) that properly calls fs.realpathSync to catch this exact class of attack — but it is never used in the file-read paths of the MCP tools or extraction pipeline.

Two places where this matters:

  1. MCP tool file reads (src/mcp/tools.ts:1403, 1432, 1743): readFileSync follows the symlink after validatePathWithinRoot approves the logical path.
  2. Extraction/indexing (src/extraction/index.ts): The walk() function does resolve symlinks with realpathSync, but stores the relative path (path.relative(rootDir, fullPath)) using the symlink's name, not the real target. Later reads of stored paths follow the symlink.

Suggested Fix

Replace validatePathWithinRoot calls in file-read paths with isPathWithinRootReal (or add fs.realpathSync to validatePathWithinRoot):

// src/utils.ts — add realpath check to validatePathWithinRoot
export function validatePathWithinRoot(projectRoot: string, filePath: string): string | null {
  const resolved = path.resolve(projectRoot, filePath);
  const normalizedRoot = path.resolve(projectRoot);

  if (!resolved.startsWith(normalizedRoot + path.sep) && resolved !== normalizedRoot) {
    return null;
  }

  // Resolve symlinks to prevent escapes
  try {
    const realPath = fs.realpathSync(resolved);
    const realRoot = fs.realpathSync(normalizedRoot);
    if (!realPath.startsWith(realRoot + path.sep) && realPath !== realRoot) {
      return null;
    }
    return realPath;
  } catch {
    // File doesn't exist yet (e.g., during write) — logical check already passed
    return resolved;
  }
}

This preserves backward compatibility while closing the symlink escape. The repo's existing isPathWithinRootReal shows the maintainers are aware of this pattern — it just needs to be applied consistently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions