Uh oh!
There was an error while loading. Please reload this page.
Fix the credential rank rule being bypassable through custom_fields - #691
Conversation
Three-reviewer round on this PR (Kimi K3, GLM-5.2, both at max effort, plus an
Opus agent) found that the takeover this line of work claims to close was still
open, by a route none of the context rules can see.
`user_form.ex` took `params["custom_fields"]` verbatim and handed it to
`Auth.update_user_fields/2`, which resolves each key with
`String.to_existing_atom/1` and writes `:email` / `:username` straight into the
schema through `profile_changeset` — no confirmation-token flow. The defence
added with the rank rule is a `Map.drop` over `profile_params`; it never saw
`custom_fields`. Reproduced end to end: an Admin submitting
`user[custom_fields][email]` on an Owner's edit form rewrote the Owner's
address, after which the public password-reset page hands over the account.
The five schema identity names are now dropped from `custom_fields`
unconditionally, not only when the actor is out of rank: the form renders a real
input for each of them, so such a key is never a legitimate submission from this
page, and an unconditional filter cannot go stale the way an authority assign
computed at mount can.
Two smaller defects from the same round, both introduced by the previous commit:
- The write path asked `@can_manage_credentials`, computed once in `mount/3`,
while the context re-evaluated the rule at write time. A target promoted
between mount and submit made the two disagree: the form wrote the credential
fields on the old answer and then took `{:error, :insufficient_permissions}`
from the context, which the clause below expects to be a changeset —
`merge_password_errors/2` called `.errors` on an atom and the LiveView died,
after a partial write. The write path now re-asks the context, against the
target reloaded from the database: the mounted struct carries a preloaded
`:roles` list, so re-asking with it would only agree with itself. Both the
inner `case` and the outer `with` gained explicit clauses for the atom.
- `admin_update_user_password/3` treated any non-`%User{}` `:admin_user` as the
system path. A map decoded from JSON by a host controller, or a bare uuid
string, took the unchecked branch — and then crashed in the audit write. Only
an absent actor is the system path now; a malformed one is refused.
Tests: the bypass is pinned at the form, where the params arrive, by pushing the
event straight at the LiveView — `form/3` refuses params with no matching
rendered input, which is exactly the gap an attacker uses. The stale-assign test
carries `email` in its payload on purpose: that is what makes it discriminating,
and reverting only the reload turns it red (verified, observed
"attacker@example.com"). Suite: 372 tests in test/integration/users, 0 failures,
no excluded count.…laimed
Follow-up to the third reviewer's round on this PR, which landed after the first
two and found things they did not.
A refusal left no trace anywhere — no Logger, no Activity, no AuditLog — while
the same module logs a far milder one ("Attempted to deactivate last Owner").
The whole argument for moving this rule into the context is the caller that does
not exist yet; when it arrives and is blocked, that has to be visible rather than
present as a silent no-op. Both refusal branches now warn, and the malformed
-actor branch says which of the two it was.
Three tests were named for "a non-staff actor holding only a permission" and
handed the predicate a bare default-role user. They passed, but for a reason
that does not distinguish the two cases: the rank rule refuses an actor with no
staff role whether or not it holds a permission. The threat model this series
exists for — a role holding `users` and nothing else, which is exactly what
admits a visitor to /admin/users — was never constructed anywhere in the file.
`users_permission_holder/0` now builds it: a fresh role, `users` granted through
`Permissions.grant_permission/2`, assigned to a confirmed user. If
`validate_admin_authority_over/2` ever starts consulting `Permissions`, these
tests will notice; before this they could not.
Suite: 372 tests in test/integration/users, 0 failures, no excluded count.
format, compile --warnings-as-errors and credo --strict (10114 mods/funs) clean.
Carried forward, not addressed here and recorded in the PR thread: the same
reviewer's finding that /admin/users/sessions revokes any account's sessions
with no rank check and no audit row (pre-existing, needs no second caller); that
`update_user_profile/2` should carry the rank rule in the context rather than
only at its caller; that `cmd mix hex.audit` turns a deferred exit code into an
immediate alias abort; and the CHANGELOG Security entry, which is maintainer-
owned in this repo.Post-merge review of #691 (credential rank rule bypassable through custom_fields). The vulnerability is real and correctly closed; five defects found in how it was closed. - refuse_credential_write/2 and the malformed-actor branch both assumed a %User{} target, but admin_update_user_password/3 takes it unguarded and can_manage_user_credentials?/2 answers false rather than raising for a non-User. A host passing a JSON-decoded map as the target got a FunctionClauseError / KeyError where the pre-#691 code returned {:error, :insufficient_permissions} — the fail-closed path crashing. - The form's drop list was a second hand-maintained copy of the list inside update_user_fields/2. Both now read Auth.updatable_profile_fields/0. - credential_authority_now/2's comment claimed the mounted struct carries a preloaded :roles list. get_user!/1 does not preload, has_system_role?/2 therefore queries, and Roles.user_has_role?/2 keys on the uuid alone — the rank answer was already fresh. Comment corrected to what the reload buys. - The new test's "revert the reload and this assertion goes red" rested on the same wrong premise. Corrected to claim only what it pins. - The form dropped the poisoned keys silently while the context logs its refusal, though a custom_fields[email] on the wire is the stronger signal. Tests: malformed target refuses cleanly; every field in updatable_profile_fields/0 is dropped from custom_fields; a legitimate custom field still saves alongside the filter. Review: dev_docs/pull_requests/2026/691-fix-credential-bypass-custom-fields/CLAUDE_REVIEW.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timujinne
left a comment
There was a problem hiding this comment.
Post-merge review — credential bypass via custom_fields (Track A, 3-pass)
Three-pass iterative review (broad → deepen → verify/narrow) on merge commit 2eadb6e9. Verdict: the bypass this PR targets is correctly closed, at both sinks. Below: what's verified closed, what #692 already fixed, and the same-series siblings left open — those are the actionable follow-ups.
Verified CLOSED by #691 (confirmed against merged tree)
custom_fields[email]/[username]sink —drop_schema_identity_fields/1(user_form.ex:560, applied at:760) drops the five identity names before they reachupdate_user_fields/2. Deny-list is exactly the setupdate_user_fields/2routes intoprofile_changeset(auth.ex:1836).profile_paramssink —Map.drop(["password","email","username"])for out-of-rank actors, gated oncredential_authority_now/2target reload (user_form.ex:990).- Negative results (worth re-checking but currently clean):
confirmed_at,is_active,hashed_passwordare NOT bypassable viacustom_fields— gated byconfirm_changeset/status_changeset/password_changeset, none inprofile_changeset's cast list. Self-serviceuser_settings.expath is safe (actor == target).
Already fixed by #692
- F-1 (refusal path crash on malformed target, both actor branches) —
refuse_credential_write/2at current HEAD takes the target unguarded and uses a safe label. ✅ - F-4 partially (deny-list duplication) —
user_form.exnow readsAuth.updatable_profile_fields/0. Parametric tests for all five names still outstanding.
🔴 Same-series siblings still fail-open (not a #691 regression — introduced together in c9413b86, #691 hardened only the password path)
Both update_user_status/3 (auth.ex:2221) and toggle_user_confirmation/3 (auth.ex:1343) keep the _system -> shape that performs the unchecked write for ANY non-%User{} actor:
caseKeyword.get(opts,:actor)do%User{}=actor->ifcan_manage_...(user,actor),do: ...,else: {:error,:insufficient_permissions}_system->do_...(user)# ← fires for %{"uuid" => ...}, a uuid string, false, 42endtoggle_user_confirmation/3is the more serious one: unchecked path →admin_unconfirm_user/1→confirmed_at = nil→ target locked out of all confirmation-gated pages (DoS / mass-lockout).grep toggle_user_confirmationinsecurity_authority_test.exs→ 0 matches — no test for the confirmation rank rule at all.update_user_status/3— same shape, less severe (reversible status change).- Neither is reachable from core (all LV callers pass a real
%User{}actor;referrals.exintentionally omits:actorfor cron) — host-only risk, but same authority-bypass class this PR set out to close. - No malformed-actor test exists for either. The word "malformed" appears in
security_authority_test.exsonly at line 285 (the password test). A regression reintroducing_system ->in either sibling would pass green.
Suggested follow-up PR: mirror the password 3-branch restructure in both siblings + add malformed-actor tests. Same pattern as #691, ~30 lines + tests.
Other findings (lower severity, for the backlog)
- F-3 · MEDIUM —
update_user_profile/2,update_user_fields/2,bulk_update_user_fields/2(auth.ex:1623/1834/2003) are public sinks with no actor param. Rank rule lives in callers, not the sink. Latent host surface — any host building attrs from request params can write identity fields with no confirmation flow. Pushing:actorinto the sinks is the bigger fix. - F-6 · LOW — refusal is
Logger.warning-only; success path writesAuditLog+Activity.log. A takeover attempt leaves no DB trace. OptionalActivity.log(action: "user.credential_write_refused"). - F-9 · LOW · pre-existing —
update_profile_and_password/2(user_form.ex:1000) does two independent writes; if the password step fails (incl. the new:insufficient_permissionsclause this PR added), the email/username change from step 1 is already persisted. NoEcto.Multi. Predates #691. - F-10 · LOW —
credential_authority_now/2reloads the target but uses the stale mounted actor. A demoted Admin keeps their preloaded role for the rest of the session. Inherent to session auth; at minimum document the target-side-only freshness. - F-11 · LOW — stale-authority test asserts
hashed_password/emailunchanged but notfirst_name == "Renamed"; a regression dropping the whole profile write would pass.
Note on method
This was a three-pass single-track review (broad → deepen → verify/narrow). An independent second track would add the most value precisely on F-2/F-7 (the sibling fail-open functions) and on re-verifying the negative results. Full artifact trace in our internal review directory.
timujinne
commented
Aug 9, 2026
проверка публикации (test, будет удалён) |
timujinne
commented
Aug 9, 2026
Post-merge review — credential bypass via |
Post-merge review of #691 found the actor-shape hole closed for `admin_update_user_password/3` still open in its two siblings. `update_user_status/3` and `toggle_user_confirmation/2` both ended in a `_system ->` catch-all that performed the UNCHECKED write. That branch exists for callers with no actor — seeds, mix tasks, and `Users.Referrals`, which expires an account deliberately without one — but it also swallowed every malformed actor: a map decoded from JSON by a host controller, a bare uuid string, `false`, `42`. Each took the unchecked path and wrote. `toggle_user_confirmation/2` is the serious one: its unchecked path reaches `admin_unconfirm_user/1`, so `confirmed_at` is cleared and the target is locked out of every confirmation-gated page. `update_user_status/3` is the same shape with a reversible effect. Neither is reachable from core — every LiveView caller passes a real `%User{}` — so this is host-facing surface, and it is the same authority-bypass class the series set out to close. Both now use the three-branch shape from `admin_update_user_password/3`, including the refusal log. An explicit `actor: nil` still takes the system path, identically in all three: `Map.get`/`Keyword.get` cannot tell it from an absent key, and making it fail closed is a decision for all three at once, not one. Tests. The malformed-actor cases are the two that carry the fix — verified by reverting `auth.ex` and re-running: exactly those two go red. The six new `toggle_user_confirmation/2` tests survive that revert and are stated here as what they are — characterization coverage for a rule that had none, since `toggle_user_confirmation` appeared zero times in this file before. They pin behaviour; they do not defend this hole. Also fixes a test in `user_form_authority_test.exs` that had been red on `main` and, underneath that, vacuous. It submitted `"first_name" => "Harmless"` as harness scaffolding while asserting `first_name` was unchanged — an Admin editing an Owner may legitimately set it, so the test failed on its own input. Removing that revealed the deeper problem: it poisoned all five identity fields in ONE payload, `Auth.update_user_fields/2` routes them into a single `profile_changeset`, and the generated `"attacker-user_timezone"` fails that field's format validation — one invalid member made the whole changeset invalid, so nothing was written whether the filter ran or not. Confirmed by deleting `drop_schema_identity_fields/2`'s call and watching the test still pass. It now submits one poisoned-but-schema-valid field per submission, so no single value can abort the others, and goes red when the filter is removed. test/integration/users — 383 tests, 0 failures, no excluded count. format --check-formatted, compile --warnings-as-errors and credo --strict (10298 mods/funs) clean.
Follow-up to #690, which merged before this landed. The takeover #686 and #690
set out to close is still open on
maintoday — this closes it.The bypass
user_form.extookparams["custom_fields"]verbatim and handed it toAuth.update_user_fields/2, which resolves each key withString.to_existing_atom/1and, when the name matches[:first_name, :last_name, :email, :username, :user_timezone], writes itinto the schema through
profile_changeset— no confirmation-token flow.The credential defence from #686 is a
Map.dropoverprofile_params. It neversaw
custom_fields.Do X: as an Admin (or any holder of the
userspermission), open an Owner'sedit form — the page correctly refuses you, the password toggle is not even
rendered — then push the form's own event:
Observe Y: the Owner's
emailcolumn is rewritten. Public password-reset page,and the account is gone.
"username"goes the same way, removing the victim'ssecond sign-in route.
Found by a three-reviewer round on #690 (Kimi K3, GLM-5.2, and an Opus agent
that reproduced it end to end against a live database). Reproduced here as a
LiveView test before the fix.
What changed
custom_fieldsunconditionally, not only when the actor is out of rank. The form renders a
real input for each, so such a key is never a legitimate submission from this
page — and an unconditional filter cannot go stale the way an authority assign
computed at mount can.
database, instead of reading
@can_manage_credentialsfrommount/3. Atarget promoted between mount and submit made the form and the context
disagree: the form wrote the credential fields on the old answer, then took
{:error, :insufficient_permissions}from the context, which the clause belowexpects to be a changeset —
merge_password_errors/2called.errorson anatom and the LiveView died, after a partial write. The reload is load-bearing:
the mounted struct carries a preloaded
:roleslist, so re-asking with itwould only agree with itself.
:admin_userthat is presentbut not a
%User{}— a map decoded from JSON by a host controller, a bare uuidstring — took the unchecked branch, and then crashed in the audit write.
whole series is about;
do_deactivate_user/2already set the precedent.users_permission_holder/0builds the actor three existing tests werenamed for and never created: a role holding
usersand no staff rank. Theypassed for a reason that could not distinguish it from a bare default-role
user.
Tests
The bypass is pinned at the form, where the params arrive, by pushing the event
straight at the LiveView —
form/3refuses params with no matching renderedinput, which is exactly the gap the attack uses.
The stale-assign test carries
emailin its payload on purpose: that is whatmakes it discriminating. Reverting only the reload turns it red (verified —
observed
attacker@example.com).test/integration/users— 372 tests, 0 failures, noexcludedlinemix format --check-formatted,mix compile --warnings-as-errors,mix credo --strict(10114 mods/funs) — clean@versionandCHANGELOG.mduntouched. The reviewers also asked for a### Securityentry; that is maintainer-owned here, so it is flagged ratherthan written.
Not in this PR
Recorded in the #690 thread and still open:
/admin/users/sessionsrevokes anyaccount's sessions with no rank check and no audit row (pre-existing, needs no
second caller);
update_user_profile/2should carry the rank rule in thecontext rather than only at its caller;
cmd mix hex.auditturns a deferredexit code into an immediate alias abort.