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.
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
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.