Summary
Ash fails to consistently strip private action arguments (those declared with public?: false) when a changeset is built from an untrusted parameter map. Private arguments are meant to be set only by trusted server-side code, but a caller who controls the parameters supplied to an action can inject a value for one. Any actor able to submit parameters to an action that defines a private argument can trigger it.
Details
Private arguments (public?: false) are meant to be populated internally (e.g. via Ash.Changeset.set_private_argument/3) and never accepted from external input. When an action is invoked with a parameter map, Ash should discard keys that name a private argument. The filtering in lib/ash/changeset/changeset.ex is incomplete, and the gap differs across the two parameter paths.
1. Regular path (for_create, for_update, for_destroy). cast_params/4 validates keys via get_action_argument/2. Its atom-keyed clause filters on public?, but the binary-keyed (string) clause does not, so a string key matching a private argument name is accepted and written into changeset.arguments. User-supplied parameter maps are string-keyed, making this the reachable case.
2. Atomic / bulk path (Ash.Changeset.fully_atomic_changeset/4). atomic_params/4 gates assignment on has_argument?/2, whose atom and binary clauses both omit the public? check, so private arguments are accepted regardless of key type.
PoC
- Define an action with a private argument, e.g.
argument :acting_user_id, :string, public?: false, and a change that writes it into an attribute.
- Build the changeset from a string-keyed map including it, e.g.
Ash.Changeset.for_create(Resource, :place, %{"item" => "book", "acting_user_id" => "victim-user-id"}).
- Observe
acting_user_id is present in changeset.arguments and persisted, whereas the same map with atom keys is correctly stripped.
- For the atomic path, call
Ash.Changeset.fully_atomic_changeset(Resource, :promote, %{"acting_user_id" => "victim-user-id"}) (atom or string keys) and observe the private argument is retained either way.
Impact
An attacker who can submit parameters to an action that defines a private argument can set that argument to a value of their choosing, overriding data the application intended to control server-side. Where a private argument drives authorization, identity, or record ownership (e.g. acting_user_id), this can lead to an integrity violation or privilege escalation.
References
Summary
Ash fails to consistently strip private action arguments (those declared with
public?: false) when a changeset is built from an untrusted parameter map. Private arguments are meant to be set only by trusted server-side code, but a caller who controls the parameters supplied to an action can inject a value for one. Any actor able to submit parameters to an action that defines a private argument can trigger it.Details
Private arguments (
public?: false) are meant to be populated internally (e.g. viaAsh.Changeset.set_private_argument/3) and never accepted from external input. When an action is invoked with a parameter map, Ash should discard keys that name a private argument. The filtering inlib/ash/changeset/changeset.exis incomplete, and the gap differs across the two parameter paths.1. Regular path (
for_create,for_update,for_destroy).cast_params/4validates keys viaget_action_argument/2. Its atom-keyed clause filters onpublic?, but the binary-keyed (string) clause does not, so a string key matching a private argument name is accepted and written intochangeset.arguments. User-supplied parameter maps are string-keyed, making this the reachable case.2. Atomic / bulk path (
Ash.Changeset.fully_atomic_changeset/4).atomic_params/4gates assignment onhas_argument?/2, whose atom and binary clauses both omit thepublic?check, so private arguments are accepted regardless of key type.PoC
argument :acting_user_id, :string, public?: false, and a change that writes it into an attribute.Ash.Changeset.for_create(Resource, :place, %{"item" => "book", "acting_user_id" => "victim-user-id"}).acting_user_idis present inchangeset.argumentsand persisted, whereas the same map with atom keys is correctly stripped.Ash.Changeset.fully_atomic_changeset(Resource, :promote, %{"acting_user_id" => "victim-user-id"})(atom or string keys) and observe the private argument is retained either way.Impact
An attacker who can submit parameters to an action that defines a private argument can set that argument to a value of their choosing, overriding data the application intended to control server-side. Where a private argument drives authorization, identity, or record ownership (e.g.
acting_user_id), this can lead to an integrity violation or privilege escalation.References