GHSA-VX2M-JPXR-XV7W

Vulnerability from github – Published: 2026-08-24 22:01 – Updated: 2026-08-24 22:01
VLAI
Summary
Cloudreve has Broken Access Control - Revoked Share Access Still Allows Signed File URL Generation via Cached context_hint
Details

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 URIservice/explorer/response.go populates ListResponse.ContextHint and FileResponse.Path (f.Uri(false).String()).

2. file/url and file/thumb accept the client-supplied hintrouters/router.go:631 and :662:

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 verbatimmiddleware/file.go:41:

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 hitdbfs.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 rowshare_navigator.go:72/:85. RestoreState reinstates n.shareRoot, n.share, n.owner, etc.

6. Root is the sole validity/password gateshare_navigator.go:114inventory.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 setshare_navigator.go:181:

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 issuancepkg/filemanager/manager/entity.go:250:

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: Content-Type: application/json

{"uri":[""]} `` (cache MISS →Rootruns →PersistStatearmed →RecyclewritesshareNavigatorStateto KV undernavigator_state__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 (sameX-Cr-Context-Hint, same URI). (cache HIT →RestoreStatesetsshareRootToskipsRootIsValidSharenever runs → signed entity URL returned.) 5. The signed URL serves the file content;file/content/:id/...validates only the signature. **Expected:** step 4 returnsErrShareNotFound/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.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/cloudreve/Cloudreve/v4"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "4.0.0-20260606032813-26b6b1044b02"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-24T22:01:03Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n \nCloudreve\u0027s 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.\n \nOn 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 \u2014 plus the lifetime of any signed entity URL minted in that window.\n \nThis 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.\n\n## Root cause (verified at `26b6b10`)\n \n**1. List responses leak the hint and each file URI** \u2014 `service/explorer/response.go` populates `ListResponse.ContextHint` and `FileResponse.Path` (`f.Uri(false).String()`).\n \n**2. `file/url` and `file/thumb` accept the client-supplied hint** \u2014 `routers/router.go:631` and `:662`:\n \n```go\nfile.POST(\"url\",  middleware.ContextHint(), /* ... */ controllers.FileURL)\nfile.GET(\"thumb\", middleware.ContextHint(), /* ... */ controllers.Thumb)\n```\n \nThe `file` group\u0027s only auth gate is `middleware.RequiredScopes(types.ScopeFilesRead)` \u2014 there is **no** independent share-validation middleware on this route. All share validation lives inside DBFS.\n \n**3. The middleware trusts the header verbatim** \u2014 `middleware/file.go:41`:\n \n```go\nfunc ContextHint() gin.HandlerFunc {\n    return func(c *gin.Context) {\n        if c.GetHeader(dbfs.ContextHintHeader) != \"\" { // X-Cr-Context-Hint\n            util.WithValue(c, dbfs.ContextHintCtxKey{}, uuid.FromStringOrNil(c.GetHeader(dbfs.ContextHintHeader)))\n        }\n        c.Next()\n    }\n}\n```\n \n**4. DBFS restores cached navigator state on a hint hit** \u2014 `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.\n \n**5. Persisted share state carries the loaded `shareRoot` + `share` row** \u2014 `share_navigator.go:72`/`:85`. `RestoreState` reinstates `n.shareRoot`, `n.share`, `n.owner`, etc.\n \n**6. `Root` is the sole validity/password gate** \u2014 `share_navigator.go:114` \u2192 `inventory.IsValidShare(share)` (`inventory/share.go:227`: `IsShareExpired` checks `Expires.Before(now)` **and** `RemainDownloads \u003c= 0`, plus owner-active and source-file checks) followed by the `share.Password` comparison.\n \n**7. `To` skips `Root` once `shareRoot` is set** \u2014 `share_navigator.go:181`:\n \n```go\nfunc (n *shareNavigator) To(ctx context.Context, path *fs.URI) (*File, error) {\n    if n.shareRoot == nil {            // restored state =\u003e NOT nil =\u003e Root() skipped\n        root, err := n.Root(ctx, path)\n        ...\n    }\n    ...\n}\n```\n \nThe 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`).\n \n**8. A failed download hook does not block URL issuance** \u2014 `pkg/filemanager/manager/entity.go:250`:\n \n```go\nif err := m.fs.ExecuteNavigatorHooks(ctx, fs.HookTypeBeforeDownload, file); err != nil {\n    m.l.Warning(\"Failed to execute navigator hooks: %s\", err) // logged, NOT fatal\n}\n```\n \nThe share\u0027s `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` \u2014 it does not re-check the share.\n\n## Steps to reproduce\n \n**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).\n \n1. Recipient lists the valid share:\n   ```\n   GET /api/v4/file?uri=\u003cshare-uri\u003e HTTP/1.1\n   Host: target\n   ```\n   Response includes `context_hint` and each file\u0027s `path`.\n2. While the share is still valid, recipient warms the cache for a known file:\n   ```\n   POST /api/v4/file/url HTTP/1.1\n   Host: target\n   X-Cr-Context-Hint: \u003ccontext_hint\u003e\n   Content-Type: application/json\n \n   {\"uri\":[\"\u003cknown-shared-file-uri\u003e\"]}\n   ```\n   (cache MISS \u2192 `Root` runs \u2192 `PersistState` armed \u2192 `Recycle` writes `shareNavigatorState` to KV under `navigator_state_\u003chint\u003e_share`.)\n3. Owner deletes the share, **or** it expires / hits zero remaining downloads.\n4. Within 300 s, recipient repeats the **same** request from step 2 (same `X-Cr-Context-Hint`, same URI).\n   (cache HIT \u2192 `RestoreState` sets `shareRoot` \u2192 `To` skips `Root` \u2192 `IsValidShare` never runs \u2192 signed entity URL returned.)\n5. The signed URL serves the file content; `file/content/:id/...` validates only the signature.\n**Expected:** step 4 returns `ErrShareNotFound` / `ErrShareLinkExpired`.\n**Actual:** step 4 returns a signed, downloadable URL.\n \n## Impact\n \nA 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.\n \n## Remediation\n \n- 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.\n- Do not store authorization-sensitive share state in context-hint cache; treat the hint as a pagination/perf token only.\n- Invalidate `navigator_state_*` entries when a share is edited or deleted.\n- Treat `HookTypeBeforeDownload` failures as blocking for share-backed downloads.\n- Add a regression test: list + prewarm hint, delete share, then `file/url` with the same hint must fail.",
  "id": "GHSA-vx2m-jpxr-xv7w",
  "modified": "2026-08-24T22:01:03Z",
  "published": "2026-08-24T22:01:03Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/cloudreve/cloudreve/security/advisories/GHSA-vx2m-jpxr-xv7w"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/cloudreve/cloudreve"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Cloudreve has Broken Access Control - Revoked Share Access Still Allows Signed File URL Generation via Cached context_hint"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…