Summary

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

Advisory 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:

// 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):

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

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. serveObjects.newObject(ctx, remote)s.f.NewObject(ctx, remote), postObjectoperations.RcatSize(..., remote, ...), and deleteObjecto.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:

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:

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"

	

References