Add configurable tag guardrails

This commit is contained in:
2026-03-18 13:36:40 +01:00
parent 8dbf05b7fe
commit 57fdb4ed85
9 changed files with 150 additions and 15 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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),

View File

@@ -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()

View File

@@ -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()