From fff507d760f32690b0604975bb5ca2f921d185eb Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Fri, 13 Mar 2026 21:58:53 +0100 Subject: [PATCH] Implement MIP-01 #h query guardrails and ordering tests --- PROGRESS_MARMOT.md | 8 +- config/config.exs | 3 + lib/parrhesia/policy/event_policy.ex | 98 ++++++++++++++++++- lib/parrhesia/web/connection.ex | 42 ++++++++ ...13205654_add_event_tags_h_lookup_index.exs | 13 +++ test/parrhesia/policy/event_policy_test.exs | 41 ++++++++ .../postgres/events_query_count_test.exs | 45 +++++++++ test/parrhesia/web/connection_test.exs | 13 +++ 8 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 priv/repo/migrations/20260313205654_add_event_tags_h_lookup_index.exs diff --git a/PROGRESS_MARMOT.md b/PROGRESS_MARMOT.md index 94dcf77..4eff288 100644 --- a/PROGRESS_MARMOT.md +++ b/PROGRESS_MARMOT.md @@ -21,10 +21,10 @@ Spec source: `~/marmot/README.md` + MIP-00..05. ## M2 — MIP-01 (group construction data expectations) -- [ ] Enforce relay-side routing prerequisites for Marmot groups (`#h` query path) -- [ ] Keep deterministic ordering for group-event catch-up (`created_at` + `id` tie-break) -- [ ] Add guardrails for group metadata traffic volume and filter windows -- [ ] Add tests for `#h` routing and ordering invariants +- [x] Enforce relay-side routing prerequisites for Marmot groups (`#h` query path) +- [x] Keep deterministic ordering for group-event catch-up (`created_at` + `id` tie-break) +- [x] Add guardrails for group metadata traffic volume and filter windows +- [x] Add tests for `#h` routing and ordering invariants ## M3 — MIP-02 (welcome events) diff --git a/config/config.exs b/config/config.exs index c9017e7..70e4153 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,6 +18,9 @@ config :parrhesia, min_pow_difficulty: 0, accept_ephemeral_events: true, mls_group_event_ttl_seconds: 300, + marmot_require_h_for_group_queries: true, + marmot_group_max_h_values_per_filter: 32, + marmot_group_max_query_window_seconds: 2_592_000, management_auth_required: true ], features: [ diff --git a/lib/parrhesia/policy/event_policy.ex b/lib/parrhesia/policy/event_policy.ex index 07bc743..aeed84c 100644 --- a/lib/parrhesia/policy/event_policy.ex +++ b/lib/parrhesia/policy/event_policy.ex @@ -8,6 +8,9 @@ defmodule Parrhesia.Policy.EventPolicy do @type policy_error :: :auth_required | :restricted_giftwrap + | :marmot_group_h_tag_required + | :marmot_group_h_values_exceeded + | :marmot_group_filter_window_too_wide | :protected_event_requires_auth | :protected_event_pubkey_mismatch | :pow_below_minimum @@ -27,7 +30,7 @@ defmodule Parrhesia.Policy.EventPolicy do {:error, :restricted_giftwrap} true -> - :ok + enforce_marmot_group_read_guardrails(filters) end end @@ -56,6 +59,15 @@ defmodule Parrhesia.Policy.EventPolicy do def error_message(:restricted_giftwrap), do: "restricted: giftwrap access requires recipient authentication" + def error_message(:marmot_group_h_tag_required), + do: "restricted: kind 445 queries must include a #h tag" + + def error_message(:marmot_group_h_values_exceeded), + do: "rate-limited: kind 445 queries exceed maximum #h values" + + def error_message(:marmot_group_filter_window_too_wide), + do: "rate-limited: kind 445 query window exceeds configured maximum" + def error_message(:protected_event_requires_auth), do: "auth-required: protected events require authenticated pubkey" @@ -111,6 +123,90 @@ defmodule Parrhesia.Policy.EventPolicy do end end + defp enforce_marmot_group_read_guardrails(filters) do + cond do + marmot_group_h_tag_required_violation?(filters) -> + {:error, :marmot_group_h_tag_required} + + marmot_group_h_values_exceeded?(filters) -> + {:error, :marmot_group_h_values_exceeded} + + marmot_group_query_window_too_wide?(filters) -> + {:error, :marmot_group_filter_window_too_wide} + + true -> + :ok + end + end + + defp marmot_group_h_tag_required_violation?(filters) do + config_bool([:policies, :marmot_require_h_for_group_queries], true) and + Enum.any?(filters, fn filter -> + targets_marmot_group_events?(filter) and not valid_h_tag_values?(Map.get(filter, "#h")) + end) + end + + defp marmot_group_h_values_exceeded?(filters) do + max_h_values = config_int([:policies, :marmot_group_max_h_values_per_filter], 32) + + max_h_values > 0 and + Enum.any?(filters, fn filter -> + targets_marmot_group_events?(filter) and h_tag_values_count(filter) > max_h_values + end) + end + + defp marmot_group_query_window_too_wide?(filters) do + max_window = config_int([:policies, :marmot_group_max_query_window_seconds], 2_592_000) + + max_window > 0 and + Enum.any?(filters, fn filter -> + if targets_marmot_group_events?(filter) do + query_window_exceeds?(filter, max_window) + else + false + end + end) + end + + defp targets_marmot_group_events?(filter) do + case Map.get(filter, "kinds") do + kinds when is_list(kinds) -> 445 in kinds + _other -> false + end + end + + defp h_tag_values_count(filter) do + case Map.get(filter, "#h") do + values when is_list(values) -> length(values) + _other -> 0 + end + end + + defp valid_h_tag_values?(values) when is_list(values) do + values != [] and Enum.all?(values, &lowercase_hex?(&1, 32)) + end + + defp valid_h_tag_values?(_values), do: false + + defp query_window_exceeds?(filter, max_window) do + case {Map.get(filter, "since"), Map.get(filter, "until")} do + {since, until} + when is_integer(since) and since >= 0 and is_integer(until) and until >= 0 and + until >= since -> + until - since > max_window + + _other -> + false + end + end + + defp lowercase_hex?(value, bytes) when is_binary(value) do + byte_size(value) == bytes * 2 and + match?({:ok, _decoded}, Base.decode16(value, case: :lower)) + end + + defp lowercase_hex?(_value, _bytes), do: false + defp reject_if_pubkey_banned(event) do with pubkey when is_binary(pubkey) <- Map.get(event, "pubkey"), {:ok, true} <- Storage.moderation().pubkey_banned?(%{}, pubkey) do diff --git a/lib/parrhesia/web/connection.ex b/lib/parrhesia/web/connection.ex index 1e5e589..3d8a0b0 100644 --- a/lib/parrhesia/web/connection.ex +++ b/lib/parrhesia/web/connection.ex @@ -221,6 +221,27 @@ defmodule Parrhesia.Web.Connection do {:error, :restricted_giftwrap} -> restricted_close(state, subscription_id, EventPolicy.error_message(:restricted_giftwrap)) + {:error, :marmot_group_h_tag_required} -> + restricted_close( + state, + subscription_id, + EventPolicy.error_message(:marmot_group_h_tag_required) + ) + + {:error, :marmot_group_h_values_exceeded} -> + restricted_close( + state, + subscription_id, + EventPolicy.error_message(:marmot_group_h_values_exceeded) + ) + + {:error, :marmot_group_filter_window_too_wide} -> + restricted_close( + state, + subscription_id, + EventPolicy.error_message(:marmot_group_filter_window_too_wide) + ) + {:error, :subscription_limit_reached} -> response = Protocol.encode_relay({ @@ -287,6 +308,27 @@ defmodule Parrhesia.Web.Connection do EventPolicy.error_message(:restricted_giftwrap) ) + {:error, :marmot_group_h_tag_required} -> + restricted_count_notice( + state, + subscription_id, + EventPolicy.error_message(:marmot_group_h_tag_required) + ) + + {:error, :marmot_group_h_values_exceeded} -> + restricted_count_notice( + state, + subscription_id, + EventPolicy.error_message(:marmot_group_h_values_exceeded) + ) + + {:error, :marmot_group_filter_window_too_wide} -> + restricted_count_notice( + state, + subscription_id, + EventPolicy.error_message(:marmot_group_filter_window_too_wide) + ) + {:error, reason} -> response = Protocol.encode_relay({:closed, subscription_id, inspect(reason)}) {:push, {:text, response}, state} diff --git a/priv/repo/migrations/20260313205654_add_event_tags_h_lookup_index.exs b/priv/repo/migrations/20260313205654_add_event_tags_h_lookup_index.exs new file mode 100644 index 0000000..5d50f30 --- /dev/null +++ b/priv/repo/migrations/20260313205654_add_event_tags_h_lookup_index.exs @@ -0,0 +1,13 @@ +defmodule Parrhesia.Repo.Migrations.AddEventTagsHLookupIndex do + use Ecto.Migration + + def up do + execute( + "CREATE INDEX event_tags_h_value_created_at_idx ON event_tags (value, event_created_at DESC) WHERE name = 'h'" + ) + end + + def down do + execute("DROP INDEX event_tags_h_value_created_at_idx") + end +end diff --git a/test/parrhesia/policy/event_policy_test.exs b/test/parrhesia/policy/event_policy_test.exs index e329df8..9274b79 100644 --- a/test/parrhesia/policy/event_policy_test.exs +++ b/test/parrhesia/policy/event_policy_test.exs @@ -57,6 +57,47 @@ defmodule Parrhesia.Policy.EventPolicyTest do EventPolicy.authorize_write(event, MapSet.new([String.duplicate("c", 64)])) end + test "requires #h when querying kind 445" do + filter = %{"kinds" => [445]} + + assert {:error, :marmot_group_h_tag_required} = + EventPolicy.authorize_read([filter], MapSet.new()) + + assert :ok = + EventPolicy.authorize_read( + [%{"kinds" => [445], "#h" => [String.duplicate("a", 64)]}], + MapSet.new() + ) + end + + test "enforces max #h values and query window for kind 445 filters" do + Application.put_env( + :parrhesia, + :policies, + marmot_group_max_h_values_per_filter: 1, + marmot_group_max_query_window_seconds: 10, + marmot_require_h_for_group_queries: true + ) + + too_many_groups = %{ + "kinds" => [445], + "#h" => [String.duplicate("a", 64), String.duplicate("b", 64)] + } + + wide_window = %{ + "kinds" => [445], + "#h" => [String.duplicate("c", 64)], + "since" => 1, + "until" => 100 + } + + assert {:error, :marmot_group_h_values_exceeded} = + EventPolicy.authorize_read([too_many_groups], MapSet.new()) + + assert {:error, :marmot_group_filter_window_too_wide} = + EventPolicy.authorize_read([wide_window], MapSet.new()) + end + test "rejects mls kinds when feature is disabled" do Application.put_env(:parrhesia, :features, nip_ee_mls: false) diff --git a/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs b/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs index 930b4c7..553f4fd 100644 --- a/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs +++ b/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs @@ -268,6 +268,51 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsQueryCountTest do assert result["id"] == matching["id"] end + test "query/3 routes Marmot group events by #h and keeps deterministic order" do + group_id = String.duplicate("a", 64) + other_group_id = String.duplicate("b", 64) + + older = + persist_event(%{ + "kind" => 445, + "created_at" => 1_700_000_600, + "tags" => [["h", group_id]], + "content" => Base.encode64("older") + }) + + tie_a = + persist_event(%{ + "kind" => 445, + "created_at" => 1_700_000_601, + "tags" => [["h", group_id]], + "content" => Base.encode64("tie-a") + }) + + tie_b = + persist_event(%{ + "kind" => 445, + "created_at" => 1_700_000_601, + "tags" => [["h", group_id]], + "content" => Base.encode64("tie-b") + }) + + _other_group = + persist_event(%{ + "kind" => 445, + "created_at" => 1_700_000_602, + "tags" => [["h", other_group_id]], + "content" => Base.encode64("other-group") + }) + + assert {:ok, results} = + Events.query(%{}, [%{"kinds" => [445], "#h" => [group_id]}], []) + + tie_winner_id = Enum.min([tie_a["id"], tie_b["id"]]) + tie_loser_id = Enum.max([tie_a["id"], tie_b["id"]]) + + assert Enum.map(results, & &1["id"]) == [tie_winner_id, tie_loser_id, older["id"]] + end + test "mls keypackage relay list kind 10051 follows replaceable conflict semantics" do author = String.duplicate("c", 64) diff --git a/test/parrhesia/web/connection_test.exs b/test/parrhesia/web/connection_test.exs index 3a77f6d..da3e11d 100644 --- a/test/parrhesia/web/connection_test.exs +++ b/test/parrhesia/web/connection_test.exs @@ -93,6 +93,19 @@ defmodule Parrhesia.Web.ConnectionTest do assert Enum.any?(decoded, fn frame -> frame == ["AUTH", state.auth_challenge] end) end + test "kind 445 REQ without #h is rejected" do + state = connection_state() + + req_payload = Jason.encode!(["REQ", "sub-445", %{"kinds" => [445]}]) + + assert {:push, frames, ^state} = Connection.handle_in({req_payload, [opcode: :text]}, state) + + decoded = Enum.map(frames, fn {:text, frame} -> Jason.decode!(frame) end) + + assert ["CLOSED", "sub-445", "restricted: kind 445 queries must include a #h tag"] = + Enum.find(decoded, fn frame -> List.first(frame) == "CLOSED" end) + end + test "valid EVENT stores event and returns accepted OK" do state = connection_state()