See how ash-project compares to other vendors in security performance
Inefficient Algorithmic Complexity vulnerability in ash-project ashpapertrail allows a user who can submit a large array attribute to a paper-trailed create or update action to cause a denial of service through excessive CPU and memory use.
With full-diff change tracking, AshPaperTrail.ChangeBuilders.FullDiff.ListChange pairs each prior array element against the new list by rebuilding the remaining-elements accumulator with acc ++ [tuple] on every step, copying the growing list each time, so the pairing scales cubically in the array length. Nothing bounds the length and the value comes straight from action input, so one request carrying a large accepted {:array, } attribute forces tens of seconds of CPU and multi-gigabyte allocations.
This issue affects ashpapertrail: from 0.1.1 before 0.7.0.
Cleartext Storage of Sensitive Information vulnerability in ash-project ashpapertrail allows an attacker with read access to the generated version resource to recover the plaintext of sensitive? attributes.
AshPaperTrail stores the values of tracked sensitive? attributes in the generated version resource's changes map, which is declared public? true and sensitive? false, so the values are returned by the version resource's default read action and printed in logs, inspect output, and error messages instead of being redacted. AshPaperTrail.Resource.Transformers.CreateVersionResource derives the changes map's sensitivity from the ignoreattributes list (the attributes excluded from changes) rather than from the tracked attributes actually stored in it, and ignoreattributes defaults to empty, so the flag is effectively always false.
This issue affects ashpapertrail: from 0.1.1 before 0.7.0.
Deserialization of Untrusted Data vulnerability in ash-project ash allows an unauthenticated attacker to inject a filter expression through a forged keyset pagination cursor, resulting in SQL injection or code execution depending on the data layer.
Read actions with keyset pagination decode the client-supplied page[:after] or page[:before] cursor in decodevalues/2 in lib/ash/page/keyset.ex using nonexecutablebinarytoterm/2 with [:safe]. That guard blocks new atoms, funs, and ports, but not a struct built from atoms already interned in a running Ash application, so a decoded %Ash.Query.Call{} expression survives and is spliced into the keyset filter as a comparison value in dofilters/4 and evaluated. Because the cursor bypasses the Ash.Expr macro, the runtime never applies the private?/public? gate that would otherwise reject it. On AshPostgres the injected fragment is inlined into the SQL query; on the ETS and Simple data layers it is evaluated in-process as an arbitrary function call.
This issue affects ash: from 1.17.0 before 3.31.3.
Uncontrolled Resource Consumption vulnerability in ash-project ash allows an attacker to exhaust the memory of the node via a crafted keyset pagination cursor.
Read actions with keyset pagination deserialize the client-supplied page[:after] or page[:before] cursor in decodevalues/2 in lib/ash/page/keyset.ex, which base64-decodes the value and passes it to :erlang.binarytoterm/2 without bounding its size. The Erlang external term format supports zlib-compressed payloads, which the decoder inflates transparently, so a cursor of a few kilobytes can allocate tens of megabytes of heap in a single call. Ash itself only ever encodes cursors uncompressed, so the decoder accepts a term shape its encoder never produces. Concurrent requests aggregate these allocations and can terminate the node.
This issue affects ash: from 1.17.0 before 3.31.1.
Summary Bypass policies incorrectly authorize requests when their condition evaluates to true but their authorization checks fail and no other policies apply.
Impact Resources with bypass policies can be accessed without proper authorization when: - Bypass condition evaluates to true - Bypass authorization checks fail - Other policies exist but their conditions don't match
Details Vulnerable code in: lib/ash/policy/policy.ex:69
elixir {%{bypass?: true}, condexpr, completeexpr}, {oneconditionmatches, allpoliciesmatch} -> { b(condexpr or oneconditionmatches), # <- Bug: uses condition only b(completeexpr or allpoliciesmatch) }
The final authorization decision is: oneconditionmatches AND allpoliciesmatch
When a bypass condition is true but bypass policies fail, and subsequent policies have non-matching conditions:
1. oneconditionmatches = condexpr (bypass condition) = true (bug - should check if bypass actually authorizes) 2. allpoliciesmatch = (completeexpr OR NOT condexpr) for each policy - For non-matching policies: (false OR NOT false) = true (policies don't apply) 3. Final: true AND true = true (incorrectly authorized)
The bypass condition alone satisfies "at least one policy applies" even though the bypass fails to authorize.
Fix Replace condexpr with completeexpr on line 69: elixir {%{bypass?: true}, condexpr, completeexpr}, {oneconditionmatches, allpoliciesmatch} -> { b(completeexpr or oneconditionmatches), # <- Fixed b(completeexpr or allpoliciesmatch) }
Line 52 should also be updated for consistency (though it's only triggered when bypass is the last policy, making it coincidentally safe in practice): elixir {%{bypass?: true}, condexpr, completeexpr}, {oneconditionmatches, true} -> { b(completeexpr or oneconditionmatches), # <- For consistency completeexpr }
PoC elixir policies do bypass always() do authorizeif actorattributeequals(:isadmin, true) end
policy actiontype(:read) do authorizeif always() end end
Non-admin user can perform create actions (should be denied).
Test demonstrating the bug: elixir test "bypass policy bug" do policies = [ %Ash.Policy.Policy{ bypass?: true, condition: [{Ash.Policy.Check.Static, result: true}], # condition = true policies: [ %Ash.Policy.Check{ type: :authorizeif, check: {Ash.Policy.Check.Static, result: false}, # policies = false checkmodule: Ash.Policy.Check.Static, checkopts: [result: false] } ] }, %Ash.Policy.Policy{ bypass?: false, condition: [{Ash.Policy.Check.Static, result: false}], policies: [ %Ash.Policy.Check{ type: :authorizeif, check: {Ash.Policy.Check.Static, result: true}, checkmodule: Ash.Policy.Check.Static, checkopts: [result: true] } ] } ]
expression = Ash.Policy.Policy.expression(policies, %{}) assert expression == false # Expected: false (deny) # Actual on main: true (incorrectly authorized) end
Summary
When using filter authorization, two edge cases could cause the policy compiler/authorizer to generate a permissive filter:
1. Bypass policies whose condition can never pass at runtime were compiled as OR(AND(condition, compiledpolicies), NOT(condition)). If the condition could never be true at runtime, the NOT(condition) branch evaluated truthy and the overall expression became permissive.
2. Runtime policy scenarios that reduce to “no checks are applicable” (an empty SAT scenario) were treated as an empty clause and dropped instead of being treated as false, which could again produce an overly broad (permissive) filter.
These bugs could allow reads to return records that should have been excluded by policy.
Impact
Projects that rely on filter-based authorization and define:
bypass ... do ... end blocks whose condition(s) are only resolvable at runtime and can never pass in a given request context, or runtime checks that simplify to an empty scenario for a clause
may unintentionally generate a permissive query filter, potentially returning unauthorized data.
Actions primarily affected: reads guarded by filter policies. Non-filter (e.g., hard forbid) policies are not impacted.
Technical details
This patch corrects two behaviors:
Ash.Policy.Policy.compilepolicyexpression/1 now treats bypass blocks as AND(conditionexpression, compiledpolicies) instead of OR(AND(...), NOT(conditionexpression)). This removes the permissive NOT(condition) escape hatch when a bypass condition never passes.
Ash.Policy.Authorizer now treats empty SAT scenarios (scenario == %{}) as false, ensuring impossible scenarios do not collapse into a no-op and inadvertently widen the filter. The reducer also normalizes nil → false consistently when building autofilter fragments.
Relevant changes are in:
lib/ash/policy/policy.ex (bypass compilation) lib/ash/policy/authorizer/authorizer.ex (scenario handling / autofilter normalization) Tests added: test/policy/filterconditiontest.exs (RuntimeFalsyCheck, RuntimeBypassResource) validate the corrected behavior.
Workarounds
Avoid bypass policies whose conditions are only decidable at runtime and may be perpetually false in some contexts; prefer explicit authorizeif/forbidif blocks without bypass for those cases. Add an explicit final forbidif always() guard for sensitive reads as a belt-and-suspenders fallback until user can upgrade. Where feasible, replace runtime-unknown checks with strict/compile-time checks or restructure to avoid empty SAT scenarios.
How to tell if user is affected
User is likely affected if ALL of the following are true:
Uses filter authorization; and Defines bypass block with accesstype :runtime without any policies after it; or Defines bypass blocks whose conditions are evaluated at runtime (e.g., checks with strictcheck/3 returning :unknown and a runtime check/4 that may never succeed in some contexts) without any policies after it
A quick sanity test is to issue a read expected to return no rows under such a bypass or runtime-falsy condition and verify it indeed returns []. The included test bypass works with filter policies demonstrates the corrected, non-permissive behavior.