Files
vuln-osv/data/Go/GHSA-49h3-cwhj-4737.json
T

1 line
9.3 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"schema_version":"1.9.0","id":"GHSA-49h3-cwhj-4737","published":"2026-07-24T20:41:16Z","modified":"2026-08-18T15:10:37.942812212Z","aliases":["CVE-2026-55495","GO-2026-6097"],"summary":"Cloudreve: Path Traversal in WOPI PUT_RELATIVE Allows Arbitrary File Creation in Owner Account","details":"## Summary\n \nCloudreve's WOPI `PUT_RELATIVE` handler treats `X-WOPI-SuggestedTarget` as a path, not a filename. It splits the header on `/` and joins the segments onto the source file's directory with `URI.JoinRaw`, which feeds Go's `url.JoinPath`. `url.JoinPath` resolves `.`/`..` segments, so a slash-bearing target such as `a/../../evil.docx` collapses to a location outside the source file's directory. The lower-level upload path then validates only the final, already-cleaned basename (`evil.docx`), which is harmless, and checks ownership against the *resolved ancestor* — which is still the same user's drive.\n \nA WOPI access token is bound to exactly one file (the route enforces `fileId == session.FileID` with a 403 otherwise). `PUT_RELATIVE` escapes that per-file scope: a token issued for one file can create (and, conditionally, overwrite) files elsewhere in the same account.\n\n## Root cause (verified at `26b6b10`)\n \n**1. Token is single-file scoped (the boundary being escaped)** — `middleware` `ViewerSessionValidation`:\n \n```go\nfileId := hashid.FromContext(c)\nif fileId != session.FileID { // 403 — token is bound to ONE file\n c.Status(http.StatusForbidden); c.Abort(); return\n}\n```\n \nRoute: `wopi := noAuth.Group(\"file/wopi\", middleware.HashID(hashid.FileID), middleware.ViewerSessionValidation())`; `wopi.POST(\":id\", controllers.ModifyFile)` → `POST /api/v4/file/wopi/:id?access_token=<token>`.\n \n**2. `PUT_RELATIVE` dispatch** — `routers/controllers/wopi.go`:\n \n```go\ncase wopi.MethodPutRelative: // X-WOPI-Override: PUT_RELATIVE\n err = service.PutContent(c, true)\n```\n \n**3. SuggestedTarget joined as a path** — `service/explorer/viewer.go`:\n \n```go\nfileName, _ := wopi.UTF7Decode(c.GetHeader(wopi.SuggestedTargetHeader)) // X-WOPI-SuggestedTarget\nfileUriParsed, _ := fs.NewUriFromString(fileUri)\nif strings.HasPrefix(fileName, \".\") { /* treat as extension */ }\nfileUri = fileUriParsed.DirUri().JoinRaw(fileName).String() // <-- path join, not basename\n...\nsubService := FileUpdateService{ Uri: fileUri }\nres, err := subService.PutContent(c, lockSession)\n```\n \n**4. `JoinRaw` splits on `/` and normalizes via `url.JoinPath`** — `pkg/filemanager/fs/uri.go`:\n \n```go\nfunc (u *URI) Join(elem ...string) *URI {\n newUrl, _ := url.Parse(u.U.String())\n return &URI{U: newUrl.JoinPath(/* PathEscape each elem */ ...)} // JoinPath cleans ./ and ../\n}\nfunc (u *URI) JoinRaw(elem string) *URI {\n return u.Join(strings.Split(strings.TrimPrefix(elem, Separator), Separator)...)\n}\n```\n \n`PathEscape` leaves `.` unescaped (it is in the unreserved set), so `..` segments survive into `JoinPath`, which resolves them. `URI.Name()` returns `path.Base(path.Clean(path))` — the cleaned basename.\n \n**5. Upload checks ownership of the resolved ancestor and validates only the clean basename** — `pkg/filemanager/fs/dbfs/upload.go`:\n \n```go\nancestor, err := f.getFileByPath(ctx, navigator, req.Props.Uri) // URI already traversal-normalized\n...\nif _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && ancestor.OwnerID() != f.user.ID {\n return nil, fs.ErrOwnerOnly // same-user -> passes\n}\n...\nif err := validateNewFile(req.Props.Uri.Name(), req.Props.Size, policy); err != nil { // checks \"evil.docx\" only\n return nil, err\n}\n```\n \n`validateFileName` rejects `/ \\ : * ? \" < > |` and bare `.`/`..` — but the traversal is already gone by the time it sees the basename.\n\n## Validation performed\n \nIndependent validation against commit `26b6b10` in a clean sandbox.\n \n**Source-verified (static):** the full chain confirmed verbatim — single-file-scoped token (403 on mismatch) → `PUT_RELATIVE` dispatch → `DirUri().JoinRaw(SuggestedTarget)` → `url.JoinPath` normalization → ancestor ownership check (same-user passes) → basename-only validation of the cleaned name.\n \n**Dynamic (control-flow executed):** the full binary is not buildable offline here (modules behind an unreachable Go proxy, embedded frontend, DB). I built and ran a harness using the **real Go `net/url` stdlib** plus the **verbatim** `Join`/`JoinRaw`/`DirUri`/`Path`/`Name`/`PathEscape`/`shouldEscape` and the `validateFileName` gate, driving the same transformation `PUT_RELATIVE` performs. Source = `cloudreve://my/folder/current.docx`:\n \n```\nSuggestedTarget resolved URI final basename validator\n\"copy.docx\" cloudreve://my/folder/copy.docx \"copy.docx\" ACCEPT\n\"a/../../evil.docx\" cloudreve://my/evil.docx \"evil.docx\" ACCEPT <- ESCAPED to /\n\"a/../../../top.docx\" cloudreve://my/top.docx \"top.docx\" ACCEPT <- ESCAPED to /\n\"sub/evil.docx\" cloudreve://my/folder/sub/evil.docx \"evil.docx\" ACCEPT <- different subdir\n\".pdf\" cloudreve://my/folder/current.pdf \"current.pdf\" ACCEPT\n\"a%2f..%2f..%2fenc.docx\" cloudreve://my/folder/a%252f..%252f.. \"a%2f..%2f..%2f\" ACCEPT (NO escape)\n```\n \nThe headline payload `a/../../evil.docx` deterministically resolves to `cloudreve://my/evil.docx` (account root) with a clean, accepted basename. Output matches the original audit probe exactly. Honest caveat: a leading non-`..` segment (e.g. `a/`) is required to prime the join; a single `../evil.docx` does not cleanly escape, and **URL-encoded separators (`%2f`) do not traverse** through this path (they are re-escaped into one literal segment). Only literal `/` separators work.\n \n**Confidence tier: source-verified + control-flow dynamically reproduced (no full live HTTP write against a deployed instance).**\n \n**Deduplication:** no existing CVE/GHSA matches. Known Cloudreve advisories are CVE-2022-32167 (XSS, v1–v3.5.3) and CVE-2026-25726 (weak-PRNG ATO, instances initialized < v4.10.0) — both unrelated. `SECURITY.md` lists \"user permissions\" as high-impact and in scope for all 4.x, so this qualifies as a vulnerability under the project's own policy.\n \n## Steps to reproduce\n \n**Setup:** user owns `cloudreve://my/folder/current.docx`; open it in the WOPI editor to obtain `<token>` (the session is bound to that file's ID).\n \n1. Send the crafted `PUT_RELATIVE`:\n ```\n POST /api/v4/file/wopi/<file-id>?access_token=<token> HTTP/1.1\n Host: target\n X-WOPI-Override: PUT_RELATIVE\n X-WOPI-SuggestedTarget: <UTF-7 of \"a/../../evil.docx\">\n Content-Type: application/octet-stream\n \n <file bytes>\n ```\n2. Cloudreve rewrites the target from `cloudreve://my/folder/current.docx` to `cloudreve://my/evil.docx`, validates the basename `evil.docx` (passes), and writes the content.\n**Expected:** the target is rejected or constrained to the source file's directory.\n**Actual:** a file is written at the account root, outside the token's single-file scope.\n \n## Impact\n \nA WOPI access token scoped to one file can write files to other locations in the same user's account. A malicious or compromised WOPI integration (or a leaked token) can plant or, conditionally, overwrite files at attacker-chosen paths the account owns, defeating the per-file scoping the WOPI session is meant to enforce. Confined to the session user's account (not cross-user).\n \n## Remediation\n \n- Treat `X-WOPI-SuggestedTarget` (and `X-WOPI-RequestedName`) as a **filename**, not a path: reject `/`, `\\`, dot segments, and percent-encoded separator variants before joining.\n- Prefer `DirUri().Join(sanitizedBaseName)` over `JoinRaw`, and after constructing the target URI assert it is a direct child of the source file's directory.\n- Add regression tests for `a/../../evil.docx`, `sub/evil.docx`, and encoded-separator variants.","affected":[{"package":{"name":"github.com/cloudreve/Cloudreve/v4","ecosystem":"Go","purl":"pkg:golang/github.com/cloudreve/Cloudreve/v4"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"4.0.0-20260613023150-7968e50429ef"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-49h3-cwhj-4737/GHSA-49h3-cwhj-4737.json"}},{"package":{"name":"github.com/cloudreve/Cloudreve/v3","ecosystem":"Go","purl":"pkg:golang/github.com/cloudreve/Cloudreve/v3"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"last_affected":"3.0.0-20250225100611-da4e44b77af4"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-49h3-cwhj-4737/GHSA-49h3-cwhj-4737.json"}}],"references":[{"type":"WEB","url":"https://github.com/cloudreve/cloudreve/security/advisories/GHSA-49h3-cwhj-4737"},{"type":"WEB","url":"https://github.com/cloudreve/cloudreve/commit/7968e50429efab40ffa8f57fecdfbd5a73d23630"},{"type":"PACKAGE","url":"https://github.com/cloudreve/cloudreve"},{"type":"WEB","url":"https://github.com/cloudreve/cloudreve/releases/tag/4.17.0"}],"database_specific":{"cwe_ids":["CWE-22"],"github_reviewed":true,"github_reviewed_at":"2026-07-24T20:41:16Z","nvd_published_at":null,"severity":"MODERATE"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N"}]}