A published root is supposed to be a boundary. A client authorized to access backups/team-a should not be able to reach backups/team-b, even when the storage credential used by the server can access both locations. CVE-2026-71309 violated that expectation in rclone's restic-compatible REST server.
In affected versions, a URL path beginning with ../ could traverse above the root configured by the operator. Depending on the backend, a request to the REST endpoint could read, create, overwrite, or delete sibling objects and objects in parent directories. The validation defect was in shared server middleware, while concrete exploitation depended on how the selected backend combined its root with the supplied remote path.
The vulnerability is classified as CWE-22, with High severity and a CVSS v4.0 score of 8.6. rclone versions 1.40.0 through 1.74.4 are affected, and the fix is available in version 1.75.0. These details are recorded in the official GHSA-45pq-889g-fcgh advisory.
Exploitation requires a reachable REST endpoint, a backend subdirectory published as the service root, and backend credentials that can access at least some parent or sibling objects. The attacker must also be able to make requests to the server; this is represented as low privileges in the published CVSS vector. No victim interaction, race condition, or unpredictable target-specific condition is required.
The vulnerability does not grant the rclone process new permissions in the backend. Instead, it allows a client to cross the logical boundary that the operator attempted to create by publishing only a subdirectory. The final reach remains constrained by the backend credential and by the backend's path semantics.
What rclone serve restic Publishes
The rclone serve restic command exposes a REST API compatible with the protocol used by restic on top of an rclone filesystem. That filesystem can point to an entire backend or to a subdirectory. For example:
rclone serve restic webdav:backups/team-a
In this configuration, backups/team-a is the root presented by the service. Even if the WebDAV credential can see backups/team-b, an API client should remain confined to the published root. The server must enforce that property before it transforms a URL path into a backend object name.
This separation matters when one storage account supports multiple repositories, customers, or automation jobs. Endpoint authentication answers “who may call the API?” The published root answers a separate question: “which objects may that client reach?” Authenticating a client does not compensate for a failure in the second boundary.
Root Cause: Canonicalization Is Not Containment Validation
The vulnerable component was the backend-independent WithRemote middleware in cmd/serve/restic/restic.go. It obtained the request path, trimmed surrounding slashes, and attempted to reject non-canonical paths by comparing the input with the result of path.Clean:
urlpath = strings.Trim(urlpath, "/")
if urlpath != "" && path.Clean(urlpath) != urlpath {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
The intent appears reasonable: if cleaning changes the text, the path contains a redundant or potentially dangerous construct. However, path.Clean normalizes a path; it does not certify that a relative path will remain under a root that is joined later.
Consider the following results:
path.Clean("../outside.txt") = "../outside.txt"
path.Clean("../../outside.txt") = "../../outside.txt"
path.Clean("a/../../outside.txt") = "../outside.txt"
In the first two examples, the parent component is already at the beginning of the relative path. There is no preceding directory for the function to eliminate, so path.Clean correctly preserves .. for its normalization purpose. Because input and output are equal, the vulnerable condition accepts the request.
The third path behaves differently. Cleaning removes a/.. and changes the string, causing the comparison to detect the difference and return HTTP 400. This partial behavior can make the flaw less obvious: a test using a/../../file appears to confirm that traversal is blocked, while the more direct ../file variant passes through the same check.
The conceptual error is treating equality after canonicalization as a validity rule. A canonical path can still be forbidden. ../outside.txt is a canonical representation of a relative path that moves one level upward, but it is not an acceptable object name when policy requires confinement below a published root.
From the URL to the Backend
After accepting the path, WithRemote stored it in the request context:
ctx := context.WithValue(r.Context(), ContextRemoteKey, urlpath)
next.ServeHTTP(w, r.WithContext(ctx))
Downstream handlers trusted that value. Read operations resolved the remote through NewObject; uploads sent with POST reached operations.RcatSize; DELETE resolved the object and called Remove. There was no second shared containment check before the object name reached the backend.
The resulting data flow was straightforward:
URL containing %2e%2e/file
↓ URL decoding
../file
↓ WithRemote accepts and stores it in context
GET, HEAD, POST, or DELETE handler
↓
backend combines its root with ../file
↓
operation potentially lands outside the published root
The %2e%2e encoding is not a separate vulnerability variant. It represents .. in the URL and makes it possible to observe behavior after the HTTP layer decodes the path. Clients and proxies may normalize paths before sending them, so a controlled reproduction must preserve the original URL representation.
Why Backend Behavior Determines the Result
The defective check was in common server code, but backends did not treat the accepted remote identically. Some joined the backend root and remote with path.Join before encoding the resulting name for the storage protocol. In those cases, .. removed the published subdirectory. Other backends encoded special components as filename characters, which prevented root escape in the tested configuration.
For WebDAV, the relevant construction was equivalent to:
subPath := path.Join(f.root, file)
Using concrete values:
f.root = "served-root"
file = "../outside-secret.txt"
path.Join("served-root", "../outside-secret.txt")
= "outside-secret.txt"
By the time the request reached the WebDAV server, it looked like an ordinary operation on outside-secret.txt. The remote storage server had no knowledge that rclone intended to confine the client to served-root.
The published tests confirmed out-of-root read, write, and delete operations with WebDAV, FTP, Memory, and SFTP. The HTTP backend is read-only, and an external read was confirmed there. In each affected case, the unsafe path accepted by the server reached an implementation that preserved or resolved the parent component in a way that could cross the published subdirectory.
No root escape occurred in the tested S3-compatible and local-backend configurations. Those implementations encoded .. as part of the object name instead of interpreting it as navigation to the parent directory.
“No root escape observed” is not a universal security guarantee for every configuration, version, and encoding mode of a backend. It means that the analyzed technique did not cross the root in the published tests. Conversely, the shared WithRemote flaw does not automatically prove exploitability against every backend supported by rclone.
Preconditions and Impact Scenarios
Practical risk appears when the service runs a version earlier than 1.75.0, publishes a subdirectory without isolating the credential at precisely that root, accepts requests from a potentially untrusted client, and uses a backend whose path semantics resolve the input in an affected way. The credential must also have permission to parent or sibling objects; the vulnerability does not expand the permissions granted by the storage system.
A multi-user layout illustrates the boundary failure:
backups/
├── customer-a/
├── customer-b/
└── restore-configuration.json
If the service publishes only backups/customer-a, a request for ../customer-b/... may cross the separation between customers. A read compromises the confidentiality of snapshots, indexes, or metadata. A POST may create or overwrite an object consumed by another job. A DELETE may remove external objects when both the credential and backend allow it.
Impact does not necessarily stop at storage. Another system may later interpret an overwritten object as configuration, a script, a manifest, or a restoration artifact. That chain is environment-specific: the CVE permits unauthorized modification of the object, while later execution or trust depends on another component.
The --append-only option reduces some overwrite and deletion risk but does not repair validation. The advisory notes that traversal reads and out-of-root object creation can remain possible, along with specific deletion scenarios involving lock paths. It is a partial impact reduction, not remediation.
How Exploitation Manifested
On a vulnerable installation, a request could preserve the parent component in the URL by using its encoded form. A request equivalent to the example below attempted to retrieve outside-secret.txt, located one level above the published root:
GET /%2e%2e/outside-secret.txt HTTP/1.1
Host: restic.example
Authorization: Basic …
After decoding, the server worked with ../outside-secret.txt. On WebDAV, joining that value to served-root produced outside-secret.txt, so the backend received an ordinary read outside the intended subdirectory. Applying the same idea to POST or DELETE could create, overwrite, or remove objects when the backend and credential allowed those operations.
Correct behavior is to reject the path with HTTP 400 before invoking the backend. The difference between vulnerable and fixed versions is not the final appearance of the storage operation, but the point at which the path stops being accepted as a valid object name.
How the Fix Works
The fix ships in rclone 1.75.0. It replaced the path.Clean comparison with an explicit validity rule based on io/fs.ValidPath. Instead of asking whether cleaning changed the text, the server now asks whether the path is a valid relative name at all before it reaches any backend.
ValidPath accepts . as the special representation of a filesystem root. For every other value, it requires an unrooted relative path encoded as valid UTF-8 with no empty, ., or .. components. The server handles its two special cases explicitly: it preserves the empty path as the legitimate API root and rejects . as an object name.
The change fixes the issue at the shared boundary before any backend sees the remote. This placement matters. Compensating independently in every backend would invite inconsistent behavior, vulnerable new implementations, and forgotten operations.
The release also added regression coverage for GET, HEAD, POST, and DELETE, exercising inputs such as .., ../, ../../, ../outside-secret.txt, %2e%2e/outside-secret.txt, interior traversal forms, ., and ./inside.txt against a backend that resolves parent components when joining paths, and verifying both the HTTP 400 response and the integrity of external objects.
Remediation and Defense in Depth
The primary fix is to upgrade to rclone 1.75.0 or later. Restart the relevant processes and confirm the version actually loaded. Immutable images, hosts with multiple installations, and services retaining an old binary in memory can cause an apparently completed update to miss the active instance.
If an immediate update is impossible, exposure can be reduced by restricting the endpoint to the networks, identities, and jobs that strictly require it. The backend credential should begin exactly at the published root, and separate customers or repositories should use different shares, buckets, or accounts. Write and delete permissions should also be removed when a workflow requires reads only.
These measures constrain potential reach but do not repair validation. Authentication, a reverse proxy, and --append-only must not be treated as substitutes for upgrading. Logs of anomalous paths and operations against sibling objects may help an investigation, although the absence of those records does not prove that exploitation never occurred.
A Web Application Firewall can reject known path patterns as an emergency measure, but normalization differences between proxy and application make such a rule fragile. The definitive control belongs in the component that interprets the path, and it is already available in the patched release.
Conclusion
CVE-2026-71309 illustrates the critical difference between cleaning a path and validating a boundary. path.Clean produced a canonical representation but preserved leading parent components. The equality check interpreted that stability as safety. Once accepted, the value was trusted by handlers and backends, and some implementations removed the published root when joining paths.
The result could break isolation between subdirectories and permit reads or modifications of external objects within the backend credential's reach. The fix centralizes an explicit validity rule with io/fs.ValidPath and covers the principal methods and path variants with regression tests.
Operators should upgrade to version 1.75.0 or later, verify the binary actually running, and review whether storage credentials enforce the same boundary that the API is intended to publish. Credential isolation and least privilege remain valuable, but they complement the software fix rather than replace it.