All vulnerabilities
GHSA-44pm-2q5p-fm63

github.com/rclone/rclone: Path traversal via an unvalidated `..` in `serve webdav` MKCOL, enabling unauthenticated write above the served root

github.com/rclone/rclone
CVSS
7.5High
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in1.75.2Fixed versionGitHub
Published9 Oct 2026Updated 9 Oct 2026GitHub

What fixes it

  • 1.75.2github.com/rclone/rclone, goGitHubgo get github.com/rclone/rclone@v1.75.2

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

Summary

**Path Traversal (CWE-22) — relative path traversal via an unvalidated .. path component in rclone serve webdav.** MKCOL /.. plants a VFS directory node whose backend path is literally .., and from that moment every write routed through it is joined onto the Fs root by the backend and resolves *above* the directory rclone was told to serve. Two unauthenticated HTTP requests are enough.

MKCOL  /..                    -> 201 Created
PUT    /../E1.txt             -> 201 Created     (file created above the served root)
PUT    /../OUTSIDE-SECRET.txt -> 201 Created     (pre-existing file above the root overwritten)
MKCOL  /../D1                 -> 201 Created     (directory created above the served root)

serve webdav is the one front-end in cmd/serve/ where this works, and that is the point of the report rather than an aside. serve restic, serve s3 and serve docker each carry a path guard you wrote — and serve ftp, serve sftp and serve nfs are confined by their *third-party* libraries, not by rclone code. serve webdav has neither: golang.org/x/net/webdav passes the request path through uncleaned, and cmd/serve/webdav/webdav.go adds no check.

Details

**1. x/net/webdav does not clean the request path.** In golang.org/x/net v0.57.0, Handler.stripPrefix (webdav/webdav.go:52-60) returns the path verbatim. That package *has* a slashClean, but it is used only by its own Dir and memFS implementations and by the lock/prop code (webdav/file.go:85,178,353-354, lock.go:154-159,236, prop.go:367) — never on the way out to a caller-supplied FileSystem. rclone adds no cleaning either: cmd/serve/webdav/webdav.go:311-318 mounts the handler with router.Handle("/*", w) on a bare chi.NewRouter(), and lib/http/server.go installs no middleware.CleanPath. So MKCOL /.. arrives at WebDAV.Mkdir as name = "/..".

**2. cmd/serve/webdav has no path guard at all.** WebDAV.Mkdir (webdav.go:518-530) is four lines and none of them is a check:

	dir, leaf, err := VFS.StatParent(name)
	if err != nil {
		return err
	}
	_, err = dir.Mkdir(leaf)

**3. VFS.StatParent splits ".." into ("", "..").** vfs/vfs.go:517-529 does path.Split and then vfs.Stat(parent); with parent == "" that returns the root *Dir and hands ".." through as the leaf. VFS.Stat itself (vfs.go:489-514) walks components and calls dir.Stat(name) per segment, so a .. in the middle of a path is looked up as a *literal name* and misses — which is why PUT /../x on its own fails, and why the MKCOL has to come first.

**4. Dir.Mkdir joins from an empty root, which is the one case where path.Join preserves ...** vfs/dir.go:1056-1060:

func (d *Dir) Mkdir(name string) (*Dir, error) {
	if d.vfs.Opt.ReadOnly {
		return nil, EROFS
	}
	path := path.Join(d.path, name)

For the VFS root d.path is "", so path.Join("", "..") is "..". (At any deeper directory path.Join("a", "..") collapses to ".", which is why the escape is exactly one level and re-rooted at the served directory's parent.) The backend Mkdir is called with "..", the new node is added to the root's children with d.path == "..", and every child path built under it from then on is "../<leaf>" — Dir.Create at dir.go:1025,1052, Dir.Rename at dir.go:1173-1174, File.Path at file.go:148.

**5. The backend resolves it, and the MKCOL succeeds on a no-op.** backend/ftp/ftp.go:1090-1093 is representative:

func (f *Fs) Mkdir(ctx context.Context, dir string) (err error) {
	root := path.Join(f.root, dir)
	return f.mkdir(ctx, root)
}

With f.root == "confined", path.Join("confined", "..") is ".", and f.mkdir returns nil immediately for "." (ftp.go:1045-1047). So the backend is never asked to do anything and the poisoned node is installed on the strength of a success that did not happen. The subsequent PUT then writes path.Join("confined", "../E1.txt") = "E1.txt", above the served root.

The precondition is the one you wrote yourselves in cc5a189f0, the fix for GHSA-45pq-889g-fcgh: *"Whether a path then escaped depended on the backend: those which join the root with the remote before encoding it — webdav, ftp, sftp, http and memory — resolved the .. away, while local and s3 encode the dot elements first and were unaffected."* We verified both halves of that sentence by execution — see *What this is not*.

The node is ephemeral, and it does not matter. It lives for --dir-cache-time (default 5 minutes) and is re-planted by one request. Measured at --dir-cache-time 1s:

MKCOL /.. then PUT immediately   : 201 201
PUT alone, cache expired         : 409
MKCOL /.. again then PUT         : 201 201

So the attack is always MKCOL /.. immediately followed by the write, and there is no window to miss.

What the client can do above the served root. One planted node, one process, one backend remote; every row below is a run:

| operation | verb | result | effect | |---|---|---|---| | plant the node | MKCOL /.. | 201 | backend Mkdir(".") is a no-op; node installed | | create a file | PUT /../M.txt | 201 | new object above the served root | | overwrite an existing file | PUT /../OUTSIDE.txt | 201 | content replaced with attacker bytes | | create a directory | MKCOL /../tenant-c | 201 | new directory above the served root | | open an existing sibling | MKCOL /../tenant-b | 201 | node for another tenant's directory installed | | write into that sibling | PUT /../tenant-b/b.txt | 201 | another tenant's file overwritten | | move a file out | MOVE → /../M.txt | 201 | file leaves the served tree entirely | | copy a file out | COPY → /../C.txt | 201 | copy created above the served root | | move a directory out | MOVE /dir1 → /../D.dir | 201 | whole subtree, contents included, leaves the served tree | | delete something the client planted | DELETE /../P.txt | 204 | removed | | delete an unseen file above the root | DELETE /../OUTSIDE.txt | 404 | refused — the name is not in the filtered listing | | …the same file, after one PUT | PUT then DELETE | 201 / 204 | arbitrary deletion by known name | | set a modification time above the root | PROPPATCH /../M.txt | 207 | accepted | | read an unseen file above the root | GET /../OUTSIDE.txt | 404 | refused | | enumerate above the root | PROPFIND /.. Depth 1 | / only | refused |

The read half is closed, and it is closed by your own August fix. Dir.stat(name) resolves only against the cached listing, vfs/dir.go:531 reads that listing through list.DirSorted, and fs/list/list.go:136 runs every entry through RemoveEscaping. Each entry under the .. node has a Remote that RemoteEscapesRoot rejects, so the node reads as empty and a GET or DELETE of anything the client did not itself write comes back 404. RemoteEscapesRoot is the correct predicate and it already exists in the tree — nothing on the write path asks it.

PoC

Released binary v1.75.1 (rclone-v1.75.1-osx-arm64.zip, sha256 bfe4a146980f07de56e7dc0115cf562aaad5c81fd10ef9b0f36f777eba0ab031). Also reproduced on a source build of master at 812e693fd (v1.76.0-DEV) — git diff v1.75.1..master over vfs/vfs.go, vfs/dir.go, cmd/serve/webdav/webdav.go and fs/list/list.go is two files and eight lines, neither touching confinement, and the master run is below rather than assumed.

Two local processes, no real remote and no real credentials. The *storage* is a second rclone serving FTP over a throwaway tree — it stands in for any third-party FTP server and is not part of the finding; note that the .. is resolved client-side by path.Join inside the ftp backend, so the storage server never sees a .. and its own defences are irrelevant.

# fixture
#   store/OUTSIDE-SECRET.txt     "OUTSIDE-SECRET-a91f3c"   <- above the served root
#   store/confined/inside.txt    "inside-ok"

rclone serve ftp    --addr 127.0.0.1:2121 --user labuser --pass labpass123 store   # the storage
rclone serve webdav --addr 127.0.0.1:8180 labftp:confined                          # under test

Controls first — the served root holds one file and the secret above it is unreachable:

C0 served root sees        : inside.txt
C1 PROPFIND /              : / /inside.txt
C2 GET /inside.txt         : 200
R0a GET /../OUTSIDE        : 404   (no .. node yet)

The escape:

W1 MKCOL /..               : 201
R0b GET /../OUTSIDE        : 404   (.. node planted, nothing written yet)
R0c body                   : Not Found
W2 PUT /../E1.txt          : 201
W3 MKCOL /../D1            : 201
W4 PUT  /../OUTSIDE-SECRET.txt (overwrite) : 201
R1 PROPFIND /.. Depth 1    : /
D1 DELETE /../E1.txt       : 204

$ find store
store/confined/inside.txt
store/D1                   <- directory created above the served root
store/OUTSIDE-SECRET.txt
$ cat store/OUTSIDE-SECRET.txt
CLOBBERED                  <- was "OUTSIDE-SECRET-a91f3c"

The cross-front-end control, which is the part we would most like you to read. Every front-end was driven against the same backend remote, from the same binary, against the same fixture, with default flags, in one scripted run — so no row can differ from another by configuration:

ftp  C1 LIST /             : inside.txt
ftp  W1 MKD ..             : < 200 Type set to ASCII < 226 Closing data connection
ftp  W3 STOR ../E2.txt     : < 200 Type set to binary > STOR E2.txt < 226 OK, received 4 bytes
ftp  state                 : confined/E2.txt confined/inside.txt OUTSIDE-SECRET.txt

sftp C1 ReadDir /          : [E2.txt inside.txt] err=<nil>
sftp W  Mkdir ".."         : err=<nil>
sftp W  Mkdir "/../D9"     : err=<nil>
sftp W  Create "/../E9.txt": err=<nil>
sftp W  Create "../E9b.txt": err=<nil>
sftp R  Open /../OUTSIDE…  : "" err=file does not exist
sftp state                 : confined/D9 confined/E9.txt confined/E9b.txt confined/inside.txt OUTSIDE-SECRET.txt

http C1 GET /inside.txt    : 200
http R1 GET /../OUTSIDE    : 404
http R2 GET /..%2fOUTSIDE  : 404

restic C1 POST /config     : 200
restic W1 POST /../E3.txt  : 400
restic W2 POST /../config  : 400
restic R1 GET /../OUTSIDE  : 400

s3  C1 GET /confined/inside.txt     : 200
s3  C2 PUT /confined/ctl.txt        : 200
s3  T1 PUT /confined/../S3ESC.txt   : 400
s3  T2 PUT /confined/..%2fS3ESC2    : 400

Read the ftp W3 line closely: the client asked for ../E2.txt, Session.buildPath collapsed it, and the driver was handed STOR E2.txt. That is your confinement being *observed working*, on the wire, in the same run in which webdav's is absent. The sftp rows come from a raw pkg/sftp client written for this, not from the sftp CLI, specifically so the client could not be the thing normalising the path; all five escape attempts land inside confined/. Every front-end has its own C1 control proving it was live and serving.

The multi-tenant reading. Same mechanism, the deployment that makes it matter — tenant A is served tenant-a/ and nothing else:

tree before : admin.conf  tenant-a/a.txt  tenant-b/b.txt

C1  PROPFIND / (tenant A's whole scope) : / /a.txt
C2  GET /../tenant-b/b.txt              : 404    <- control: before the plant, A cannot touch B
T1  MKCOL /..                           : 201
T2  MKCOL /../tenant-b                  : 201    (already exists)
T3  PUT /../tenant-b/b.txt              : 201
T4  PUT /../admin.conf                  : 201
T5  MKCOL /../tenant-c                  : 201
T6  PUT /../tenant-c/planted.txt        : 201

tree after  : admin.conf  tenant-a/a.txt  tenant-b/b.txt  tenant-c/planted.txt
tenant-b/b.txt : TENANT-A-CLOBBERED-TENANT-B
admin.conf     : ADMIN-CONFIG-REPLACED

--read-only is a complete mitigation, checked rather than assumed:

MKCOL /.. (--read-only)   : 405
PUT /../ro.txt            : 409
files above the root      : 0

On master (812e693fd):

C1 GET /inside.txt  : 200
T1 MKCOL /..        : 201
T2 PUT /../MAIN.txt : 201
T3 PUT overwrite    : 201
OUTSIDE content     : CLOB

Affected range

**<= 1.75.1.** Established by running the probe against seven separately downloaded released binaries, not by reading a changelog. Each row is a fresh fixture, a fresh storage server and a fresh front-end process, with the control re-verified before the verdict was recorded.

| version | webdav MKCOL /.. + PUT | webdav read of an untouched file above the root | restic POST /../x | controls | |---|---|---|---|---| | v1.71.0 | OPEN mkcol=201 put=201 | OPEN 200, secret read | OPEN post=200 | dav=200 restic=200 | | v1.72.0 | OPEN mkcol=201 put=201 | OPEN 200, secret read | OPEN post=200 | dav=200 restic=200 | | v1.73.0 | OPEN mkcol=201 put=201 | OPEN 200, secret read | OPEN post=200 | dav=200 restic=200 | | v1.74.0 | OPEN mkcol=201 put=201 | OPEN 200, secret read | OPEN post=200 | dav=200 restic=200 | | v1.74.4 | OPEN mkcol=201 put=201 | OPEN 200, secret read | OPEN post=200 | dav=200 restic=200 | | v1.75.0 | OPEN mkcol=201 put=201 | OPEN 200, secret read | closed post=400 | dav=200 restic=200 | | v1.75.1 | OPEN mkcol=201 put=201 | closed 404 | closed post=400 | dav=200 restic=200 |

Two columns flip, both exactly at one of your published fix boundaries, and the harness found both without being told where they were. The restic column flips at v1.75.0, the first release containing cc5a189f0 — the fix for GHSA-45pq-889g-fcgh / CVE-2026-71309. The webdav read column flips at v1.75.1, the first and only release containing 57842c5ee — the fix for GHSA-3vxh-3pcx-9m8q / GHSA-38xv-hf3p-h7mq. Both were confirmed independently afterwards with git tag --contains. The webdav write column does not flip anywhere. We offer that as the reason to trust the range rather than as decoration: the same instrument that rediscovered two boundaries you had already set found none on the write path.

v1.71.0 is the oldest release we ran and it is open. We have not established a lower bound and do not claim one — the mechanism is long-standing and the true range is very likely older, so <= 1.75.1 is the range we are willing to state. Per SECURITY.md only the latest release is supported and this reproduces on it.

Three configuration preconditions, none of them exotic and none requiring a non-default flag: rclone serve webdav is running; the served remote is a backend that joins root and remote before encoding (webdav, ftp, sftp, http, memory per your own enumeration — not local, not s3); and the served remote has a non-empty root (remote:subdir, not remote:), because with an empty root there is nothing above it inside the backend's namespace. Authentication is not a precondition, because rclone serve webdav has none by default.

Impact

An unauthenticated client of a rclone serve webdav listener gets file creation, file overwrite, directory creation, MOVE and COPY of data out of the served tree, subtree MOVE, modification-time change, and — after a single overwrite — deletion, at any path above the directory rclone was configured to serve, up to the top of the backend's own namespace.

The deployment where this is worst is the one serve webdav is most often used for: one subdirectory per tenant or per user under a shared remote. A client confined to tenant-a/ overwrites tenant-b/b.txt and admin.conf and plants tenant-c/, as shown above. Sibling names need not be guessed in that layout; they are the tenant identifiers.

There is no confidentiality impact at v1.75.1, and we want to be explicit about it. Reads and enumeration above the served root are refused, because RemoveEscaping filters the listing the VFS lookup depends on. A GET that succeeds returns the client's own bytes from a prior overwrite, not the original content. On v1.75.0 and earlier the read was open too, and the sweep above shows exactly that — but the report is scoped to the current release, where this is an integrity and availability finding only.

Fix

Finished code, not a diff. The narrow fix is the guard serve restic already has, in the one front-end that lacks one. In cmd/serve/webdav/webdav.go, with iofs "io/fs" added to the imports:

// Mkdir creates a directory
func (w *WebDAV) Mkdir(ctx context.Context, name string, perm os.FileMode) (err error) {
	VFS, err := w.getVFS(ctx)
	if err != nil {
		return err
	}
	if err := checkPath(name); err != nil {
		return err
	}
	dir, leaf, err := VFS.StatParent(name)
	if err != nil {
		return err
	}
	_, err = dir.Mkdir(leaf)
	return err
}

// checkPath rejects a request path which is not a canonical relative path free
// of "." and ".." elements. The backends join the path with the Fs root, so
// such elements would otherwise address objects outside it.
//
// The empty path is the root of the collection so is allowed. "." is not a
// valid name here, even though iofs.ValidPath accepts it as the root of an FS.
func checkPath(name string) error {
	name = strings.Trim(name, "/")
	if name == "" {
		return nil
	}
	if name == "." || !iofs.ValidPath(name) {
		return os.ErrInvalid
	}
	return nil
}

and the same two-line checkPath call at the top of OpenFile, RemoveAll, Stat and both arguments of Rename — every place in that file that takes a client-supplied name. Returning a non-IsNotExist error there gets x/net/webdav's default mapping, which for MKCOL and PUT is 405 (webdav.go:332-335); if you would rather mirror serve restic's 400 you can intercept it in ServeHTTP before the handler runs, which is also where a single check would cover every verb at once.

The chokepoint alternative, which we raise in the same breath rather than filing separately. lib/sanitize.Leaf already refuses "", ".", ".." and anything containing / — exactly the check the VFS needs on a leaf name. Calling it at the top of Dir.Mkdir, Dir.Create and Dir.Rename in vfs/dir.go:

// Mkdir creates a new directory
func (d *Dir) Mkdir(name string) (*Dir, error) {
	if d.vfs.Opt.ReadOnly {
		return nil, EROFS
	}
	if err := sanitize.Leaf(name); err != nil {
		fs.Errorf(d, "Dir.Mkdir refusing unsafe name: %v", err)
		return nil, EINVAL
	}
	path := path.Join(d.path, name)
	...

closes the cell for every current and future front-end at once, and removes rclone's dependence on pkg/sftp, goftp.io/server/v2 and go-nfs continuing to clean paths on its behalf.

The trade-off, stated rather than buried. That second change touches the VFS write path, which every serve front-end and rclone mount share, so it is a minor-version change rather than a patch — a name that today reaches a backend as .. and is simply an odd directory would start returning EINVAL. The narrow fix is a patch and closes this report. We think the narrow fix should ship now and the chokepoint should follow, and we are flagging it because the pattern argues for it: this is the fifth path-confinement fix on cmd/serve/ in four months — serve s3 (83d1e62aa, GHSA-8v25), serve restic twice (CVE-2026-59733, then cc5a189f0), serve docker (f376c6478, GHSA-p6vx) — each written against the front-end that was reported, and none generalised. Three of the ten front-ends that are currently safe are safe because of a dependency, not because of rclone code, so a go.mod update can reopen them with no rclone change at all.

Severity

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N — 7.5 High.

Metric by metric. **AV:N** — plain HTTP to the WebDAV listener, your value on every serve advisory. **AC:L** — two requests, no race, no window to hit, and no knowledge needed at all for the create / MKCOL / MOVE-out half; your value on GHSA-8v25 for the same class. The node expiring with the directory cache is not an access-complexity factor, because re-planting is one request and was measured to work. **PR:N** — rclone serve webdav has no authentication by default; your value on GHSA-8v25 and GHSA-xwwr-4h3p-r22c. **UI:N — no victim action of any kind. S:U** — your value on both serve restic advisories and on GHSA-8v25. **I:H** — arbitrary creation, arbitrary overwrite by known name, directory creation, MOVE/COPY of data out of the served tree, subtree MOVE, and deletion after one overwrite, at any path above the served root; in the per-tenant layout that is another tenant's data, and I:H is your own value for cross-tenant overwrite on CVE-2026-59733.

**C:N, and we want to defend it properly because it is the metric that lowers this score. Reads and enumeration above the served root are refused** at v1.75.1. Our own first run reported GET /../OUTSIDE-SECRET.txt → 200 and we nearly wrote that down; re-ordering the probes showed it was an artefact of probe ordering — the 200 only appears *after* a PUT has already overwritten that file, so what comes back is the attacker's own bytes. With the read issued before any write, both before and after the node is planted, it is 404 and 404 with body Not Found. So C:N, not C:L and not C:H.

Two higher readings, named and declined. Deletion of another tenant's data is demonstrated (PUT then DELETE → 201 / 204, file gone) and you scored A:H for exactly that on CVE-2026-59733; with A:H the vector is CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H = 9.1 Critical. And the escape does cross from the root rclone was configured to serve into the rest of the storage namespace, which is the argument for S:C — you used S:C on GHSA-66hp for an archive escaping its own namespace; that gives 7.9. We are not proposing either. Your nearest-class advisory, GHSA-45pq-889g-fcgh / CVE-2026-71309 — *"Incomplete path validation allows backend root escape in serve restic"*, the same class in the same front-end family by the same mechanism — is high, and consistency with your own scoring is worth more to us than a higher number. If you conclude that A:H or S:C is right, that is your call to make and we will not dispute it.

The one reading we would argue against is I:L. GHSA-8v25 was scored C:L/I:L for *root-level files* in one bucket; this reaches every path above the served root, including other tenants' data, which is the I:H situation you already recognised on CVE-2026-59733.

What this is not

Not a default-configuration hole on every backend. serve webdav over a local backend does not escape, and the status codes lie about it — all requests return 201 and the verdict has to come from the filesystem. The file landed at confined/../A-ESC.txt: the local backend's EncodeDot had rendered .. as two U+FF0E characters and confined the write. We also confirmed the other direction, so the class is backend-family-wide rather than an ftp quirk — front-end over the webdav backend, same two requests, B-ESC.txt above the served root. Your sentence in cc5a189f0 about which backends are affected is accurate in both directions, and the range above carries the precondition.

**Not a second open cell in serve nfs, and we are not claiming one.** willscott/go-nfs v0.0.4 validates . and .. in MKDIR only (nfs_onmkdir.go:40); CREATE, RENAME, REMOVE, SYMLINK, LINK and MKNOD check only PathNameMax and then fs.Join(path..., Filename). So the directory *plant* this whole finding depends on is blocked there. But nfs_onrename.go:77's toLoc with a .. leaf would reach VFS.Rename and then operations.DirMove with a destination outside the root, and we did not drive it — a real NFS client needs root on the host we tested from. We record it as an untested residual with a named next step (a go-nfs-client MKDIR / CREATE / RENAME with a .. filename) so you are not reading "nfs is fine" into a row we did not run.

**serve dlna and serve docker were read, not run.** dlna is read-only and cleans in objectFromID (cds.go:268-272); docker has volumeMountPath and is already the subject of GHSA-p6vx-hf7p-98j6. Neither needs action from this report and neither was executed, so neither appears as a claim.

**Not a flaw in golang.org/x/net/webdav.** Its Handler is documented to hand the path to the FileSystem implementation, and its own Dir and memFS both call slashClean before touching anything. The missing cleaning is rclone's to add. The reason this is a matrix with one open cell rather than a library-wide problem is the reverse of the same observation: serve ftp, serve sftp and serve nfs are closed by goftp.io/server/v2's buildPath (session.go:275-286), pkg/sftp's cleanPath (request-server.go:345-355, applied at request.go:163) and go-nfs's nfs_onmkdir.go:40 respectively — three third-party libraries, none of them rclone.

Not reachable through the rc. We did not test the rc API and are not reporting anything about it, per clause 3 of SECURITY.md and the disposition on the rc security page. We note it only because rclone rc serve/start would have been the natural way to get several front-ends into one process for the control above, and we deliberately did not use it — standing up something documented as shell-equivalent inside the process under test would have contaminated the claim that this needs no privilege. The cross-front-end control was built from one script, one binary and one backend remote instead.

Dedup

We could find no published advisory of yours covering serve webdav. The four path-confinement advisories on the serve front-ends are GHSA-fqj9-69pf-6pjg and GHSA-45pq-889g-fcgh (serve restic), GHSA-8v25-v8p6-qf7v (serve s3) and GHSA-p6vx-hf7p-98j6 (serve docker); GHSA-3vxh-3pcx-9m8q and GHSA-38xv-hf3p-h7mq are the backend *listing* side, i.e. a malicious server's names reaching a client, which is the opposite direction to this; and GHSA-66hp-wgxq-6f5q is archive entry names. This is a WebDAV client writing *into* the server, through the VFS, and we believe none of those cover it.

We should say plainly that the duplication risk here is real rather than nominal: you have published four fixes in this exact class between 2026-06-29 and 2026-08-20, so someone is clearly working the same axis, and a private report already in your queue would be invisible from outside. If this duplicates something you are already carrying, please say so and close it — we would rather know than have you handle it twice.

Recorded, not claimed

Three adjacent hunts came back negative and we report them so you need not repeat them.

The symlink route does not give a read above the root. With --links, a planted .. node and a .rclonelink whose target is ../OUTSIDE-SECRET.txt (or the absolute /OUTSIDE-SECRET.txt), GET returns 404 for both. File.resolveNode (vfs/file.go:746-800) joins, cleans and then resolves through vfs.Stat, so the target lands back on the same filtered lookup and an absolute target is trimmed and re-rooted at the served directory. That conclusion is only sayable because of the control: our first attempt had no working positive control — a symlink to a file *inside* the root also returned 404, because the .rclonelink translation happens at listing time — and with --dir-cache-time 1s and a refresh the control came alive at 200 inside-ok while both escaping targets stayed refused.

The archive matrix is full. Only two archivers are registered (.zip at zip.go:29, .sqfs at squashfs.go:27) and all three name-handling sites call the helper: zip.go:142 (sanitize.Path), squashfs.go:245 (sanitize.Leaf), cmd/archive/extract/extract.go:216 (sanitize.Path). Run against the released binary with a zip built for the purpose — entries control.txt, ../ESCAPED-ZIP.txt, ..\ESCAPED-BACKSLASH.txt, sub/../../ESCAPED-INTERIOR.txt, ./dot.txt — control.txt extracted and then extraction aborted: refusing to extract archive entry: path "../ESCAPED-ZIP.txt" has a ".." component. Nothing above the destination. Failing closed on the first bad entry is the stronger behaviour and we note it as such.

**serve s3 and serve restic hold, and the serve s3 guard is the one that got the anchoring right first.** canonicalKey (utils.go:96) anchors with a leading / before cleaning, which is exactly the property CVE-2026-71309 later found missing from restic's version of the same test; four probe forms (../, ..%2f, on PUT and on GET) all return 400 with controls at 200.

Text from GitHub Advisory Database

Affected versions 1

  • github.com/rclone/rclone, go<= 1.75.1GitHub