Add configurable tag guardrails
This commit is contained in:
@@ -18,6 +18,8 @@ config :parrhesia,
|
|||||||
max_event_bytes: 262_144,
|
max_event_bytes: 262_144,
|
||||||
max_filters_per_req: 16,
|
max_filters_per_req: 16,
|
||||||
max_filter_limit: 500,
|
max_filter_limit: 500,
|
||||||
|
max_tags_per_event: 256,
|
||||||
|
max_tag_values_per_filter: 128,
|
||||||
max_subscriptions_per_connection: 32,
|
max_subscriptions_per_connection: 32,
|
||||||
max_event_future_skew_seconds: 900,
|
max_event_future_skew_seconds: 900,
|
||||||
max_event_ingest_per_window: 120,
|
max_event_ingest_per_window: 120,
|
||||||
|
|||||||
@@ -174,6 +174,16 @@ if config_env() == :prod do
|
|||||||
"PARRHESIA_LIMITS_MAX_FILTER_LIMIT",
|
"PARRHESIA_LIMITS_MAX_FILTER_LIMIT",
|
||||||
Keyword.get(limits_defaults, :max_filter_limit, 500)
|
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:
|
max_subscriptions_per_connection:
|
||||||
int_env.(
|
int_env.(
|
||||||
"PARRHESIA_LIMITS_MAX_SUBSCRIPTIONS_PER_CONNECTION",
|
"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]
|
@required_fields ~w[id pubkey created_at kind tags content sig]
|
||||||
@max_kind 65_535
|
@max_kind 65_535
|
||||||
@default_max_event_future_skew_seconds 900
|
@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])
|
@supported_mls_ciphersuites MapSet.new(~w[0x0001 0x0002 0x0003 0x0004 0x0005 0x0006 0x0007])
|
||||||
@required_mls_extensions MapSet.new(["0xf2ee", "0x000a"])
|
@required_mls_extensions MapSet.new(["0xf2ee", "0x000a"])
|
||||||
@supported_keypackage_ref_sizes [32, 48, 64]
|
@supported_keypackage_ref_sizes [32, 48, 64]
|
||||||
@@ -17,6 +18,7 @@ defmodule Parrhesia.Protocol.EventValidator do
|
|||||||
| :invalid_created_at
|
| :invalid_created_at
|
||||||
| :created_at_too_far_in_future
|
| :created_at_too_far_in_future
|
||||||
| :invalid_kind
|
| :invalid_kind
|
||||||
|
| :too_many_tags
|
||||||
| :invalid_tags
|
| :invalid_tags
|
||||||
| :invalid_content
|
| :invalid_content
|
||||||
| :invalid_sig
|
| :invalid_sig
|
||||||
@@ -87,6 +89,7 @@ defmodule Parrhesia.Protocol.EventValidator do
|
|||||||
created_at_too_far_in_future:
|
created_at_too_far_in_future:
|
||||||
"invalid: event creation date is too far off from the current time",
|
"invalid: event creation date is too far off from the current time",
|
||||||
invalid_kind: "invalid: kind must be an integer between 0 and 65535",
|
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_tags: "invalid: tags must be an array of non-empty string arrays",
|
||||||
invalid_content: "invalid: content must be a string",
|
invalid_content: "invalid: content must be a string",
|
||||||
invalid_sig: "invalid: sig must be 64-byte lowercase hex",
|
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) when is_integer(kind) and kind >= 0 and kind <= @max_kind, do: :ok
|
||||||
defp validate_kind(_kind), do: {:error, :invalid_kind}
|
defp validate_kind(_kind), do: {:error, :invalid_kind}
|
||||||
|
|
||||||
defp validate_tags(tags) when is_list(tags) do
|
defp validate_tags(tags) when is_list(tags), do: validate_tags(tags, max_tags_per_event(), 0)
|
||||||
if Enum.all?(tags, &valid_tag?/1) do
|
|
||||||
:ok
|
|
||||||
else
|
|
||||||
{:error, :invalid_tags}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp validate_tags(_tags), do: {:error, :invalid_tags}
|
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) when is_binary(content), do: :ok
|
||||||
defp validate_content(_content), do: {:error, :invalid_content}
|
defp validate_content(_content), do: {:error, :invalid_content}
|
||||||
|
|
||||||
@@ -510,4 +522,11 @@ defmodule Parrhesia.Protocol.EventValidator do
|
|||||||
|> Application.get_env(:limits, [])
|
|> Application.get_env(:limits, [])
|
||||||
|> Keyword.get(:max_event_future_skew_seconds, @default_max_event_future_skew_seconds)
|
|> Keyword.get(:max_event_future_skew_seconds, @default_max_event_future_skew_seconds)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ defmodule Parrhesia.Protocol.Filter do
|
|||||||
|
|
||||||
@max_kind 65_535
|
@max_kind 65_535
|
||||||
@default_max_filters_per_req 16
|
@default_max_filters_per_req 16
|
||||||
|
@default_max_tag_values_per_filter 128
|
||||||
|
|
||||||
@type validation_error ::
|
@type validation_error ::
|
||||||
:invalid_filters
|
:invalid_filters
|
||||||
@@ -19,6 +20,7 @@ defmodule Parrhesia.Protocol.Filter do
|
|||||||
| :invalid_until
|
| :invalid_until
|
||||||
| :invalid_limit
|
| :invalid_limit
|
||||||
| :invalid_search
|
| :invalid_search
|
||||||
|
| :too_many_tag_values
|
||||||
| :invalid_tag_filter
|
| :invalid_tag_filter
|
||||||
|
|
||||||
@allowed_keys MapSet.new(["ids", "authors", "kinds", "since", "until", "limit", "search"])
|
@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_until: "invalid: until must be a non-negative integer",
|
||||||
invalid_limit: "invalid: limit must be a positive integer",
|
invalid_limit: "invalid: limit must be a positive integer",
|
||||||
invalid_search: "invalid: search must be a non-empty string",
|
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_filter:
|
||||||
"invalid: tag filters must use #<single-letter> with non-empty string arrays"
|
"invalid: tag filters must use #<single-letter> with non-empty string arrays"
|
||||||
}
|
}
|
||||||
@@ -178,19 +181,33 @@ defmodule Parrhesia.Protocol.Filter do
|
|||||||
filter
|
filter
|
||||||
|> Enum.filter(fn {key, _value} -> valid_tag_filter_key?(key) end)
|
|> Enum.filter(fn {key, _value} -> valid_tag_filter_key?(key) end)
|
||||||
|> Enum.reduce_while(:ok, fn {_key, values}, :ok ->
|
|> Enum.reduce_while(:ok, fn {_key, values}, :ok ->
|
||||||
if valid_tag_filter_values?(values) do
|
case validate_tag_filter_values(values) do
|
||||||
{:cont, :ok}
|
:ok -> {:cont, :ok}
|
||||||
else
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
{:halt, {:error, :invalid_tag_filter}}
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp valid_tag_filter_values?(values) when is_list(values) do
|
defp validate_tag_filter_values(values) when is_list(values),
|
||||||
values != [] and Enum.all?(values, &is_binary/1)
|
do: validate_tag_filter_values(values, max_tag_values_per_filter(), 0)
|
||||||
end
|
|
||||||
|
|
||||||
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
|
defp filter_predicates(event, filter) do
|
||||||
[
|
[
|
||||||
@@ -278,4 +295,12 @@ defmodule Parrhesia.Protocol.Filter do
|
|||||||
|> Application.get_env(:limits, [])
|
|> Application.get_env(:limits, [])
|
||||||
|> Keyword.get(:max_filters_per_req, @default_max_filters_per_req)
|
|> Keyword.get(:max_filters_per_req, @default_max_filters_per_req)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ defmodule Parrhesia.Web.Connection do
|
|||||||
:invalid_until,
|
:invalid_until,
|
||||||
:invalid_limit,
|
:invalid_limit,
|
||||||
:invalid_search,
|
:invalid_search,
|
||||||
|
:too_many_tag_values,
|
||||||
:invalid_tag_filter
|
:invalid_tag_filter
|
||||||
] ->
|
] ->
|
||||||
Filter.error_message(reason)
|
Filter.error_message(reason)
|
||||||
@@ -462,6 +463,27 @@ defmodule Parrhesia.Web.Connection do
|
|||||||
restricted_count_notice(state, subscription_id, EventPolicy.error_message(reason))
|
restricted_count_notice(state, subscription_id, EventPolicy.error_message(reason))
|
||||||
end
|
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
|
defp handle_count_error(state, subscription_id, reason) do
|
||||||
response = Protocol.encode_relay({:closed, subscription_id, inspect(reason)})
|
response = Protocol.encode_relay({:closed, subscription_id, inspect(reason)})
|
||||||
{:push, {:text, response}, state}
|
{:push, {:text, response}, state}
|
||||||
@@ -648,6 +670,7 @@ defmodule Parrhesia.Web.Connection do
|
|||||||
:invalid_until,
|
:invalid_until,
|
||||||
:invalid_limit,
|
:invalid_limit,
|
||||||
:invalid_search,
|
:invalid_search,
|
||||||
|
:too_many_tag_values,
|
||||||
:invalid_tag_filter,
|
:invalid_tag_filter,
|
||||||
:auth_required,
|
:auth_required,
|
||||||
:pubkey_not_allowed,
|
:pubkey_not_allowed,
|
||||||
@@ -701,6 +724,7 @@ defmodule Parrhesia.Web.Connection do
|
|||||||
:invalid_until,
|
:invalid_until,
|
||||||
:invalid_limit,
|
:invalid_limit,
|
||||||
:invalid_search,
|
:invalid_search,
|
||||||
|
:too_many_tag_values,
|
||||||
:invalid_tag_filter
|
:invalid_tag_filter
|
||||||
],
|
],
|
||||||
do: Filter.error_message(reason)
|
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_bytes]) == 262_144
|
||||||
assert Parrhesia.Config.get([:limits, :max_event_future_skew_seconds]) == 900
|
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_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, :event_ingest_window_seconds]) == 1
|
||||||
assert Parrhesia.Config.get([:limits, :auth_max_age_seconds]) == 600
|
assert Parrhesia.Config.get([:limits, :auth_max_age_seconds]) == 600
|
||||||
assert Parrhesia.Config.get([:limits, :max_outbound_queue]) == 256
|
assert Parrhesia.Config.get([:limits, :max_outbound_queue]) == 256
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ defmodule Parrhesia.Protocol.EventValidatorMarmotTest do
|
|||||||
EventValidator.validate(invalid_empty_content)
|
EventValidator.validate(invalid_empty_content)
|
||||||
end
|
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
|
defp valid_keypackage_event(overrides \\ %{}) do
|
||||||
base_event = %{
|
base_event = %{
|
||||||
"pubkey" => String.duplicate("1", 64),
|
"pubkey" => String.duplicate("1", 64),
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ defmodule Parrhesia.Protocol.FilterTest do
|
|||||||
assert {:error, :invalid_search} = Filter.validate_filters([%{"search" => ""}])
|
assert {:error, :invalid_search} = Filter.validate_filters([%{"search" => ""}])
|
||||||
end
|
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
|
test "matches with AND semantics inside filter and OR across filters" do
|
||||||
event = valid_event()
|
event = valid_event()
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,38 @@ defmodule Parrhesia.Web.ConnectionTest do
|
|||||||
assert payload["approximate"] == false
|
assert payload["approximate"] == false
|
||||||
end
|
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
|
test "AUTH accepts valid challenge event" do
|
||||||
state = connection_state()
|
state = connection_state()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user