From bade14ee693fa4092e401d1fc285b2197a03f0ae Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Thu, 18 Dec 2025 09:47:40 -0700 Subject: [PATCH] fix: hide user profile anonymous options on public repo APIs --- models/fixtures/user.yml | 1 + services/convert/repository.go | 21 ++++++++++++++----- tests/integration/api_repo_test.go | 33 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index fa7607abcd..4e957f30f3 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -46,6 +46,7 @@ email: user2@example.com keep_email_private: true keep_pronouns_private: true + pronouns: he/him email_notifications_preference: enabled passwd: ZogKvWdyEx:password passwd_hash_algo: dummy diff --git a/services/convert/repository.go b/services/convert/repository.go index f3603ee7f1..3823fd3a09 100644 --- a/services/convert/repository.go +++ b/services/convert/repository.go @@ -4,7 +4,7 @@ package convert import ( - "context" + stdCtx "context" "time" "forgejo.org/models" @@ -15,14 +15,15 @@ import ( unit_model "forgejo.org/models/unit" "forgejo.org/modules/log" api "forgejo.org/modules/structs" + "forgejo.org/services/context" ) // ToRepo converts a Repository to api.Repository -func ToRepo(ctx context.Context, repo *repo_model.Repository, permissionInRepo access_model.Permission) *api.Repository { +func ToRepo(ctx stdCtx.Context, repo *repo_model.Repository, permissionInRepo access_model.Permission) *api.Repository { return innerToRepo(ctx, repo, permissionInRepo, false) } -func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInRepo access_model.Permission, isParent bool) *api.Repository { +func innerToRepo(ctx stdCtx.Context, repo *repo_model.Repository, permissionInRepo access_model.Permission, isParent bool) *api.Repository { var parent *api.Repository if permissionInRepo.Units == nil && permissionInRepo.UnitsMode == nil { @@ -179,9 +180,19 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR repoAPIURL := repo.APIURL() + // Calculate the effective permission for `ToUserWithAccessMode` for the repo owner. When accessing a public repo, + // permissionInRepo.AccessMode will be AccessModeRead even for an anonymous user -- in that case, downgrade + // `ownerViewPerms` to `AccessModeNone`. `innerToRepo` doesn't have great access to recognize an anonymous user, so + // the best-effort made here is to check if `ctx` is an `APIContext`. + ownerViewPerms := permissionInRepo.AccessMode + apiCtx, ok := ctx.(*context.APIContext) + if ok && apiCtx.Doer == nil { + ownerViewPerms = perm.AccessModeNone + } + return &api.Repository{ ID: repo.ID, - Owner: ToUserWithAccessMode(ctx, repo.Owner, permissionInRepo.AccessMode), + Owner: ToUserWithAccessMode(ctx, repo.Owner, ownerViewPerms), Name: repo.Name, FullName: repo.FullName(), Description: repo.Description, @@ -246,7 +257,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR } // ToRepoTransfer convert a models.RepoTransfer to a structs.RepeTransfer -func ToRepoTransfer(ctx context.Context, t *models.RepoTransfer) *api.RepoTransfer { +func ToRepoTransfer(ctx stdCtx.Context, t *models.RepoTransfer) *api.RepoTransfer { teams, _ := ToTeams(ctx, t.Teams, false) return &api.RepoTransfer{ diff --git a/tests/integration/api_repo_test.go b/tests/integration/api_repo_test.go index ca939dfa7b..edd0c61a44 100644 --- a/tests/integration/api_repo_test.go +++ b/tests/integration/api_repo_test.go @@ -289,6 +289,39 @@ func TestAPIViewRepo(t *testing.T) { assert.Equal(t, 1, repo.Stars) } +// Validate that private information on the user profile isn't exposed by way of being an owner of a public repository. +func TestAPIViewRepoOwnerSettings(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + var repo api.Repository + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, 1, repo.ID) + assert.Equal(t, "user2@noreply.example.org", repo.Owner.Email) // unauthed, always private + assert.Empty(t, repo.Owner.Pronouns) // user2.keep_pronouns_private = true + + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.Equal(t, "user2@noreply.example.org", repo.Owner.Email) // user2.keep_email_private = true + assert.Equal(t, "he/him", repo.Owner.Pronouns) // user2.keep_pronouns_private = true + + req = NewRequest(t, "GET", "/api/v1/repos/user12/repo10") + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, 10, repo.ID) + assert.Equal(t, "user12@noreply.example.org", repo.Owner.Email) // unauthed, always private + + req = NewRequest(t, "GET", "/api/v1/repos/user12/repo10").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.Equal(t, "user12@example.com", repo.Owner.Email) // user2.keep_email_private = false +} + func TestAPIOrgRepos(t *testing.T) { defer tests.PrepareTestEnv(t)() user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})