[security] resolveInsideRoot doesn't protect against CWE-59 (symlink-based traversal) — follow-up to #369 #380

Closed
opened 2026-05-11 04:13:22 +00:00 by hongming-pc2 · 1 comment
Owner

Follow-up to #369resolveInsideRoot does not protect against CWE-59 (symlink-based traversal)

Discovered during five-axis review of PR #369 (which correctly closes the relative-path-traversal vector named in #321).

resolveInsideRoot (workspace-server/internal/handlers/org_helpers.go:320-341) uses filepath.Abs + prefix-check to validate paths are inside root. This is correct for lexical traversal (../../etc, absolute paths). It is not sufficient against symlink-based traversal (CWE-59):

func resolveInsideRoot(root, userPath string) (string, error) {
    // ... existing checks ...
    joined := filepath.Join(absRoot, userPath)
    absJoined, _ := filepath.Abs(joined)
    if absJoined != absRoot && !strings.HasPrefix(absJoined, absRoot+sep) {
        return "", fmt.Errorf("path escapes root")
    }
    return absJoined, nil  // ← does NOT resolve symlinks
}

Attack scenario

  1. Attacker submits org YAML that points files_dir (or any path-typed field that goes through this helper) at a directory containing a symlink — e.g. via a git repo where the YAML references files_dir: workspaces/dev, and inside that directory the attacker plants a symlink inner -> /etc.
  2. resolveInsideRoot(orgBaseDir, "workspaces/dev/inner") lexically resolves to <orgBaseDir>/workspaces/dev/inner — prefix check passes.
  3. Downstream caller (e.g. parseEnvFile, filepath.Walk, CopyTemplateToContainer) opens the path. Linux follows the symlink → reads /etc/.env or walks /etc.

Fix shape

// After prefix-check:
realPath, err := filepath.EvalSymlinks(absJoined)
if err != nil {
    // Path doesn't exist yet (legitimate for "to-be-created" cases) — return absJoined.
    // OR: caller separates "validate-existing" from "validate-prospective" cases.
    if os.IsNotExist(err) {
        return absJoined, nil
    }
    return "", fmt.Errorf("symlink resolution: %w", err)
}
// Re-verify the resolved real path is still inside root.
if realPath != absRoot && !strings.HasPrefix(realPath, absRoot+string(filepath.Separator)) {
    return "", fmt.Errorf("path resolves to a symlink that escapes root")
}
return realPath, nil

Test cases to add (alongside #369's matrix)

{"symlink_to_outside", setupSymlinkToOutside, true /* should reject */},
{"symlink_to_inside",  setupSymlinkToInside,  false /* should accept */},
{"nested_symlink",     setupNestedSymlink,    true /* parent contains escape symlink */},
{"non_existent",       "workspaces/never-existed", false /* allow prospective paths */},

Severity

Tier:medium. Exploitation requires an attacker who controls the file tree under orgBaseDir — typically a malicious org-template repo or a compromised git pull. NOT exploitable from YAML-only input alone (which #369 covers).

Callers that consume resolveInsideRoot output

  • loadWorkspaceEnv (closed by #369, will benefit from EvalSymlinks too)
  • createWorkspaceTree (org_import.go:~313)
  • CopyTemplateToContainer (provisioner.go:766-820 — filepath.Walk will follow symlinks during the tar step)

Filing per the recommendation in my PR #369 review.

— hongming-pc2

## Follow-up to #369 — `resolveInsideRoot` does not protect against CWE-59 (symlink-based traversal) **Discovered during five-axis review of PR #369** (which correctly closes the relative-path-traversal vector named in #321). `resolveInsideRoot` (workspace-server/internal/handlers/org_helpers.go:320-341) uses `filepath.Abs` + prefix-check to validate paths are inside `root`. This is correct for **lexical** traversal (`../../etc`, absolute paths). It is **not** sufficient against **symlink-based** traversal (CWE-59): ```go func resolveInsideRoot(root, userPath string) (string, error) { // ... existing checks ... joined := filepath.Join(absRoot, userPath) absJoined, _ := filepath.Abs(joined) if absJoined != absRoot && !strings.HasPrefix(absJoined, absRoot+sep) { return "", fmt.Errorf("path escapes root") } return absJoined, nil // ← does NOT resolve symlinks } ``` ### Attack scenario 1. Attacker submits org YAML that points `files_dir` (or any path-typed field that goes through this helper) at a directory containing a symlink — e.g. via a git repo where the YAML references `files_dir: workspaces/dev`, and inside that directory the attacker plants a symlink `inner -> /etc`. 2. `resolveInsideRoot(orgBaseDir, "workspaces/dev/inner")` lexically resolves to `<orgBaseDir>/workspaces/dev/inner` — prefix check passes. 3. Downstream caller (e.g. `parseEnvFile`, `filepath.Walk`, `CopyTemplateToContainer`) opens the path. Linux follows the symlink → reads `/etc/.env` or walks /etc. ### Fix shape ```go // After prefix-check: realPath, err := filepath.EvalSymlinks(absJoined) if err != nil { // Path doesn't exist yet (legitimate for "to-be-created" cases) — return absJoined. // OR: caller separates "validate-existing" from "validate-prospective" cases. if os.IsNotExist(err) { return absJoined, nil } return "", fmt.Errorf("symlink resolution: %w", err) } // Re-verify the resolved real path is still inside root. if realPath != absRoot && !strings.HasPrefix(realPath, absRoot+string(filepath.Separator)) { return "", fmt.Errorf("path resolves to a symlink that escapes root") } return realPath, nil ``` ### Test cases to add (alongside #369's matrix) ```go {"symlink_to_outside", setupSymlinkToOutside, true /* should reject */}, {"symlink_to_inside", setupSymlinkToInside, false /* should accept */}, {"nested_symlink", setupNestedSymlink, true /* parent contains escape symlink */}, {"non_existent", "workspaces/never-existed", false /* allow prospective paths */}, ``` ### Severity Tier:medium. Exploitation requires an attacker who controls the file tree under `orgBaseDir` — typically a malicious org-template repo or a compromised git pull. NOT exploitable from YAML-only input alone (which #369 covers). ### Callers that consume `resolveInsideRoot` output - `loadWorkspaceEnv` (closed by #369, will benefit from EvalSymlinks too) - `createWorkspaceTree` (org_import.go:~313) - `CopyTemplateToContainer` (provisioner.go:766-820 — `filepath.Walk` will follow symlinks during the tar step) Filing per the recommendation in my PR #369 review. — hongming-pc2
triage-operator added the securitytier:medium labels 2026-05-11 04:23:09 +00:00
Member

Self-assigning. Fix pushed to fix/380-cwe59-symlink-traversal → PR incoming.

Self-assigning. Fix pushed to `fix/380-cwe59-symlink-traversal` → PR incoming.
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: molecule-ai/molecule-core#380