From 6dee391e8691b5d8d3dba3944566108a8e49f607 Mon Sep 17 00:00:00 2001 From: Moises Macia Date: Wed, 25 Mar 2026 20:11:09 +0000 Subject: [PATCH] add XMLResourceLimits module to control parser bounds --- lib/feed_reader/xml_resource_limits.ex | 153 +++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 lib/feed_reader/xml_resource_limits.ex diff --git a/lib/feed_reader/xml_resource_limits.ex b/lib/feed_reader/xml_resource_limits.ex new file mode 100644 index 0000000..e8d7598 --- /dev/null +++ b/lib/feed_reader/xml_resource_limits.ex @@ -0,0 +1,153 @@ +defmodule FeedReader.XMLResourceLimits do + @moduledoc """ + Accounts SAX events before a format handler retains their data. + + These limits prevent large or pathological feeds from causing unbounded + parser memory use or work. Although SAX input is incremental, handlers still + retain feed metadata, attributes, and the current item's content. This module + makes parsing fail with a resource-limit error before those retained values + exceed their budgets. + + Call `account_event/3` before updating parser state. Set the scope to `:feed` + while collecting feed metadata and to `:item` for the current item; starting + each item resets its byte budget. The defaults bound a document to 1,000,000 + events, depth 256, 128 attributes per element, 64 KiB per attribute, 1 MiB of + feed metadata, and 4 MiB per item. Limits can be overridden with matching + `:max_*` options; invalid values use the defaults. + """ + + @enforce_keys [ + :max_xml_events, + :max_xml_depth, + :max_attributes_per_element, + :max_attribute_bytes, + :max_feed_metadata_bytes, + :max_item_bytes + ] + defstruct max_xml_events: 1_000_000, + max_xml_depth: 256, + max_attributes_per_element: 128, + max_attribute_bytes: 65_536, + max_feed_metadata_bytes: 1_048_576, + max_item_bytes: 4_194_304, + events: 0, + depth: 0, + feed_metadata_bytes: 0, + item_bytes: 0, + scope: :none + + @type scope :: :none | :feed | :item + @type t :: %__MODULE__{} + @type limit_error :: {:resource_limit, atom(), non_neg_integer()} + + @doc "Creates an accounting state using defaults or valid non-negative limits." + @spec new(keyword()) :: t() + def new(opts \\ []) do + %__MODULE__{ + max_xml_events: option(opts, :max_xml_events, 1_000_000), + max_xml_depth: option(opts, :max_xml_depth, 256), + max_attributes_per_element: option(opts, :max_attributes_per_element, 128), + max_attribute_bytes: option(opts, :max_attribute_bytes, 65_536), + max_feed_metadata_bytes: option(opts, :max_feed_metadata_bytes, 1_048_576), + max_item_bytes: option(opts, :max_item_bytes, 4_194_304) + } + end + + @doc "Selects which retained-data budget subsequent SAX events consume." + @spec set_scope(t(), scope()) :: t() + def set_scope(%__MODULE__{} = limits, :item), do: %{limits | scope: :item, item_bytes: 0} + + def set_scope(%__MODULE__{} = limits, scope) when scope in [:none, :feed], + do: %{limits | scope: scope} + + @doc "Checks one Saxy event and returns updated accounting or a stable limit error." + @spec account_event(t(), atom(), term()) :: + {:ok, t()} | {:error, limit_error() | :invalid_sax_event} + def account_event(%__MODULE__{} = limits, event, data) do + with {:ok, limits} <- increment_events(limits) do + account_data(limits, event, data) + end + end + + defp increment_events(limits) do + count = limits.events + 1 + + if count > limits.max_xml_events, + do: limit_error(:max_xml_events, limits.max_xml_events), + else: {:ok, %{limits | events: count}} + end + + defp account_data(limits, :start_element, {name, attrs}) + when is_binary(name) and is_list(attrs) do + depth = limits.depth + 1 + + with :ok <- within(depth, limits.max_xml_depth, :max_xml_depth), + {:ok, bytes} <- attribute_bytes(attrs, limits.max_attribute_bytes), + :ok <- + within(length(attrs), limits.max_attributes_per_element, :max_attributes_per_element) do + add_bytes(%{limits | depth: depth}, bytes + byte_size(name)) + end + end + + defp account_data(limits, :end_element, name) when is_binary(name) and limits.depth > 0 do + with {:ok, limits} <- add_bytes(limits, byte_size(name)) do + {:ok, %{limits | depth: limits.depth - 1}} + end + end + + defp account_data(limits, event, data) when event in [:characters, :cdata] and is_binary(data), + do: add_bytes(limits, byte_size(data)) + + defp account_data(limits, event, _data) + when event in [:start_document, :end_document], + do: {:ok, limits} + + defp account_data(_limits, _event, _data), do: {:error, :invalid_sax_event} + + defp attribute_bytes(attrs, max_attribute_bytes) do + Enum.reduce_while(attrs, {:ok, 0}, fn + {name, value}, {:ok, total} when is_binary(name) and is_binary(value) -> + bytes = byte_size(name) + byte_size(value) + + if bytes > max_attribute_bytes do + {:halt, limit_error(:max_attribute_bytes, max_attribute_bytes)} + else + {:cont, {:ok, total + bytes}} + end + + _attribute, _acc -> + {:halt, {:error, :invalid_sax_event}} + end) + end + + defp add_bytes(limits, bytes) do + case limits.scope do + :feed -> + next = limits.feed_metadata_bytes + bytes + + if next > limits.max_feed_metadata_bytes, + do: limit_error(:max_feed_metadata_bytes, limits.max_feed_metadata_bytes), + else: {:ok, %{limits | feed_metadata_bytes: next}} + + :item -> + next = limits.item_bytes + bytes + + if next > limits.max_item_bytes, + do: limit_error(:max_item_bytes, limits.max_item_bytes), + else: {:ok, %{limits | item_bytes: next}} + + :none -> + {:ok, limits} + end + end + + defp within(value, max, key), do: if(value > max, do: limit_error(key, max), else: :ok) + defp limit_error(key, max), do: {:error, {:resource_limit, key, max}} + + defp option(opts, key, default) do + case Keyword.get(opts, key, default) do + value when is_integer(value) and value >= 0 -> value + _invalid -> default + end + end +end