Cloudreve: Path Traversal in WOPI PUT_RELATIVE Allows Arbitrary File Creation in Owner Account
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N상세 설명
Summary
Cloudreve'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.
A 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.
Root cause (verified at 26b6b10)
1. Token is single-file scoped (the boundary being escaped) — middleware ViewerSessionValidation:
1fileId := hashid.FromContext(c) 2if fileId != session.FileID { // 403 — token is bound to ONE file 3 c.Status(http.StatusForbidden); c.Abort(); return 4}Route: 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>.
2. PUT_RELATIVE dispatch — routers/controllers/wopi.go:
1case wopi.MethodPutRelative: // X-WOPI-Override: PUT_RELATIVE 2 err = service.PutContent(c, true)3. SuggestedTarget joined as a path — service/explorer/viewer.go:
1fileName, _ := wopi.UTF7Decode(c.GetHeader(wopi.SuggestedTargetHeader)) // X-WOPI-SuggestedTarget 2fileUriParsed, _ := fs.NewUriFromString(fileUri) 3if strings.HasPrefix(fileName, ".") { /* treat as extension */ } 4fileUri = fileUriParsed.DirUri().JoinRaw(fileName).String() // <-- path join, not basename 5... 6subService := FileUpdateService{ Uri: fileUri } 7res, err := subService.PutContent(c, lockSession)4. JoinRaw splits on / and normalizes via url.JoinPath — pkg/filemanager/fs/uri.go:
1func (u *URI) Join(elem ...string) *URI { 2 newUrl, _ := url.Parse(u.U.String()) 3 return &URI{U: newUrl.JoinPath(/* PathEscape each elem */ ...)} // JoinPath cleans ./ and ../ 4} 5func (u *URI) JoinRaw(elem string) *URI { 6 return u.Join(strings.Split(strings.TrimPrefix(elem, Separator), Separator)...) 7}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.
5. Upload checks ownership of the resolved ancestor and validates only the clean basename — pkg/filemanager/fs/dbfs/upload.go:
1ancestor, err := f.getFileByPath(ctx, navigator, req.Props.Uri) // URI already traversal-normalized 2... 3if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && ancestor.OwnerID() != f.user.ID { 4 return nil, fs.ErrOwnerOnly // same-user -> passes 5} 6... 7if err := validateNewFile(req.Props.Uri.Name(), req.Props.Size, policy); err != nil { // checks "evil.docx" only 8 return nil, err 9}validateFileName rejects / \ : * ? " < > | and bare ./.. — but the traversal is already gone by the time it sees the basename.
Validation performed
Independent validation against commit 26b6b10 in a clean sandbox.
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.
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:
1SuggestedTarget resolved URI final basename validator 2"copy.docx" cloudreve://my/folder/copy.docx "copy.docx" ACCEPT 3"a/../../evil.docx" cloudreve://my/evil.docx "evil.docx" ACCEPT <- ESCAPED to / 4"a/../../../top.docx" cloudreve://my/top.docx "top.docx" ACCEPT <- ESCAPED to / 5"sub/evil.docx" cloudreve://my/folder/sub/evil.docx "evil.docx" ACCEPT <- different subdir 6".pdf" cloudreve://my/folder/current.pdf "current.pdf" ACCEPT 7"a%2f..%2f..%2fenc.docx" cloudreve://my/folder/a%252f..%252f.. "a%2f..%2f..%2f" ACCEPT (NO escape)The 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.
Confidence tier: source-verified + control-flow dynamically reproduced (no full live HTTP write against a deployed instance).
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.
Steps to reproduce
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).
- Send the crafted
PUT_RELATIVE:http1POST /api/v4/file/wopi/<file-id>?access_token=<token> HTTP/1.12Host: target3X-WOPI-Override: PUT_RELATIVE4X-WOPI-SuggestedTarget: <UTF-7 of "a/../../evil.docx">5Content-Type: application/octet-stream67<file bytes> - Cloudreve rewrites the target from
cloudreve://my/folder/current.docxtocloudreve://my/evil.docx, validates the basenameevil.docx(passes), and writes the content.
Expected: the target is rejected or constrained to the source file's directory.
Actual: a file is written at the account root, outside the token's single-file scope.
Impact
A 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).
Remediation
- Treat
X-WOPI-SuggestedTarget(andX-WOPI-RequestedName) as a filename, not a path: reject/,\, dot segments, and percent-encoded separator variants before joining. - Prefer
DirUri().Join(sanitizedBaseName)overJoinRaw, and after constructing the target URI assert it is a direct child of the source file's directory. - Add regression tests for
a/../../evil.docx,sub/evil.docx, and encoded-separator variants.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.