fix: stop disclosing user e-mail (username) outside the project members list#3823
Merged
Anty0 merged 17 commits intoJul 26, 2026
Merged
Conversation
Contributor
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Anty0
force-pushed
the
jirikuchynka/community-contributors
branch
2 times, most recently
from
July 25, 2026 15:58
70a1fe0 to
37ab36d
Compare
…rs list
A user's `username` is their e-mail address. It was exposed on many API
surfaces (activity feed, suggestions, comments, translation history, tasks,
batch-job author, notifications, key-trash "deleted by", branches, API-key
owner, XLSX task report) behind `maskedMemberField`, which only gated on the
caller holding MEMBERS_VIEW and never on the target being a member — so a
public-project admin saw community contributors' and ex-members' e-mails, and
non-project-scoped endpoints leaked to anyone (fail-open).
Strip `username` from every response model except the project members list,
org members list, self ("me"), instance-admin user management, and the
admin-only project export (which re-links authors by username on import). The
field stays on the wire, deprecated and always empty. Remove the now-unused
maskedMemberField. Regenerate the frontend schema and update affected tests.
Follow-up from review of the username-disclosure change: - Centralize the deprecation/schema text into a single `USERNAME_FIELD_DEPRECATION` constant so the five annotation sites can't drift, and correct the wording (username is also returned on org-member/self/admin surfaces, not "only the members list"). - Make ApiKeyModel enforce the empty-username invariant structurally like the other two models: move username out of the constructor to a body `val ""`, and make IApiKeyModel.username read-only. - Document the disclosure policy on UserAccount.username (the allowlisted surfaces). - TaskReportHelper.formatUserName falls back to `#<id>` when name is blank, so the XLSX report never has an empty/ambiguous cell and never the e-mail. - Reword the now-stale shouldExposeMemberInfo comment (its websocket caller went away with maskedMemberField). - Lock the "kept" side: assert the project members list still exposes username; add a TaskReportHelper unit test. - Fix the soft-delete-keys e2e: the deleter filter identifies users by name now that username is stripped, so give the test user a name and assert on it.
- UserName (useUserName): fall back to the "Unnamed user" label after name/username, so name-less authors on stripped surfaces (comments, history, trash) no longer render a blank label. Username stays "", so no e-mail is shown. - Add UsernameDisclosureGuardTest: classpath-scans every HATEOAS response model and fails if a non-allowlisted one accepts `username` via its constructor. The allowlist lives in the test; a vacuity guard ensures the scan actually finds the models. UserAccount.username KDoc now points at the test instead of enumerating api-module class names. - Rename shouldExposeMemberInfo -> shouldExposeMemberCount and scope its comment: it gates only the non-sensitive member count (where the fail-open default is intentional), not any member field. Rename its test to ShouldExposeMemberCountTest (the masking concept is gone). - Assert the instance-admin user list keeps exposing username (the last untested allowlist surface).
Round-3 review: the guard only proved `username` was absent from the primary constructor, a weaker property than "cannot be set". Tighten it so a non-allowlisted response model is flagged when username is settable via ANY constructor OR is a mutable (`var`) property an assembler could assign post-construction; the immutable `val username = ""` pattern still passes. Scan a single `io.tolgee` root so every hateoas package (including a mispackaged one) is covered without a prefix list. Anchor the vacuity check on an EE model too, and fail loud on reflection errors instead of treating an un-inspectable model as clean.
Round-4 review: - The four models that intentionally expose username (the e-mail) are allowlisted by class, but nothing bound each to the gated endpoint it may be served from. Document that binding on each model (endpoint + scope, "do not return from a less-gated endpoint") and mirror the scopes in the guard test's allowlist. The guard KDoc now states it does NOT prove endpoint gating. - Note the guard's coverage boundary (RepresentationModel response types only) and remove the dead UserResponseDTO (a plain data class with a `var username`, referenced nowhere), which was the one un-scanned response-shaped type carrying the e-mail. - Strengthen the two positive allowlist assertions from "non-empty" to the exact e-mail, matching the org-members and self tests, so a wrong-field wiring (e.g. name in place of e-mail) is caught.
The disclosure guard lives in the EE test module (it must, to scan EE response models too), so a core-only build that runs without EE does not run it. Document that limitation on the guard and on UserAccount.username so a core-only fork knows to re-add an equivalent core-scoped scan.
The guard lived only in :ee-test, a conditionally-built module — an EE-less build (OSS fork, or the CWD drift that drops :ee-app) shipped green with zero enforcement of what is a core invariant. Extract the allowlist and the scan-and-assert into a single shared helper (io.tolgee.testing.security.UsernameDisclosureGuard in :testing), keyed on string class names so :testing needs no compile dependency on :api or spring-hateoas. Add a core entrypoint in :server-app — built and tested in every build — anchored on the core allowlisted models, so core response models stay guarded even when EE is absent. The existing EE test becomes a thin entrypoint that additionally anchors an EE model. One allowlist, no duplication.
…ones The core entrypoint's anti-vacuous anchor was the allowlisted (exempt) model set — the models the offender loop skips — so it only proved the scan reached models it ignores, not the stripped models it polices. A scan that returned just the allowlisted subset would pass green on the always-built entrypoint. Add corePolicedModelNames (SimpleUserAccountModel, ApiKeyModel, ProjectActivityAuthorModel, across three hateoas packages) and anchor both entrypoints on it, so the vacuity check proves the offender loop actually sees stripped models.
…-email fields
Round-8 review (full panel):
- Consolidate the changeset's comments: keep the invariant in one place (UsernameDisclosureGuard),
reduce the model/entity/test KDocs to one-line pointers, trim the guard class doc to the load-
bearing blind spots, and cut inline comments that restate the code or an assertion message.
- Extract the offender rule into UsernameDisclosureGuard.hasSettableUsername and unit-test it
directly (UsernameDisclosureDetectorTest) with plain non-RepresentationModel fixtures, so the
detector's positive path is covered — the entrypoint scans only ever see stripped models.
- Add a self-endpoint retention test (GET /v2/user keeps username), the one allowlisted surface
that lacked one.
- Remove the now-dead authorEmail/authorUsername view fields and the query columns that populated
them (translation history, project activity, suggestions) — unread since the assemblers stopped
reading them, and a standing re-leak footgun.
- TaskReportHelper: carry the user id ("name #id") so same-named users stay distinguishable in the
XLSX report without the e-mail.
Per the project's comment rule (default: no comment; keep only where a reader would burn themselves). Drop the one-line model KDocs (allowlist membership is the record), the two entrypoint KDocs, the test-assertion narration, the detector-test rationale, and the guard property/predicate docs. Trim the entity field doc to the one non-obvious fact (username is the e-mail) and the guard class doc to the RepresentationModel-only blind spot. Keep the SecurityService fail-open note, the blind-spot warning, and the per-entry endpoint+gate comments on the allowlist.
They restated endpoint/scope wiring that lives in the controllers and would rot silently against it; the model class names already identify each surface and the guard's error message states the criterion an entry must meet.
The fail-open comment on shouldExposeMemberCount named a remote endpoint (rot-prone) and stood in for a missing test on the projectless branch. Add that test (mirroring the off-request one) so both fail-open branches are checked, and remove the comment — a reader who "tightens" return true to false now fails a test instead of relying on prose.
Round-12 review (full panel converged on the guard's core predicate): - Flag any non-sanctioned RepresentationModel that DECLARES a `username` property, not just a settable one — a computed immutable `val username get() = email` no longer slips through. The five username-bearing models (including the two IApiKeyModel delegators) are listed in strippedModelNames; anything else declaring username must be sanctioned deliberately. - Add UsernameDisclosureGuardValueTest asserting each stripped model actually emits "", so a getter that returns the e-mail fails a test even though the scan (which reads declaration, not value) stays green. Document that the guard keys on the literal name `username` and reads declaration not value. - Add a scanned-count tripwire so a whole model-bearing module dropping off the classpath fails loudly; fold the guard-owned anchor sets into assertNoLeak; order the entry point above its helper. - Remove the last dead deletedByUserUsername (trash queries) symmetrically, keeping the positional KeyWithTranslationsView offsets aligned.
Update KeyWithTranslationsView.of()'s layout comment from 6 to 5 optional trashed columns after deletedByUserUsername was removed, and restore a one-line note on shouldExposeMemberCount that its request-scope guard must run before projectHolder.projectOrNull (which throws off-request).
… search KeySearchSearchResultModel delegates KeySearchResultView (`by view`), so removing deletedByUserUsername from that view also removed it from the model's serialized shape. That field carried the deleter's raw username (their e-mail) under a non-`username` name on the KEYS_VIEW-gated key-search/trash response — a disclosure the SimpleUserAccountModel-based analysis had missed. Regenerate the frontend schema to match.
…he allowlist bindings Round-14 review: - Give RevealedApiKeyModel and ApiKeyWithLanguagesModel an explicit empty username override so the @Deprecated/@Schema reaches the generated schema (Kotlin interface delegation does not propagate it); regenerate the schema so all five stripped models advertise the deprecation. - Cover the guard's declaresUsername predicate directly (positive and negative) now that it replaced the settable check, and assert the value-tested set stays in sync with strippedModelNames. - Record each allowlisted model's endpoint + required scope next to its allowlist entry, so a future author reusing an e-mail-bearing model on a new endpoint has a local signal. - Trim the guard/value-test/view docs to the load-bearing lines.
Drop the value-test narration (the sync rationale moves into the assertion's failure message), the duplicated "explicit override" comments on the two api-key models (the schema-check job already fails if the override is dropped), and the guard header's self-restating first sentence. Rename the EE entrypoint UsernameDisclosureGuardTest -> UsernameDisclosureGuardEeTest so it reads as the twin of UsernameDisclosureGuardCoreTest.
Anty0
force-pushed
the
jirikuchynka/strip-username-disclosure
branch
from
July 26, 2026 01:50
e9aeaf0 to
c0467c1
Compare
Anty0
merged commit Jul 26, 2026
34a4bce
into
jirikuchynka/community-contributors
72 of 74 checks passed
This was referenced Jul 26, 2026
Anty0
added a commit
that referenced
this pull request
Jul 26, 2026
…rs list (#3823) Strip a user's `username` (which is their e-mail) from every API response except the surfaces where disclosure is legitimate: after this change one user can see another's `username` only where the caller holds `MEMBERS_VIEW` AND the target is a current member (plus org-members list, self, instance-admin, and the admin-only export ZIP). `SecurityService.maskedMemberField` gated only on the caller's `MEMBERS_VIEW`, never on target membership, and failed open off-request/off-project — so project admins on public projects saw community contributors' and ex-members' raw e-mails, and non-project-scoped endpoints leaked to anyone. Stripping the field makes the rule hold structurally instead of re-checking membership on ~11 author-bearing surfaces. `username` is now a deprecated always-empty field on the stripped models (kept on the wire so clients don't break); a classpath-scanning `UsernameDisclosureGuard` test fails CI if any non-sanctioned response model reintroduces it. Frontend already prefers name + avatar for identity off the members/org/admin surfaces.
Anty0
added a commit
that referenced
this pull request
Jul 27, 2026
…rs list (#3823) Strip a user's `username` (which is their e-mail) from every API response except the surfaces where disclosure is legitimate: after this change one user can see another's `username` only where the caller holds `MEMBERS_VIEW` AND the target is a current member (plus org-members list, self, instance-admin, and the admin-only export ZIP). `SecurityService.maskedMemberField` gated only on the caller's `MEMBERS_VIEW`, never on target membership, and failed open off-request/off-project — so project admins on public projects saw community contributors' and ex-members' raw e-mails, and non-project-scoped endpoints leaked to anyone. Stripping the field makes the rule hold structurally instead of re-checking membership on ~11 author-bearing surfaces. `username` is now a deprecated always-empty field on the stripped models (kept on the wire so clients don't break); a classpath-scanning `UsernameDisclosureGuard` test fails CI if any non-sanctioned response model reintroduces it. Frontend already prefers name + avatar for identity off the members/org/admin surfaces.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Strip a user's
username(which is their e-mail address) from every API responseexcept the surfaces where disclosure is legitimate. After this change the only place one user
can see another user's
usernameis where both of these hold by construction:MEMBERS_VIEW, andEverywhere else — activity feed, translation suggestions/comments/history, tasks, batch-job
author, notifications, key-trash "deleted by", branches, API-key owner, the XLSX task report —
usernameis now always empty and the field is marked deprecated (kept on the wire so existingclients don't break).
Why the previous masking was not enough
SecurityService.maskedMemberFieldgated only on the caller havingMEMBERS_VIEW. It couldnot satisfy the rule above:
(who has
MEMBERS_VIEW) opening the suggestions list / activity feed / comment thread / historysaw the raw e-mail of community contributors and ex-members who authored those rows — exactly
the disclosure we want to prevent.
shouldExposeMemberInfo()returnstruewhen there is no request scope or noproject in scope, so non-project-scoped endpoints (Notifications
originatingUser,/v2/user-tasksco-assignees) leaked usernames to anyone.A membership-aware masking fix would need a per-author "is currently a member" check on ~11
surfaces — expensive, N+1-prone, and re-opened by every new author-bearing surface. Stripping the
field instead makes the rule hold structurally and cannot drift.
Kept (legitimate disclosure — unchanged)
UserAccountInProjectModel) — the one intended surface.UserAccountWithOrganizationRoleModel) — org-scoped, kept as an exemption.PrivateUserAccountModel) — your own e-mail.UserAccountModel, super-auth gated).UserRef(username)) — admin-only, and import re-links authors byusername, so it must stay.
Changes
SimpleUserAccountModel.username→ deprecated constant""(removed from the constructor so noassembler can repopulate it). This one model backs ~16 assemblers (suggestions, comments, tasks,
batch, notifications, key-trash, branches, PAT-with-user, invitations), so it closes most surfaces
— including the two fail-open leaks — at once.
ProjectActivityAuthorModel.usernameandApiKeyModel.username→ deprecated, always"".TaskReportHelper(XLSX task report) no longer embeds the e-mail — name only.SecurityService.maskedMemberField(keptshouldExposeMemberInfo, stillused for the members-count gate).
@deprecatedon the threeusernamefields; field typeunchanged, so the webapp still compiles — display code already prefers
name).Notes
never carried a username.
name(+ avatar) is already preferred for identity everywhere offthe members/org/admin surfaces, which keep
username.