Skip to content

fix(account): authorize mergeSpecifiedPersons / canMergeSpecifiedPersons - #10997

Merged
ArtyomSavchenko merged 2 commits into
hcengineering:developfrom
ledoent:fix/account-merge-persons-authz
Jul 27, 2026
Merged

fix(account): authorize mergeSpecifiedPersons / canMergeSpecifiedPersons#10997
ArtyomSavchenko merged 2 commits into
hcengineering:developfrom
ledoent:fix/account-merge-persons-authz

Conversation

@dnplkndll

@dnplkndll dnplkndll commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #10914. (Reported publicly in that issue, before this PR — hence a public fix rather than a private advisory.)

Problem

mergeSpecifiedPersons and canMergeSpecifiedPersons called decodeTokenVerbose and 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". doMergePersons re-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. Meanwhile getEmailSocialId (server/account/src/utils.ts) looks an account up by social id value alone, with no verifiedOn filter, and requestPasswordReset then resolves getAccount(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, or ensurePerson), and person uuids are visible to workspace members.

Fix

Both operations now pass through verifyMergePersonsAuthority:

  1. Global admin tokens and the tool/workspace services pass — mirroring the already-gated sibling mergeSpecifiedAccounts in serviceOperations.ts, and keeping the migration/tooling callers (dev/tool, models/contact migration) working. This check precedes the workspace check because such tokens carry no workspace.
  2. Everybody else must maintain the workspace their token carries, and both persons must be in its reach: a person holding an account that is not a member of that workspace is refused, as are the platform-wide system and read-only-guest accounts. Guest and below are excluded by the existing Maintainer role-power bar.
  3. A login-capable social id (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 escalation updateWorkspaceRole forbids elsewhere.

canMergeSpecifiedPersons returns false instead of throwing. It is a predicate that MergePersons.svelte polls 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. mergeSpecifiedPersons still throws Forbidden, 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.svelte merges two other people's persons — a duplicate contact into an employee — using the workspace-scoped token that workbench-resources/src/connect.ts installs. The Maintainer bar chosen here matches the bar the shipped UI already enforces client-side for this action (secured: true).

Scope and residual risk

  • The merge dialog's mainstream flow (duplicate contact → employee) is unaffected: such persons hold no account and typically no global social ids.
  • Deliberately no longer allowed for a plain Maintainer: merging a person whose account exists but is not a member of that workspace (e.g. a departed employee's duplicate). The account service cannot see workspace contact documents, so membership is the only scope signal available; an admin or tool token remains the path for it.
  • Not addressed here, and worth a separate look: requestPasswordReset, loginOtp/validateOtp and confirmEmail all treat socialId.personUuid as 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 test in server/account — 499 passed. (The 4 postgres-real failures need a live Postgres/CockroachDB and fail identically on a clean checkout.)
  • 19 new cases across both operations. Mutation-checked: stubbing verifyMergePersonsAuthority to return true fails 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

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
dnplkndll force-pushed the fix/account-merge-persons-authz branch from aed08a2 to 3a0be9b Compare July 26, 2026 17:53
@ArtyomSavchenko
ArtyomSavchenko merged commit afe11fa into hcengineering:develop Jul 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Authorization check missing in mergeSpecifiedPersons and

2 participants