fix(account): authorize mergeSpecifiedPersons / canMergeSpecifiedPersons - #10997
Merged
ArtyomSavchenko merged 2 commits intoJul 27, 2026
Merged
Conversation
Both operations decoded the caller token and discarded the result, so any authenticated caller could merge any two persons by uuid. Merging re-points the secondary person's social ids onto the primary one, and neither login nor password recovery require a social id to be verified, so this reached as far as taking over an arbitrary account. Authorize both behind verifyMergePersonsAuthority: - global admin tokens and the tool/workspace services pass, matching the account level mergeSpecifiedAccounts; - everybody else must maintain the workspace their token carries, and both persons must be within its reach: a person holding an account elsewhere, and the platform wide system and guest accounts, are refused; - a login capable social id may not move onto an account the caller does not own. doMergePersons only refuses verified secondary social ids, which leaves the unverified ones a maintainer could mint for themselves. canMergeSpecifiedPersons answers false instead of throwing: it is the predicate the merge dialog polls, and it awaits it without a catch. Claude-Session: https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7 Signed-off-by: Don Kendall <dkendall@ledoweb.com>
Nineteen cases over both operations: the reported attack shape, the maintainer to owner escalation through an unverified email, foreign accounts on either side of the merge, the platform guest account, and the paths that must keep working (workspace contacts, members, tool service and admin tokens). Removing the authorization check fails eight of them. Claude-Session: https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7 Signed-off-by: Don Kendall <dkendall@ledoweb.com>
dnplkndll
force-pushed
the
fix/account-merge-persons-authz
branch
from
July 26, 2026 17:53
aed08a2 to
3a0be9b
Compare
ArtyomSavchenko
approved these changes
Jul 27, 2026
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.
Fixes #10914. (Reported publicly in that issue, before this PR — hence a public fix rather than a private advisory.)
Problem
mergeSpecifiedPersonsandcanMergeSpecifiedPersonscalleddecodeTokenVerboseand threw the result away, so the only requirement was a well-formed token from any authenticated user.The impact is worse than "two persons get merged".
doMergePersonsre-points the secondary person's social ids onto the primary person, and it only refuses when a secondary social id is verified — the primary side is never inspected. MeanwhilegetEmailSocialId(server/account/src/utils.ts) looks an account up by social id value alone, with noverifiedOnfilter, andrequestPasswordResetthen resolvesgetAccount(emailSocialId.personUuid). So a caller who merges a person carrying an unverified email of their own into a victim's person can request a password reset for the victim's account and receive the link themselves. Persons carrying only unverified social ids are readily obtainable (an unconfirmed signup, orensurePerson), and person uuids are visible to workspace members.Fix
Both operations now pass through
verifyMergePersonsAuthority:tool/workspaceservices pass — mirroring the already-gated siblingmergeSpecifiedAccountsinserviceOperations.ts, and keeping the migration/tooling callers (dev/tool,models/contactmigration) working. This check precedes the workspace check because such tokens carry no workspace.Maintainerrole-power bar.EMAIL,HULY) may not move onto an account the caller does not own. Without this, rule 2 still permits a Maintainer to escalate to any co-member — including the workspace Owner — via the unverified-email path above, which is precisely the escalationupdateWorkspaceRoleforbids elsewhere.canMergeSpecifiedPersonsreturnsfalseinstead of throwing. It is a predicate thatMergePersons.sveltepolls on every selection change and awaits without a catch; throwing would leave the dialog spinning on a permanently disabled Save button with an unhandled rejection.mergeSpecifiedPersonsstill throwsForbidden, so the two cannot drift.Why not "the caller must be one of the merged persons"
That suggestion from the issue would break the only interactive caller.
MergePersons.sveltemerges two other people's persons — a duplicate contact into an employee — using the workspace-scoped token thatworkbench-resources/src/connect.tsinstalls. TheMaintainerbar chosen here matches the bar the shipped UI already enforces client-side for this action (secured: true).Scope and residual risk
tooltoken remains the path for it.requestPasswordReset,loginOtp/validateOtpandconfirmEmailall treatsocialId.personUuidas authoritative account identity without requiring verification. This PR fences the merge primitive; it does not fix that underlying assumption.Verification
rush validate --to @hcengineering/account— clean;rush format— no changes.rushx testinserver/account— 499 passed. (The 4postgres-realfailures need a live Postgres/CockroachDB and fail identically on a clean checkout.)verifyMergePersonsAuthorityto returntruefails 8 of them, so the negative tests pin real behavior rather than passing vacuously.No UI or client changes, so there is nothing to screenshot.
https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7