Skip to content

Security: allowedRoots file-tool containment can be bypassed through symlinks #45

Description

@smyhlin

Summary

I found and reproduced a filesystem containment bypass in DevSpace on Linux.

A symbolic link placed inside an allowed workspace can point outside the configured allowedRoots. DevSpace file tools can then read from and write to the external target.

This affects the dedicated filesystem tools, not only the intentionally open-world shell tool.

Affected area

The current containment check in src/roots.ts is lexical:

const resolvedPath = resolve(expandHomePath(path));
const resolvedRoot = resolve(expandHomePath(root));
const relationship = relative(resolvedRoot, resolvedPath);

It verifies the normalized path string, but does not resolve or reject symbolic-link components in the actual filesystem.

As a result, a path can appear lexically inside an allowed root while resolving to a file outside it.

Reproduction

Tested on Linux with DevSpace 1.0.2.

Create an allowed directory and an outside directory:

rm -rf /tmp/devspace-symlink-poc

mkdir -p \
  /tmp/devspace-symlink-poc/allowed \
  /tmp/devspace-symlink-poc/outside

printf 'OUTSIDE_SECRET\n' \
  > /tmp/devspace-symlink-poc/outside/secret.txt

ln -s ../outside \
  /tmp/devspace-symlink-poc/allowed/escape

Configure DevSpace with only this allowed root:

/tmp/devspace-symlink-poc/allowed

Open that workspace and use the DevSpace read tool with:

escape/secret.txt

Observed result:

OUTSIDE_SECRET

Then use the DevSpace write tool with:

escape/written.txt

The file is created outside the allowed root:

cat /tmp/devspace-symlink-poc/outside/written.txt

In my test, both operations succeeded:

READ_RESULT: OUTSIDE_SECRET
OUTSIDE_FILE: WRITTEN_OUTSIDE

Expected behavior

Filesystem tools should reject any path whose real filesystem target is outside every configured allowed root.

A symlink inside an allowed directory should not let read, write, edit, grep, glob, or directory tools escape that root.

Actual behavior

The path passes the lexical resolve() / relative() check because:

/tmp/devspace-symlink-poc/allowed/escape/secret.txt

looks like a child of the allowed root before the symlink is resolved.

The operating system resolves escape to:

/tmp/devspace-symlink-poc/outside

so the file operation occurs outside the allowlist.

Security impact

This requires an authenticated and approved MCP client, so it is not an unauthenticated remote compromise.

Also, DevSpace's shell tool is intentionally capable of running commands with the local user's permissions.

However, this still matters because:

  1. DevSpace documents filesystem containment for the dedicated file tools.
  2. MCP hosts may apply different approval or safety decisions to read/write than to an open-world shell tool.
  3. Users may deliberately disable or avoid shell operations while trusting the filesystem allowlist.
  4. A repository can already contain attacker-controlled symlinks.
  5. A model may follow a symlink without realizing that it leaves the approved workspace.

This therefore breaks an advertised security boundary even though shell access is separately powerful.

Suggested remediation

A simple additional realpath() call is not sufficient for all operations because new write targets do not exist yet, and validation followed by opening can introduce TOCTOU races.

A reasonable cross-platform policy would be:

  1. Resolve and validate every configured allowed root at startup.
  2. Require allowed roots to exist and be directories.
  3. Canonicalize existing target paths before access.
  4. Verify the canonical target remains beneath a canonical allowed root.
  5. For new files, canonicalize the nearest existing parent.
  6. Walk path components using lstat() and reject symbolic-link components.
  7. Open files using safe flags where supported.
  8. Apply the same policy consistently to read, write, edit, list, glob, and search tools.

For a simpler and safer first fix, DevSpace file tools could reject all symlink traversal inside workspaces.

The separate Windows cross-drive issue should also add an isAbsolute(relativeResult) rejection, but that does not fix this Linux symlink case.

Suggested regression tests

Tests should use real temporary directories and symbolic links.

Read escape

allowed/escape -> outside/
allowed root: allowed/
read: escape/secret.txt
expected: AccessDeniedError

Write escape

allowed/escape -> outside/
allowed root: allowed/
write: escape/new.txt
expected: AccessDeniedError
outside/new.txt must not exist

Edit escape

allowed/escape -> outside/
edit: escape/existing.txt
expected: AccessDeniedError
outside file must remain unchanged

Directory and search tools

The same escape should be rejected by:

ls
grep
glob
find

Safe internal symlink policy

If symlinks are intentionally supported, add tests proving that:

allowed/link -> allowed/other-directory

is permitted only when the final canonical target remains under the same allowed root.

Additional hardening consideration

Currently, when no roots are explicitly configured, DevSpace can fall back to process.cwd().

For a remotely reachable daemon, it would be safer for serve to fail closed unless at least one root was explicitly configured, with an opt-in unsafe development flag for the current-directory fallback.

Environment

DevSpace: 1.0.2
Platform: Linux
Filesystem: local Linux filesystem
Authentication: approved OAuth client
Affected tools confirmed: read and write

Security: allowedRoots symlink escape

I reproduced a filesystem containment bypass on Linux with DevSpace 1.0.2.

A symlink inside an allowed root can point outside that root, after which the dedicated read and write tools can access the external target.

The attached ZIP contains:

  • minimal reproduction;
  • exact observed output;
  • relevant source excerpts;
  • audit notes.

Expected behavior: file tools must reject paths whose real filesystem target is outside the configured allowed roots.

Attached evidence:

devspace_audit_evidence_2026-06-27.zip

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions