154 lines
5.4 KiB
Elixir
154 lines
5.4 KiB
Elixir
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
|