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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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