Skip to content

fix(server): prevent projects.writeFile symlink escapes#1071

Open
pRizz wants to merge 1 commit intopingdotgg:mainfrom
pRizz:codex/bug-001-symlink-write-fix
Open

fix(server): prevent projects.writeFile symlink escapes#1071
pRizz wants to merge 1 commit intopingdotgg:mainfrom
pRizz:codex/bug-001-symlink-write-fix

Conversation

@pRizz
Copy link

@pRizz pRizz commented Mar 14, 2026

Closes #1072

What Changed

projects.writeFile now canonicalizes both the workspace root and the target path before writing, so a path that looks in-workspace lexically cannot escape the project root by traversing a symlinked directory.

This also adds a regression test that creates a symlink inside the workspace pointing to an outside directory and verifies the write is rejected without creating the outside file.

Why

The previous check only validated the lexical relative path. That allowed a write like linked-outside/escape.md to pass validation when linked-outside was a symlink to a directory outside the workspace.

Related, but distinct: #316 reports a broader issue where WebSocket methods trust arbitrary client-supplied cwd values and can therefore operate in any directory the server can access. This PR addresses a different boundary failure: even when a caller is already operating against a chosen workspace, projects.writeFile can still escape that workspace by traversing an in-tree symlink. Fixing this PR improves workspace-root enforcement, but it does not fully resolve the broader cwd-validation problem described in #316.

Minimal Repro Script

The script below is intentionally not committed in this PR. It is the smallest copy-pasteable repro I found that can be run from the repo root.

  • Run it on main to prove the bug is present. It should print VERDICT: UNFIXED/VULNERABLE and show that an outside file was created.
  • Run it on this branch to prove the fix is working. It should print VERDICT: FIXED and show that no outside file was created.
#!/usr/bin/env bash
set -euo pipefail

PORT=4321
LOG_FILE=$(mktemp)
WORKSPACE=$(mktemp -d)
OUTSIDE=$(mktemp -d)
RESPONSE_FILE=$(mktemp)

cleanup() {
  if [[ -n "${SERVER_PID:-}" ]]; then
    kill "$SERVER_PID" 2>/dev/null || true
    wait "$SERVER_PID" 2>/dev/null || true
  fi
  rm -f "$LOG_FILE"
  rm -f "$RESPONSE_FILE"
}
trap cleanup EXIT

env -u T3CODE_AUTH_TOKEN bun run --cwd apps/server dev -- --port "$PORT" --host 127.0.0.1 --no-browser >"$LOG_FILE" 2>&1 &
SERVER_PID=$!
sleep 3

ln -s "$OUTSIDE" "$WORKSPACE/linked-outside"
export WORKSPACE PORT RESPONSE_FILE

node --input-type=module <<'NODE'
const workspace = process.env.WORKSPACE;
const port = process.env.PORT;
const responseFile = process.env.RESPONSE_FILE;
const requestId = crypto.randomUUID();
const ws = new WebSocket(`ws://127.0.0.1:${port}/`);
const fs = await import('node:fs/promises');

ws.addEventListener('message', (event) => {
  const message = JSON.parse(String(event.data));

  if (message.type === 'push' && message.channel === 'server.welcome') {
    ws.send(JSON.stringify({
      id: requestId,
      body: {
        _tag: 'projects.writeFile',
        cwd: workspace,
        relativePath: 'linked-outside/escape.txt',
        contents: 'escaped via symlink\n',
      },
    }));
    return;
  }

  if (message.id === requestId) {
    console.log('WebSocket response:');
    console.log(JSON.stringify(message, null, 2));
    fs.writeFile(responseFile, `${JSON.stringify(message, null, 2)}\n`, 'utf8').catch((error) => {
      console.error('Failed to persist response:', error);
    });
    ws.close();
  }
});
NODE

printf '\nOutside file path: %s\n' "$OUTSIDE/escape.txt"

if [[ -f "$OUTSIDE/escape.txt" ]]; then
  echo "VERDICT: UNFIXED/VULNERABLE"
  echo "The server wrote outside the workspace through the symlink."
  printf 'Outside file contents:\n'
  cat "$OUTSIDE/escape.txt" || true
else
  echo "VERDICT: FIXED"
  echo "The server did not create the outside file."
fi

if [[ -f "$RESPONSE_FILE" ]]; then
  printf '\nSaved response:\n'
  cat "$RESPONSE_FILE" || true
fi

exit 0

Checklist

  • This PR is small and focused
  • I explained what changed and why

Note

Prevent symlink escapes in projects.writeFile path validation

  • Adds resolveCanonicalPath in wsServer.ts to walk up to the nearest existing ancestor and resolve the real path via realpathSync.native, catching symlink indirections.
  • Updates resolveWorkspaceWritePath to compare canonical paths for both the workspace root and the target, rejecting writes that resolve outside the project root.
  • Adds a test in wsServer.test.ts covering a symlinked directory that points outside the workspace.
  • Behavioral Change: projects.writeFile requests whose resolved path escapes the workspace root now fail with an error instead of writing the file.

Macroscope summarized aa2c93b.

@coderabbitai
Copy link

coderabbitai bot commented Mar 14, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 42b92878-8361-40de-979c-e9473f8c0427

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can disable the changed files summary in the walkthrough.

Disable the reviews.changed_files_summary setting to disable the changed files summary in the walkthrough.

@github-actions github-actions bot added size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Mar 14, 2026
@pRizz pRizz changed the title Prevent projects.writeFile symlink escapes fix(server): prevent projects.writeFile symlink escapes Mar 14, 2026
@pRizz pRizz marked this pull request as ready for review March 14, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: projects.writeFile can escape the workspace root through symlinked directories

1 participant