Skip to content

Cloudreve: Information Exposure in `GET /api/v4/user/search`: `SearchActive` omits the active-status predicate, leaking inactive/banned account emails

Moderate severity GitHub Reviewed Published Jul 23, 2026 in cloudreve/cloudreve • Updated Jul 24, 2026

Package

gomod github.com/cloudreve/Cloudreve/v3 (Go)

Affected versions

<= 3.0.0-20250225100611-da4e44b77af4

Patched versions

None
gomod github.com/cloudreve/Cloudreve/v4 (Go)
< 4.0.0-20260613023921-7e1289d55279
4.0.0-20260613023921-7e1289d55279

Description

Summary

GET /api/v4/user/search is available to any logged-in user. The service calls userClient.SearchActive, but despite its name that method filters only by email/nickname keyword and never adds a StatusActive predicate — while the sibling lookups GetActiveByID and GetActiveByDavAccount, defined a few lines above it, do. Search hits are serialized at RedactLevelUser, which includes the email address.

A normal logged-in user can therefore enumerate and retrieve the email (plus nickname, avatar, creation time, redacted group, profile share-visibility) of inactive and banned accounts that an active-user directory is supposed to suppress. No global status interceptor compensates — the only User query interceptor is soft-delete, and inactive/banned rows are not soft-deleted.

Details

Root cause (verified at 26b6b10)

1. Route — logged-in + UserInfo.Read scope (routers/router.go):

user := v4.Group("user")            // protected user group (login required)
user.GET("search",
    middleware.RequiredScopes(types.ScopeUserInfoRead),
    controllers.FromQuery[usersvc.SearchUserService](...), controllers.UserSearch)

The RequiredScopes check applies to scoped OAuth tokens; plain session requests are not gated by it — so any logged-in user reaches the search.

2. Service — 2-char keyword to SearchActive (service/user/info.go):

type SearchUserService struct { Keyword string `form:"keyword" binding:"required,min=2"` }
const resultLimit = 10
func (s *SearchUserService) Search(c *gin.Context) ([]*ent.User, error) {
    return dep.UserClient().SearchActive(c, resultLimit, s.Keyword)
}

3. The bug — SearchActive has no status predicate (inventory/user.go):

func (c *userClient) SearchActive(ctx context.Context, limit int, keyword string) ([]*ent.User, error) {
    ctx = context.WithValue(ctx, LoadUserGroup{}, true)
    return withUserEagerLoading(ctx,
        c.client.User.Query().
            Where(user.Or(user.EmailContainsFold(keyword), user.NickContainsFold(keyword))).
            Limit(limit),                       // <-- no user.StatusEQ(user.StatusActive)
    ).All(ctx)
}

Contrast the siblings immediately above:

func (c *userClient) GetActiveByID(...)        { ... Where(user.ID(id)).Where(user.StatusEQ(user.StatusActive)) ... }
func (c *userClient) GetActiveByDavAccount(...) { ... Where(user.EmailEqualFold(email)).Where(user.StatusEQ(user.StatusActive)) ... }

withUserEagerLoading only eager-loads the group/passkey edges; it adds no status filter. Status values are active/inactive/manual_banned/sys_banned (ent/user/user.go).

4. No global status interceptorUser.Mixin() is CommonMixin{} (ent/schema/user.go), whose Interceptors() returns only softDeleteInterceptors (ent/schema/common.go). Inactive/banned users are not soft-deleted, so nothing filters them out at query time.

5. Results serialized with email (routers/controllers/user.goservice/user/response.go):

// UserSearch:
return user.BuildUserRedacted(item, user.RedactLevelUser, hasher)
// BuildUserRedacted:
if level == RedactLevelUser { user.Email = userRaw.Email }   // email included

Secondary path: GET /api/v4/user/info/:idGetUser uses GetByID (no status filter), and the controller picks RedactLevelUser for any non-anonymous caller (RedactLevelAnonymous only for anonymous). So a logged-in caller with an inactive/banned user's hashed ID also receives the email-bearing profile. (Less practical than search, since it needs the hashed ID rather than a 2-char keyword.)

Steps to reproduce (requires a live instance)

  1. Ensure a target account exists in inactive or manual_banned/sys_banned status (e.g., an unconfirmed registration or a banned user).
  2. As any logged-in user:
    GET /api/v4/user/search?keyword=<>=2 chars of the target email/nick>
    Cookie: cloudreve-session=<attacker-session>
    
  3. Observe the inactive/banned account in the results, including its email.
    Expected: only active accounts appear (matching the method name and the sibling GetActive* behavior).
    Actual: inactive/banned accounts are returned with their email addresses.

Impact

Any logged-in user can enumerate and harvest the email addresses (and basic profile metadata) of inactive and banned accounts that active-user lookups intentionally hide. No account access, passwords, or 2FA secrets are exposed; the impact is PII leakage and user enumeration.

Remediation

  • Add Where(user.StatusEQ(user.StatusActive)) to SearchActive (matching GetActiveByID).
  • Apply the same active-status requirement to GET /api/v4/user/info/:id, or fall back to anonymous-level redaction unless the target account is active.
  • Consider not returning email from directory search at all — display name + hashed ID is usually sufficient.
  • Regression tests: searching a keyword that matches an inactive/banned account must return no result (or no email).

References

@HFO4 HFO4 published to cloudreve/cloudreve Jul 23, 2026
Published to the GitHub Advisory Database Jul 24, 2026
Reviewed Jul 24, 2026
Last updated Jul 24, 2026

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

EPSS score

Weaknesses

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Exposure of Private Personal Information to an Unauthorized Actor

The product does not properly prevent a person's private, personal information from being accessed by actors who either (1) are not explicitly authorized to access the information or (2) do not have the implicit consent of the person about whom the information is collected. Learn more on MITRE.

CVE ID

CVE-2026-55496

GHSA ID

GHSA-8r7f-r8hj-r3rv

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.