---
id: GHSA-vx2m-jpxr-xv7w
title: >-
  Cloudreve has Broken Access Control - Revoked Share Access Still Allows Signed
  File URL Generation via Cached context_hint
summary: >-
  Cloudreve has Broken Access Control - Revoked Share Access Still Allows Signed
  File URL Generation via Cached context_hint
severity: medium
cvss: 5.3
cwe:
  - CWE-863
vendor: cloudreve
product: github.com/cloudreve/Cloudreve/v4
ecosystem: go
affected:
  - github.com/cloudreve/Cloudreve/v4 <= 4.0.0-20260606032813-26b6b1044b02
published: '2026-08-24'
updated: '2026-08-24'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-vx2m-jpxr-xv7w'
references:
  - url: >-
      https://github.com/cloudreve/cloudreve/security/advisories/GHSA-vx2m-jpxr-xv7w
  - url: 'https://github.com/advisories/GHSA-vx2m-jpxr-xv7w'
tags:
  - ghsa
  - go
ingestedAt: '2026-08-24T22:12:11.021Z'
---

## Overview

## Summary
 
Cloudreve's file-listing responses hand the client a `context_hint` (UUID) that is meant to speed up follow-up operations. When that hint is replayed on the `file/url` (and `file/thumb`) routes, DBFS caches a `shareNavigatorState` containing the already-loaded share root and share row.
 
On a later request carrying the same hint, `shareNavigator.RestoreState` repopulates `shareRoot`, and `shareNavigator.To` then **skips `Root`**. `Root` is the only place that re-checks `inventory.IsValidShare` (share expiry, remaining-download count, owner status, source-file validity) and the share password. As a result, a recipient who prewarms a context hint while access is valid can keep minting signed file URLs for already-known shared file paths for up to the context-hint TTL (`5 * 60` = 300 s) after the owner deletes the share or the share expires — plus the lifetime of any signed entity URL minted in that window.
 
This is a **revocation / expiry bypass**, not a way to discover unknown share contents: the attacker must already have had access to the share and must know the target file URI from a prior listing.

## Root cause (verified at `26b6b10`)
 
**1. List responses leak the hint and each file URI** — `service/explorer/response.go` populates `ListResponse.ContextHint` and `FileResponse.Path` (`f.Uri(false).String()`).
 
**2. `file/url` and `file/thumb` accept the client-supplied hint** — `routers/router.go:631` and `:662`:
 
```go
file.POST("url",  middleware.ContextHint(), /* ... */ controllers.FileURL)
file.GET("thumb", middleware.ContextHint(), /* ... */ controllers.Thumb)
```
 
The `file` group's only auth gate is `middleware.RequiredScopes(types.ScopeFilesRead)` — there is **no** independent share-validation middleware on this route. All share validation lives inside DBFS.
 
**3. The middleware trusts the header verbatim** — `middleware/file.go:41`:
 
```go
func ContextHint() gin.HandlerFunc {
    return func(c *gin.Context) {
        if c.GetHeader(dbfs.ContextHintHeader) != "" { // X-Cr-Context-Hint
            util.WithValue(c, dbfs.ContextHintCtxKey{}, uuid.FromStringOrNil(c.GetHeader(dbfs.ContextHintHeader)))
        }
        c.Next()
    }
}
```
 
**4. DBFS restores cached navigator state on a hint hit** — `dbfs.go:745` (`ContextHintTTL = 5 * 60`, `dbfs.go:34`). On a miss it arms `PersistState`; the closure fires in `DBFS.Recycle()` at end of request.
 
**5. Persisted share state carries the loaded `shareRoot` + `share` row** — `share_navigator.go:72`/`:85`. `RestoreState` reinstates `n.shareRoot`, `n.share`, `n.owner`, etc.
 
**6. `Root` is the sole validity/password gate** — `share_navigator.go:114` → `inventory.IsValidShare(share)` (`inventory/share.go:227`: `IsShareExpired` checks `Expires.Before(now)` **and** `RemainDownloads <= 0`, plus owner-active and source-file checks) followed by the `share.Password` comparison.
 
**7. `To` skips `Root` once `shareRoot` is set** — `share_navigator.go:181`:
 
```go
func (n *shareNavigator) To(ctx context.Context, path *fs.URI) (*File, error) {
    if n.shareRoot == nil {            // restored state => NOT nil => Root() skipped
        root, err := n.Root(ctx, path)
        ...
    }
    ...
}
```
 
The single-file-share branch is **also** affected: it calls `latestSharedSingleFile`, which fetches `n.fileClient.GetByID(n.share.Edges.File.ID)` straight from the restored `share` with no revalidation (`share_navigator.go`).
 
**8. A failed download hook does not block URL issuance** — `pkg/filemanager/manager/entity.go:250`:
 
```go
if err := m.fs.ExecuteNavigatorHooks(ctx, fs.HookTypeBeforeDownload, file); err != nil {
    m.l.Warning("Failed to execute navigator hooks: %s", err) // logged, NOT fatal
}
```
 
The share's `BeforeDownload` hook is `shareClient.Downloaded()` (`UpdateOneID(share.ID).AddDownloads(1).AddRemainDownloads(-1)`). Against a deleted share this update errors, but the error is only logged and the signed URL is still minted. The signed content endpoint `file/content/:id/...` is then guarded only by `middleware.SignRequired` — it does not re-check the share.

## Steps to reproduce
 
**Setup:** one share owner; one recipient (a second free account, or anonymous if the default anon group keeps share-download). Recipient knows the share URL (and password, if any).
 
1. Recipient lists the valid share:
   ```
   GET /api/v4/file?uri=<share-uri> HTTP/1.1
   Host: target
   ```
   Response includes `context_hint` and each file's `path`.
2. While the share is still valid, recipient warms the cache for a known file:
   ```
   POST /api/v4/file/url HTTP/1.1
   Host: target
   X-Cr-Context-Hint: <context_hint>
   Content-Type: application/json
 
   {"uri":["<known-shared-file-uri>"]}
   ```
   (cache MISS → `Root` runs → `PersistState` armed → `Recycle` writes `shareNavigatorState` to KV under `navigator_state_<hint>_share`.)
3. Owner deletes the share, **or** it expires / hits zero remaining downloads.
4. Within 300 s, recipient repeats the **same** request from step 2 (same `X-Cr-Context-Hint`, same URI).
   (cache HIT → `RestoreState` sets `shareRoot` → `To` skips `Root` → `IsValidShare` never runs → signed entity URL returned.)
5. The signed URL serves the file content; `file/content/:id/...` validates only the signature.
**Expected:** step 4 returns `ErrShareNotFound` / `ErrShareLinkExpired`.
**Actual:** step 4 returns a signed, downloadable URL.
 
## Impact
 
A former share recipient (including an anonymous one, under default permissions) can keep minting signed download URLs for already-known shared files for up to 300 s after the owner deletes the share or after time/download-limit expiry, plus the validity window of each signed URL minted in that period. It defeats owner revocation, time expiry, and the remaining-download limit, and bypasses password revalidation on cached state.
 
## Remediation
 
- On `RestoreState`, re-run `inventory.IsValidShare` and re-compare the current `share.Password` before trusting cached `shareRoot`; or bind the cached state to an authorization version that changes on any share edit/delete/download-limit change.
- Do not store authorization-sensitive share state in context-hint cache; treat the hint as a pagination/perf token only.
- Invalidate `navigator_state_*` entries when a share is edited or deleted.
- Treat `HookTypeBeforeDownload` failures as blocking for share-backed downloads.
- Add a regression test: list + prewarm hint, delete share, then `file/url` with the same hint must fail.

## Affected packages

- `github.com/cloudreve/Cloudreve/v4 <= 4.0.0-20260606032813-26b6b1044b02`

## Remediation

Refer to the advisory for the patched release.
