diff --git a/config/config.exs b/config/config.exs index 9cf6496..3540a88 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,6 +18,8 @@ config :parrhesia, max_event_bytes: 262_144, max_filters_per_req: 16, max_filter_limit: 500, + max_tags_per_event: 256, + max_tag_values_per_filter: 128, max_subscriptions_per_connection: 32, max_event_future_skew_seconds: 900, max_event_ingest_per_window: 120, diff --git a/config/runtime.exs b/config/runtime.exs index aafeca5..dbbf002 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -174,6 +174,16 @@ if config_env() == :prod do "PARRHESIA_LIMITS_MAX_FILTER_LIMIT", Keyword.get(limits_defaults, :max_filter_limit, 500) ), + max_tags_per_event: + int_env.( + "PARRHESIA_LIMITS_MAX_TAGS_PER_EVENT", + Keyword.get(limits_defaults, :max_tags_per_event, 256) + ), + max_tag_values_per_filter: + int_env.( + "PARRHESIA_LIMITS_MAX_TAG_VALUES_PER_FILTER", + Keyword.get(limits_defaults, :max_tag_values_per_filter, 128) + ), max_subscriptions_per_connection: int_env.( "PARRHESIA_LIMITS_MAX_SUBSCRIPTIONS_PER_CONNECTION", diff --git a/lib/parrhesia/protocol/event_validator.ex b/lib/parrhesia/protocol/event_validator.ex index 43942f8..de31e3c 100644 --- a/lib/parrhesia/protocol/event_validator.ex +++ b/lib/parrhesia/protocol/event_validator.ex @@ -6,6 +6,7 @@ defmodule Parrhesia.Protocol.EventValidator do @required_fields ~w[id pubkey created_at kind tags content sig] @max_kind 65_535 @default_max_event_future_skew_seconds 900 + @default_max_tags_per_event 256 @supported_mls_ciphersuites MapSet.new(~w[0x0001 0x0002 0x0003 0x0004 0x0005 0x0006 0x0007]) @required_mls_extensions MapSet.new(["0xf2ee", "0x000a"]) @supported_keypackage_ref_sizes [32, 48, 64] @@ -17,6 +18,7 @@ defmodule Parrhesia.Protocol.EventValidator do | :invalid_created_at | :created_at_too_far_in_future | :invalid_kind + | :too_many_tags | :invalid_tags | :invalid_content | :invalid_sig @@ -87,6 +89,7 @@ defmodule Parrhesia.Protocol.EventValidator do created_at_too_far_in_future: "invalid: event creation date is too far off from the current time", invalid_kind: "invalid: kind must be an integer between 0 and 65535", + too_many_tags: "invalid: event tags exceed configured limit", invalid_tags: "invalid: tags must be an array of non-empty string arrays", invalid_content: "invalid: content must be a string", invalid_sig: "invalid: sig must be 64-byte lowercase hex", @@ -169,16 +172,25 @@ defmodule Parrhesia.Protocol.EventValidator do defp validate_kind(kind) when is_integer(kind) and kind >= 0 and kind <= @max_kind, do: :ok defp validate_kind(_kind), do: {:error, :invalid_kind} - defp validate_tags(tags) when is_list(tags) do - if Enum.all?(tags, &valid_tag?/1) do - :ok - else - {:error, :invalid_tags} - end - end + defp validate_tags(tags) when is_list(tags), do: validate_tags(tags, max_tags_per_event(), 0) defp validate_tags(_tags), do: {:error, :invalid_tags} + defp validate_tags([], _max_tags, _count), do: :ok + + defp validate_tags([tag | rest], max_tags, count) do + cond do + count + 1 > max_tags -> + {:error, :too_many_tags} + + valid_tag?(tag) -> + validate_tags(rest, max_tags, count + 1) + + true -> + {:error, :invalid_tags} + end + end + defp validate_content(content) when is_binary(content), do: :ok defp validate_content(_content), do: {:error, :invalid_content} @@ -510,4 +522,11 @@ defmodule Parrhesia.Protocol.EventValidator do |> Application.get_env(:limits, []) |> Keyword.get(:max_event_future_skew_seconds, @default_max_event_future_skew_seconds) end + + defp max_tags_per_event do + case Application.get_env(:parrhesia, :limits, []) |> Keyword.get(:max_tags_per_event) do + value when is_integer(value) and value > 0 -> value + _other -> @default_max_tags_per_event + end + end end diff --git a/lib/parrhesia/protocol/filter.ex b/lib/parrhesia/protocol/filter.ex index fccdd4a..485158b 100644 --- a/lib/parrhesia/protocol/filter.ex +++ b/lib/parrhesia/protocol/filter.ex @@ -5,6 +5,7 @@ defmodule Parrhesia.Protocol.Filter do @max_kind 65_535 @default_max_filters_per_req 16 + @default_max_tag_values_per_filter 128 @type validation_error :: :invalid_filters @@ -19,6 +20,7 @@ defmodule Parrhesia.Protocol.Filter do | :invalid_until | :invalid_limit | :invalid_search + | :too_many_tag_values | :invalid_tag_filter @allowed_keys MapSet.new(["ids", "authors", "kinds", "since", "until", "limit", "search"]) @@ -36,6 +38,7 @@ defmodule Parrhesia.Protocol.Filter do invalid_until: "invalid: until must be a non-negative integer", invalid_limit: "invalid: limit must be a positive integer", invalid_search: "invalid: search must be a non-empty string", + too_many_tag_values: "invalid: tag filters exceed configured value limit", invalid_tag_filter: "invalid: tag filters must use # with non-empty string arrays" } @@ -178,19 +181,33 @@ defmodule Parrhesia.Protocol.Filter do filter |> Enum.filter(fn {key, _value} -> valid_tag_filter_key?(key) end) |> Enum.reduce_while(:ok, fn {_key, values}, :ok -> - if valid_tag_filter_values?(values) do - {:cont, :ok} - else - {:halt, {:error, :invalid_tag_filter}} + case validate_tag_filter_values(values) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} end end) end - defp valid_tag_filter_values?(values) when is_list(values) do - values != [] and Enum.all?(values, &is_binary/1) - end + defp validate_tag_filter_values(values) when is_list(values), + do: validate_tag_filter_values(values, max_tag_values_per_filter(), 0) - defp valid_tag_filter_values?(_values), do: false + defp validate_tag_filter_values(_values), do: {:error, :invalid_tag_filter} + + defp validate_tag_filter_values([], _max_values, 0), do: {:error, :invalid_tag_filter} + defp validate_tag_filter_values([], _max_values, _count), do: :ok + + defp validate_tag_filter_values([value | rest], max_values, count) do + cond do + count + 1 > max_values -> + {:error, :too_many_tag_values} + + is_binary(value) -> + validate_tag_filter_values(rest, max_values, count + 1) + + true -> + {:error, :invalid_tag_filter} + end + end defp filter_predicates(event, filter) do [ @@ -278,4 +295,12 @@ defmodule Parrhesia.Protocol.Filter do |> Application.get_env(:limits, []) |> Keyword.get(:max_filters_per_req, @default_max_filters_per_req) end + + defp max_tag_values_per_filter do + case Application.get_env(:parrhesia, :limits, []) + |> Keyword.get(:max_tag_values_per_filter) do + value when is_integer(value) and value > 0 -> value + _other -> @default_max_tag_values_per_filter + end + end end diff --git a/lib/parrhesia/web/connection.ex b/lib/parrhesia/web/connection.ex index 0e814fa..586ba05 100644 --- a/lib/parrhesia/web/connection.ex +++ b/lib/parrhesia/web/connection.ex @@ -414,6 +414,7 @@ defmodule Parrhesia.Web.Connection do :invalid_until, :invalid_limit, :invalid_search, + :too_many_tag_values, :invalid_tag_filter ] -> Filter.error_message(reason) @@ -462,6 +463,27 @@ defmodule Parrhesia.Web.Connection do restricted_count_notice(state, subscription_id, EventPolicy.error_message(reason)) end + defp handle_count_error(state, subscription_id, reason) + when reason in [ + :invalid_filters, + :empty_filters, + :too_many_filters, + :invalid_filter, + :invalid_filter_key, + :invalid_ids, + :invalid_authors, + :invalid_kinds, + :invalid_since, + :invalid_until, + :invalid_limit, + :invalid_search, + :too_many_tag_values, + :invalid_tag_filter + ] do + response = Protocol.encode_relay({:closed, subscription_id, Filter.error_message(reason)}) + {:push, {:text, response}, state} + end + defp handle_count_error(state, subscription_id, reason) do response = Protocol.encode_relay({:closed, subscription_id, inspect(reason)}) {:push, {:text, response}, state} @@ -648,6 +670,7 @@ defmodule Parrhesia.Web.Connection do :invalid_until, :invalid_limit, :invalid_search, + :too_many_tag_values, :invalid_tag_filter, :auth_required, :pubkey_not_allowed, @@ -701,6 +724,7 @@ defmodule Parrhesia.Web.Connection do :invalid_until, :invalid_limit, :invalid_search, + :too_many_tag_values, :invalid_tag_filter ], do: Filter.error_message(reason) diff --git a/test/parrhesia/config_test.exs b/test/parrhesia/config_test.exs index 5de8a7a..2d160d0 100644 --- a/test/parrhesia/config_test.exs +++ b/test/parrhesia/config_test.exs @@ -6,6 +6,8 @@ defmodule Parrhesia.ConfigTest do assert Parrhesia.Config.get([:limits, :max_event_bytes]) == 262_144 assert Parrhesia.Config.get([:limits, :max_event_future_skew_seconds]) == 900 assert Parrhesia.Config.get([:limits, :max_event_ingest_per_window]) == 120 + assert Parrhesia.Config.get([:limits, :max_tags_per_event]) == 256 + assert Parrhesia.Config.get([:limits, :max_tag_values_per_filter]) == 128 assert Parrhesia.Config.get([:limits, :event_ingest_window_seconds]) == 1 assert Parrhesia.Config.get([:limits, :auth_max_age_seconds]) == 600 assert Parrhesia.Config.get([:limits, :max_outbound_queue]) == 256 diff --git a/test/parrhesia/protocol/event_validator_marmot_test.exs b/test/parrhesia/protocol/event_validator_marmot_test.exs index ffbf81e..fea4697 100644 --- a/test/parrhesia/protocol/event_validator_marmot_test.exs +++ b/test/parrhesia/protocol/event_validator_marmot_test.exs @@ -140,6 +140,18 @@ defmodule Parrhesia.Protocol.EventValidatorMarmotTest do EventValidator.validate(invalid_empty_content) end + test "rejects events with too many tags" do + event = + valid_keypackage_event(%{ + "tags" => Enum.map(1..257, fn index -> ["e", "ref-#{index}"] end) + }) + + assert {:error, :too_many_tags} = EventValidator.validate(event) + + assert {:error, "invalid: event tags exceed configured limit"} = + Protocol.validate_event(event) + end + defp valid_keypackage_event(overrides \\ %{}) do base_event = %{ "pubkey" => String.duplicate("1", 64), diff --git a/test/parrhesia/protocol/filter_test.exs b/test/parrhesia/protocol/filter_test.exs index 500e65c..95e7c72 100644 --- a/test/parrhesia/protocol/filter_test.exs +++ b/test/parrhesia/protocol/filter_test.exs @@ -40,6 +40,15 @@ defmodule Parrhesia.Protocol.FilterTest do assert {:error, :invalid_search} = Filter.validate_filters([%{"search" => ""}]) end + test "rejects tag filters with too many values" do + filter = %{"#e" => Enum.map(1..129, &"event-ref-#{&1}")} + + assert {:error, :too_many_tag_values} = Filter.validate_filters([filter]) + + assert Filter.error_message(:too_many_tag_values) == + "invalid: tag filters exceed configured value limit" + end + test "matches with AND semantics inside filter and OR across filters" do event = valid_event() diff --git a/test/parrhesia/web/connection_test.exs b/test/parrhesia/web/connection_test.exs index 2882618..0d5efc5 100644 --- a/test/parrhesia/web/connection_test.exs +++ b/test/parrhesia/web/connection_test.exs @@ -40,6 +40,38 @@ defmodule Parrhesia.Web.ConnectionTest do assert payload["approximate"] == false end + test "REQ rejects tag filters that exceed the configured value limit" do + state = connection_state() + + payload = + JSON.encode!(["REQ", "sub-tag-limit", %{"#e" => Enum.map(1..129, &"ref-#{&1}")}]) + + assert {:push, {:text, response}, ^state} = + Connection.handle_in({payload, [opcode: :text]}, state) + + assert JSON.decode!(response) == [ + "CLOSED", + "sub-tag-limit", + "invalid: tag filters exceed configured value limit" + ] + end + + test "COUNT rejects tag filters that exceed the configured value limit" do + state = connection_state() + + payload = + JSON.encode!(["COUNT", "sub-tag-limit", %{"#e" => Enum.map(1..129, &"ref-#{&1}")}]) + + assert {:push, {:text, response}, ^state} = + Connection.handle_in({payload, [opcode: :text]}, state) + + assert JSON.decode!(response) == [ + "CLOSED", + "sub-tag-limit", + "invalid: tag filters exceed configured value limit" + ] + end + test "AUTH accepts valid challenge event" do state = connection_state()