Add configurable tag guardrails
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 #<single-letter> 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user