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

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:

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:

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:

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 →RestoreStatesetsshareRoot→ToskipsRoot→IsValidSharenever 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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…