Skip to content

Fix the credential rank rule being bypassable through custom_fields - #691

Merged
ddon merged 3 commits into
BeamLabEU:mainfrom
timujinne:fix/credential-bypass-custom-fields
Aug 9, 2026
Merged

Fix the credential rank rule being bypassable through custom_fields#691
ddon merged 3 commits into
BeamLabEU:mainfrom
timujinne:fix/credential-bypass-custom-fields

Conversation

@timujinne

Copy link
Copy Markdown
Contributor

Follow-up to #690, which merged before this landed. The takeover #686 and #690
set out to close is still open on main today
— this closes it.

The bypass

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, when the name matches
[:first_name, :last_name, :email, :username, :user_timezone], writes it
into the schema through profile_changeset — no confirmation-token flow.

The credential defence from #686 is a Map.drop over profile_params. It never
saw custom_fields.

Do X: as an Admin (or any holder of the users permission), open an Owner's
edit form — the page correctly refuses you, the password toggle is not even
rendered — then push the form's own event:

render_submit(view,"save_user",%{"user"=>%{"custom_fields"=>%{"email"=>"attacker@example.com"}}})

Observe Y: the Owner's email column is rewritten. Public password-reset page,
and the account is gone. "username" goes the same way, removing the victim's
second 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

  • The five schema identity names are dropped from custom_fields
    unconditionally
    , 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.
  • The write path re-asks the rank rule against the target reloaded from the
    database
    , instead of reading @can_manage_credentials from mount/3. A
    target 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 below
    expects to be a changeset — merge_password_errors/2 called .errors on an
    atom and the LiveView died, after a partial write. The reload is load-bearing:
    the mounted struct carries a preloaded :roles list, so re-asking with it
    would only agree with itself.
  • Only an absent actor is the system path. A :admin_user that is present
    but not a %User{} — a map decoded from JSON by a host controller, a bare uuid
    string — took the unchecked branch, and then crashed in the audit write.
  • Refusals are logged. A guard nobody can tell fired is the failure mode this
    whole series is about; do_deactivate_user/2 already set the precedent.
  • users_permission_holder/0 builds the actor three existing tests were
    named for and never created: a role holding users and no staff rank. They
    passed 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/3 refuses params with no matching rendered
input, which is exactly the gap the attack uses.

The stale-assign test carries email in its payload on purpose: that is what
makes it discriminating. Reverting only the reload turns it red (verified —
observed attacker@example.com).

  • test/integration/users372 tests, 0 failures, no excluded line
  • mix format --check-formatted, mix compile --warnings-as-errors,
    mix credo --strict (10114 mods/funs) — clean

@version and CHANGELOG.md untouched. The reviewers also asked for a
### Security entry; that is maintainer-owned here, so it is flagged rather
than written.

Not in this PR

Recorded in the #690 thread and still open: /admin/users/sessions revokes any
account's sessions with no rank check and no audit row (pre-existing, needs no
second caller); update_user_profile/2 should carry the rank rule in the
context rather than only at its caller; cmd mix hex.audit turns a deferred
exit code into an immediate alias abort.

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.
@ddon
ddon merged commit 2eadb6e into BeamLabEU:mainAug 9, 2026
ddon pushed a commit that referenced this pull request Aug 9, 2026
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>

@timujinnetimujinne left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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] sinkdrop_schema_identity_fields/1 (user_form.ex:560, applied at :760) drops the five identity names before they reach update_user_fields/2. Deny-list is exactly the set update_user_fields/2 routes into profile_changeset (auth.ex:1836).
  • profile_params sinkMap.drop(["password","email","username"]) for out-of-rank actors, gated on credential_authority_now/2 target reload (user_form.ex:990).
  • Negative results (worth re-checking but currently clean): confirmed_at, is_active, hashed_password are NOT bypassable via custom_fields — gated by confirm_changeset/status_changeset/password_changeset, none in profile_changeset's cast list. Self-service user_settings.ex path is safe (actor == target).

Already fixed by #692

  • F-1 (refusal path crash on malformed target, both actor branches) — refuse_credential_write/2 at current HEAD takes the target unguarded and uses a safe label. ✅
  • F-4 partially (deny-list duplication) — user_form.ex now reads Auth.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, 42end
  • toggle_user_confirmation/3 is the more serious one: unchecked path → admin_unconfirm_user/1confirmed_at = nil → target locked out of all confirmation-gated pages (DoS / mass-lockout). grep toggle_user_confirmation in security_authority_test.exs0 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.ex intentionally omits :actor for 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.exs only 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 · MEDIUMupdate_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 :actor into the sinks is the bigger fix.
  • F-6 · LOW — refusal is Logger.warning-only; success path writes AuditLog + Activity.log. A takeover attempt leaves no DB trace. Optional Activity.log(action: "user.credential_write_refused").
  • F-9 · LOW · pre-existingupdate_profile_and_password/2 (user_form.ex:1000) does two independent writes; if the password step fails (incl. the new :insufficient_permissions clause this PR added), the email/username change from step 1 is already persisted. No Ecto.Multi. Predates #691.
  • F-10 · LOWcredential_authority_now/2 reloads 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/email unchanged but not first_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

Copy link
Copy Markdown
ContributorAuthor

проверка публикации (test, будет удалён)

@timujinne

Copy link
Copy Markdown
ContributorAuthor

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] sinkdrop_schema_identity_fields/1 (user_form.ex:560, applied at :760) drops the five identity names before they reach update_user_fields/2. Deny-list is exactly the set update_user_fields/2 routes into profile_changeset (auth.ex:1836).
  • profile_params sinkMap.drop(["password","email","username"]) for out-of-rank actors, gated on credential_authority_now/2 target reload (user_form.ex:990).
  • Negative results (worth re-checking but currently clean): confirmed_at, is_active, hashed_password are NOT bypassable via custom_fields — gated by confirm_changeset/status_changeset/password_changeset, none in profile_changeset's cast list. Self-service user_settings.ex path is safe (actor == target).

Already fixed by #692

  • F-1 (refusal path crash on malformed target, both actor branches) — refuse_credential_write/2 at current HEAD takes the target unguarded and uses a safe label. ✅
  • F-4 partially (deny-list duplication) — user_form.ex now reads Auth.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, 42end
  • toggle_user_confirmation/3 is the more serious one: unchecked path → admin_unconfirm_user/1confirmed_at = nil → target locked out of all confirmation-gated pages (DoS / mass-lockout). grep toggle_user_confirmation in security_authority_test.exs0 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.ex intentionally omits :actor for 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.exs only 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 · MEDIUMupdate_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 :actor into the sinks is the bigger fix.
  • F-6 · LOW — refusal is Logger.warning-only; success path writes AuditLog + Activity.log. A takeover attempt leaves no DB trace. Optional Activity.log(action: "user.credential_write_refused").
  • F-9 · LOW · pre-existingupdate_profile_and_password/2 (user_form.ex:1000) does two independent writes; if the password step fails (incl. the new :insufficient_permissions clause this PR added), the email/username change from step 1 is already persisted. No Ecto.Multi. Predates Fix the credential rank rule being bypassable through custom_fields #691.
  • F-10 · LOWcredential_authority_now/2 reloads 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/email unchanged but not first_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.

ddon pushed a commit that referenced this pull request Aug 9, 2026
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.
@timujinne
timujinne deleted the fix/credential-bypass-custom-fields branch August 10, 2026 08:35
Sign up for freeto 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.

2 participants

@timujinne@ddon