From 2828e4bf72d486bb11bb81ebf26aa20254b62bae Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 17 Jun 2026 17:11:55 +0200 Subject: [PATCH] fix(api): don't expose private org membership via public_members The GET /orgs/{org}/public_members and /public_members/{username} endpoints returned membership information without checking whether the requester is allowed to see the organization. For a private org, any authenticated user could probe public_members/{username} and infer membership from the 204 vs 404 response, disclosing data that is hidden in the web UI. Gate both handlers on HasOrgOrUserVisible so they return 404 when the doer cannot see the organization, matching the existing behaviour of the org GET endpoint. Assisted-by: Claude:claude-opus-4-8 --- routers/api/v1/org/member.go | 11 +++++++++++ tests/integration/api_org_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/routers/api/v1/org/member.go b/routers/api/v1/org/member.go index 11b46a05c1..ce7f227af6 100644 --- a/routers/api/v1/org/member.go +++ b/routers/api/v1/org/member.go @@ -119,6 +119,12 @@ func ListPublicMembers(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" + // don't disclose membership of organizations the doer cannot see + if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) { + ctx.APIErrorNotFound() + return + } + listMembers(ctx, false) } @@ -201,6 +207,11 @@ func IsPublicMember(ctx *context.APIContext) { if ctx.Written() { return } + // don't disclose membership of organizations the doer cannot see + if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) { + ctx.APIErrorNotFound() + return + } is, err := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, userToCheck.ID) if err != nil { ctx.APIErrorInternal(err) diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 3306c6539c..017c7baaf2 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -263,6 +263,32 @@ func testAPIOrgGeneral(t *testing.T) { }) } +func TestAPIOrgPrivateMembersNotLeaked(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // privated_org (org 23) has private visibility and a single member, user5 + const orgName = "privated_org" + const memberName = "user5" + + // member publicizes their own membership inside the private org + memberSession := loginUser(t, memberName) + memberToken := getTokenForLoggedInUser(t, memberSession, auth_model.AccessTokenScopeWriteOrganization) + req := NewRequest(t, "PUT", "/api/v1/orgs/"+orgName+"/public_members/"+memberName).AddTokenAuth(memberToken) + MakeRequest(t, req, http.StatusNoContent) + + // an outsider must not be able to learn about the membership of a private org + outsiderSession := loginUser(t, "user2") + outsiderToken := getTokenForLoggedInUser(t, outsiderSession, auth_model.AccessTokenScopeReadOrganization) + req = NewRequest(t, "GET", "/api/v1/orgs/"+orgName+"/public_members/"+memberName).AddTokenAuth(outsiderToken) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/api/v1/orgs/"+orgName+"/public_members").AddTokenAuth(outsiderToken) + MakeRequest(t, req, http.StatusNotFound) + + // the member can still see the public membership of their own org + req = NewRequest(t, "GET", "/api/v1/orgs/"+orgName+"/public_members/"+memberName).AddTokenAuth(memberToken) + MakeRequest(t, req, http.StatusNoContent) +} + func testAPIDeleteOrgRepos(t *testing.T) { org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) orgRepos, err := repo_model.GetOrgRepositories(t.Context(), org3.ID)