Kestrel
대시보드로 돌아가기
CVE-2026-55495MEDIUM· 4.3GHSA대응게시일: 2026. 07. 24.수정일: 2026. 07. 24.

Cloudreve: Path Traversal in WOPI PUT_RELATIVE Allows Arbitrary File Creation in Owner Account

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
4.3medium

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위불변
영향
기밀성 영향없음
무결성 영향낮음
가용성 영향없음
버전별 점수
CVSS 3.14.3MODERATE
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:

text
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 dispatchrouters/controllers/wopi.go:

text
1case wopi.MethodPutRelative: // X-WOPI-Override: PUT_RELATIVE
2 err = service.PutContent(c, true)

3. SuggestedTarget joined as a pathservice/explorer/viewer.go:

text
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.JoinPathpkg/filemanager/fs/uri.go:

text
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 basenamepkg/filemanager/fs/dbfs/upload.go:

text
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:

text
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).

  1. Send the crafted PUT_RELATIVE:
    http
    1POST /api/v4/file/wopi/<file-id>?access_token=<token> HTTP/1.1
    2Host: target
    3X-WOPI-Override: PUT_RELATIVE
    4X-WOPI-SuggestedTarget: <UTF-7 of "a/../../evil.docx">
    5Content-Type: application/octet-stream
    6
    7<file bytes>
  2. 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.
    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 (and X-WOPI-RequestedName) as a filename, not a path: reject /, \, dot segments, and percent-encoded separator variants before joining.
  • Prefer DirUri().Join(sanitizedBaseName) over JoinRaw, 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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.