payloadcms / payloadcms/payload

Default `unlock` access lets any authenticated user unlock any other account (CVE-2026-11779) — and the access function receives no `data`

Open
#18,110 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area: core Bug status: needs-triage v3
Dominant language
TypeScript
Stars
44.8k
Forks
4.2k
Avg merge
2d 21h
Merged PRs (30d)
53

Description

Describe the Bug

What happens

On an auth-enabled collection, access.unlock defaults to defaultAccess, i.e. ({ req: { user } }) => Boolean(user) (packages/payload/src/collections/config/defaults.ts, packages/payload/src/auth/defaultAccess.ts). So any authenticated user of any auth collection — a low-privilege editor, for instance — can call POST /api/<auth-collection>/unlock with someone else's email and reset that account's loginAttempts / lockUntil. The brute-force protection (maxLoginAttempts / lockTime) can therefore be defeated by an attacker who holds any valid account: lock-outs on the accounts they are guessing can be cleared at will.

This is the behaviour described by CVE-2026-11779 / GHSA-jg8r-5jh2-v2xj (published 2026-06-26, "unreviewed", no patched version listed). I could not find an issue or a PR tracking it, so here it is. Checked on the installed payload@3.88.0 (latest at the time of writing) and on main.

A second, related limitation makes it hard to write a safe rule oneself: unlockOperation calls executeAccess({ slug, req }, collectionConfig.access.unlock) (packages/payload/src/auth/operations/unlock.ts) — the access function never receives data (the targeted email / username). So a rule like "a user may only unlock themselves" or "only accounts in my team" cannot be expressed; the only workable rule is role-based or () => false. This is also what #14459 is about.

Proposal (happy to open the PR):

  1. Change the default for unlock: it should not be Boolean(user). The safest default is to deny unless configured (() => false) — or, less strict, a documented default that requires an explicit opt-in. Since this changes behaviour, I understand it targets v4; a v3 patch could keep the default but log a warning when unlock is left unconfigured.
  2. Pass data (the sanitized email / username being unlocked) to the unlock access function, like other operations pass data, so per-target rules become possible.

Workaround for existing projects (what we did): set access.unlock explicitly on every auth collection, e.g. restricted to an admin role.

I would appreciate a word from the maintainers on the preferred target (3.x patch vs v4 default change) before opening the PR.

Link to the code that reproduces this issue

https://github.com/payloadcms/payload/blob/main/packages/payload/src/collections/config/defaults.ts#L16-L22

Reproduction Steps

No custom code is needed: the behaviour comes from the defaults linked above. On a blank project (pnpx create-payload-app@latest -t blank, the default users auth collection, no access configured):

  1. Create three users: admin@example.com, victim@example.com, attacker@example.com (any roles — the collection has none by default).
  2. Lock victim by failing the login maxLoginAttempts times (default 5): POST /api/users/login with a wrong password until the response says the account is locked. In the database, loginAttempts = 5 and lockUntil is set.
  3. Log in as attacker (POST /api/users/login) and keep the payload-token cookie.
  4. With that cookie, call POST /api/users/unlock with body { "email": "victim@example.com" }.

Expected: the request is refused — attacker has no business unlocking another account.
Actual: the access check passes (Boolean(req.user) is true for any logged-in user), resetLoginAttempts runs, and victim's loginAttempts / lockUntil are reset — the brute-force protection is gone. Reading of unlockOperation: the only guard before the reset is executeAccess({ slug, req }, collectionConfig.access.unlock).

Same result with any authenticated user of any auth collection.

Second point (no data passed to the access function): set access.unlock: ({ req, data }) => data?.email === req.user?.email on the collection — data is undefined in the unlock access call, so the rule cannot be expressed (see #14459).

Which area(s) are affected?

area: core

Environment Info
payload: 3.88.0 (defaults unchanged on main)
@payloadcms/db-postgres: 3.88.0
@payloadcms/next: 3.88.0
next: 16.3.1
node: 22.x
OS: Linux (Docker) / Windows 11 dev

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with packages/payload/src/collections/config/defaults.ts, packages/payload/src/auth/defaultAccess.ts, and packages/payload/src/auth/operations/unlock.ts to trace the default access check and its arguments. Reproduce the behavior with the documented login and POST /api/users/unlock steps, then verify the chosen version/default policy prevents unauthorized unlocks and makes the sanitized target data available to the access function.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
authentication, backend, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.