Research
Researchvulnerability5 min read

Edict's file:// handler let anyone read files outside the project directory (CWE-22)

The add_remote_skill endpoint in cft0808/edict applied path traversal protection to local and relative paths but skipped the file:// branch entirely. One .resolve() and an allowed_roots check closed the gap.

Edict's file:// handler let anyone read files outside the project directory (CWE-22)

The add_remote_skill endpoint in cft0808/edict accepted file:// URLs and opened the referenced path without resolving symlinks, collapsing .. sequences or checking whether the target fell within an allowed directory. The result was a straightforward path traversal: anyone on the network with API access was able to read files from the host filesystem.

I identified the vulnerability, wrote a fix and submitted PR #258. It was merged on 5 April 2026.

The vulnerability

The add_remote_skill() function in dashboard/server.py handles several URL schemes for importing skill definitions. The relevant code branches are:

  1. https:// URLs, validated by validate_url().
  2. Absolute paths starting with / and relative paths starting with ., which call .resolve() and check the result against allowed_roots.
  3. file:// URLs, which stripped the scheme prefix and opened the path directly.

The third branch was the problem. Before the fix, it looked like this:

elif source_url.startswith('file://'):
    local_path = pathlib.Path(source_url[7:])
    if not local_path.exists():
        return {'ok': False, 'error': f'本地文件不存在: {local_path}'}
    content = local_path.read_text()

No .resolve(). No allowed_roots check. The path was taken from user input, tested for existence and read. A request with file:///etc/passwd or file://../../../home/user/.ssh/id_rsa would either return the file contents (if it passed content validation) or confirm the file's existence through the distinct error message for missing versus invalid files.

The sibling branches, handling / and . prefixed paths, already had full protection:

local_path = pathlib.Path(source_url).resolve()
allowed_roots = (OCLAW_HOME.resolve(), BASE.parent.resolve())
if not any(str(local_path).startswith(str(root)) for root in allowed_roots):
    return {'ok': False, 'error': '路径不在允许的目录范围内'}

The file:// branch was simply missed. Same function, same file, three branches doing the same job. Two of them were secured. One was not.

What limits exploitation

A content validation step downstream requires files to start with --- and contain name: in the first 500 characters. This means the endpoint cannot serve back arbitrary binary files or most configuration files in their raw form. /etc/passwd would be rejected on content validation, not on the path check.

That said, YAML frontmatter is common. Kubernetes manifests, other skill definitions, Hugo content files and various configuration formats would pass the check. More importantly, file existence enumeration works regardless of content validation. The error returned for "file does not exist" differs from the error returned for "file exists but has invalid content". An attacker can map the filesystem without ever reading a byte of content.

Deployment context

The severity depends heavily on deployment configuration. Edict's default non-Docker mode binds to 127.0.0.1:7891, limiting exposure to local access. But the Dockerfile and docker-compose.yml bind to 0.0.0.0:7891, making the API network-accessible.

Authentication is opt-in. The POST /api/auth/setup endpoint exists, but until someone explicitly configures a password, every API endpoint is publicly accessible. CORS restrictions apply to browser-originating requests but do nothing to stop direct HTTP calls.

In a Docker deployment with default settings, the path traversal is reachable from the network without any credentials.

The fix

The fix adds .resolve() and the same allowed_roots check to the file:// branch:

elif source_url.startswith('file://'):
    local_path = pathlib.Path(source_url[7:]).resolve()
    if not local_path.exists():
        return {'ok': False, 'error': f'本地文件不存在: {local_path}'}
    allowed_roots = (OCLAW_HOME.resolve(), BASE.parent.resolve())
    if not any(str(local_path).startswith(str(root)) for root in allowed_roots):
        return {'ok': False, 'error': '路径不在允许的目录范围内'}
    content = local_path.read_text()

The .resolve() call canonicalises the path, collapsing .. sequences and following symlinks to produce an absolute path. The allowed_roots tuple restricts reads to OCLAW_HOME (the application's configuration directory) and the project base directory. The error message follows the existing Chinese-language convention used elsewhere in the codebase.

This is the exact same pattern already established in lines 356 to 364 of the same file. No new security model. No novel defence. Just applying the existing one to the branch that was left out.

A known limitation

The str().startswith() approach for checking allowed roots has a known prefix-collision edge case. A path like /home/user/.openclaw-evil/ would match against an allowed root of /home/user/.openclaw because the string prefix matches. The proper defence is to compare resolved pathlib.Path parents, not string prefixes.

However, this is a pre-existing pattern shared across all three branches of the function. The fix matches the codebase's own convention rather than introducing a different approach for one branch. Fixing the prefix collision is a separate issue that affects the entire function, not just the file:// path.

Tests

The PR includes three tests in tests/test_cwe22_file_url.py:

TestWhat it validates
test_file_url_path_traversal_blockedA file:// URL pointing outside allowed_roots returns ok: False
test_file_url_within_allowed_roots_worksA file:// URL inside allowed_roots still works correctly
test_file_url_etc_passwd_blockedThe classic /etc/passwd read via file:// is rejected

Each test configures isolated temporary directories for DATA, OCLAW_HOME and the target files, ensuring no test depends on host filesystem state.

The pattern repeating

This is the same class of inconsistency I have been finding across AI agent frameworks throughout 2026. In LightRAG's Memgraph backend, the Neo4j storage layer had Cypher injection protection but the Memgraph layer did not. In NousResearch's Hermes Agent, _setup_worktree() joined user-supplied include paths without validation, enabling arbitrary file reads. The common thread is not a single vulnerability class but a structural habit: security controls applied inconsistently across parallel code paths.

AI frameworks tend to grow multiple input handlers quickly. HTTP URLs, local paths, file:// URLs, relative imports. Each gets its own branch. The developer who writes the first branch and secures it may not be the developer who adds the third branch six months later. The security properties of one branch do not propagate to its siblings by default. They propagate by review, by testing or by a researcher reading every branch and noticing that one of them is different.

Five lines of code separated a secured endpoint from a path traversal. The fix was not clever. The gap should not have existed.

Newsletter

One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.