diff --git a/PROGRESS_MARMOT.md b/PROGRESS_MARMOT.md index 50c591d..e79b786 100644 --- a/PROGRESS_MARMOT.md +++ b/PROGRESS_MARMOT.md @@ -42,9 +42,9 @@ Spec source: `~/marmot/README.md` + MIP-00..05. ## M5 — optional MIP-04 (encrypted media) -- [ ] Accept/store MIP-04 metadata-bearing events as regular Nostr events -- [ ] Add policy hooks for media metadata limits and abuse controls -- [ ] Add tests for search/filter interactions with media metadata tags +- [x] Accept/store MIP-04 metadata-bearing events as regular Nostr events +- [x] Add policy hooks for media metadata limits and abuse controls +- [x] Add tests for search/filter interactions with media metadata tags ## M6 — optional MIP-05 (push notifications) diff --git a/config/config.exs b/config/config.exs index 70e4153..a4304ea 100644 --- a/config/config.exs +++ b/config/config.exs @@ -21,6 +21,11 @@ config :parrhesia, marmot_require_h_for_group_queries: true, marmot_group_max_h_values_per_filter: 32, marmot_group_max_query_window_seconds: 2_592_000, + marmot_media_max_imeta_tags_per_event: 8, + marmot_media_max_field_value_bytes: 1024, + marmot_media_max_url_bytes: 2048, + marmot_media_allowed_mime_prefixes: [], + marmot_media_reject_mip04_v1: true, management_auth_required: true ], features: [ diff --git a/lib/parrhesia/policy/event_policy.ex b/lib/parrhesia/policy/event_policy.ex index aeed84c..2c8d44b 100644 --- a/lib/parrhesia/policy/event_policy.ex +++ b/lib/parrhesia/policy/event_policy.ex @@ -11,6 +11,14 @@ defmodule Parrhesia.Policy.EventPolicy do | :marmot_group_h_tag_required | :marmot_group_h_values_exceeded | :marmot_group_filter_window_too_wide + | :media_metadata_tags_exceeded + | :media_metadata_tag_value_too_large + | :media_metadata_url_too_long + | :media_metadata_invalid_url + | :media_metadata_invalid_hash + | :media_metadata_invalid_mime + | :media_metadata_mime_not_allowed + | :media_metadata_unsupported_version | :protected_event_requires_auth | :protected_event_pubkey_mismatch | :pow_below_minimum @@ -42,6 +50,7 @@ defmodule Parrhesia.Policy.EventPolicy do fn -> reject_if_event_banned(event) end, fn -> enforce_pow(event) end, fn -> enforce_protected_event(event, authenticated_pubkeys) end, + fn -> enforce_media_metadata_policy(event) end, fn -> enforce_mls_feature_flag(event) end ] @@ -68,6 +77,30 @@ defmodule Parrhesia.Policy.EventPolicy do def error_message(:marmot_group_filter_window_too_wide), do: "rate-limited: kind 445 query window exceeds configured maximum" + def error_message(:media_metadata_tags_exceeded), + do: "rate-limited: too many media metadata tags in event" + + def error_message(:media_metadata_tag_value_too_large), + do: "invalid: media metadata field value exceeds configured limit" + + def error_message(:media_metadata_url_too_long), + do: "invalid: media metadata url exceeds configured limit" + + def error_message(:media_metadata_invalid_url), + do: "invalid: media metadata url must be a valid http/https URL" + + def error_message(:media_metadata_invalid_hash), + do: "invalid: media metadata x field must be 32-byte lowercase hex" + + def error_message(:media_metadata_invalid_mime), + do: "invalid: media metadata mime type is invalid" + + def error_message(:media_metadata_mime_not_allowed), + do: "blocked: media metadata mime type is not allowed" + + def error_message(:media_metadata_unsupported_version), + do: "blocked: media metadata version is not supported" + def error_message(:protected_event_requires_auth), do: "auth-required: protected events require authenticated pubkey" @@ -207,6 +240,167 @@ defmodule Parrhesia.Policy.EventPolicy do defp lowercase_hex?(_value, _bytes), do: false + defp enforce_media_metadata_policy(event) do + imeta_tags = + event + |> Map.get("tags", []) + |> Enum.filter(&imeta_tag?/1) + + max_imeta_tags = config_int([:policies, :marmot_media_max_imeta_tags_per_event], 8) + + if max_imeta_tags > 0 and length(imeta_tags) > max_imeta_tags do + {:error, :media_metadata_tags_exceeded} + else + validate_imeta_tags(imeta_tags) + end + end + + defp imeta_tag?(["imeta" | _rest]), do: true + defp imeta_tag?(_tag), do: false + + defp validate_imeta_tags(imeta_tags) do + Enum.reduce_while(imeta_tags, :ok, fn tag, :ok -> + with {:ok, fields} <- parse_imeta_tag(tag), + :ok <- validate_imeta_fields(fields) do + {:cont, :ok} + else + {:error, _reason} = error -> {:halt, error} + end + end) + end + + defp parse_imeta_tag(["imeta" | fields]) when is_list(fields) do + if fields != [] and rem(length(fields), 2) == 0 do + parsed_fields = + fields + |> Enum.chunk_every(2) + |> Enum.reduce(%{}, fn [key, value], acc -> Map.put(acc, key, value) end) + + {:ok, parsed_fields} + else + {:error, :media_metadata_tag_value_too_large} + end + end + + defp parse_imeta_tag(_tag), do: {:error, :media_metadata_tag_value_too_large} + + defp validate_imeta_fields(fields) do + with :ok <- validate_imeta_value_sizes(fields), + :ok <- validate_imeta_url(fields), + :ok <- validate_imeta_hash(fields), + :ok <- validate_imeta_mime(fields) do + validate_imeta_version(fields) + end + end + + defp validate_imeta_value_sizes(fields) do + max_value_bytes = config_int([:policies, :marmot_media_max_field_value_bytes], 1024) + + if max_value_bytes <= 0 or Enum.all?(Map.values(fields), &(byte_size(&1) <= max_value_bytes)) do + :ok + else + {:error, :media_metadata_tag_value_too_large} + end + end + + defp validate_imeta_url(fields) do + case Map.get(fields, "url") do + nil -> + :ok + + url -> + max_url_bytes = config_int([:policies, :marmot_media_max_url_bytes], 2048) + + cond do + max_url_bytes > 0 and byte_size(url) > max_url_bytes -> + {:error, :media_metadata_url_too_long} + + valid_http_url?(url) -> + :ok + + true -> + {:error, :media_metadata_invalid_url} + end + end + end + + defp validate_imeta_hash(fields) do + case Map.get(fields, "x") do + nil -> + :ok + + hash -> + if lowercase_hex?(hash, 32) do + :ok + else + {:error, :media_metadata_invalid_hash} + end + end + end + + defp validate_imeta_mime(fields) do + case Map.get(fields, "m") do + nil -> + :ok + + mime_type -> + allowed_prefixes = config_list([:policies, :marmot_media_allowed_mime_prefixes], []) + + cond do + not valid_mime_type?(mime_type) -> + {:error, :media_metadata_invalid_mime} + + allowed_prefixes == [] -> + :ok + + Enum.any?(allowed_prefixes, &String.starts_with?(mime_type, &1)) -> + :ok + + true -> + {:error, :media_metadata_mime_not_allowed} + end + end + end + + defp validate_imeta_version(fields) do + case Map.get(fields, "v") do + nil -> + :ok + + "mip04-v2" -> + :ok + + "mip04-v1" -> + if config_bool([:policies, :marmot_media_reject_mip04_v1], true) do + {:error, :media_metadata_unsupported_version} + else + :ok + end + + _other -> + {:error, :media_metadata_unsupported_version} + end + end + + defp valid_http_url?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _other -> + false + end + end + + defp valid_http_url?(_url), do: false + + defp valid_mime_type?(mime_type) when is_binary(mime_type) do + String.match?(mime_type, ~r/^[a-z0-9!#$&^_.+\-]+\/[a-z0-9!#$&^_.+\-]+$/) + end + + defp valid_mime_type?(_mime_type), 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 @@ -315,4 +509,14 @@ defmodule Parrhesia.Policy.EventPolicy do _other -> default end end + + defp config_list([scope, key], default) do + case Application.get_env(:parrhesia, scope, []) |> Keyword.get(key, default) do + value when is_list(value) -> + if Enum.all?(value, &is_binary/1), do: value, else: default + + _other -> + default + end + end end diff --git a/lib/parrhesia/web/connection.ex b/lib/parrhesia/web/connection.ex index 3d8a0b0..a2c2e9c 100644 --- a/lib/parrhesia/web/connection.ex +++ b/lib/parrhesia/web/connection.ex @@ -430,6 +430,14 @@ defmodule Parrhesia.Web.Connection do :pow_below_minimum, :pubkey_banned, :event_banned, + :media_metadata_tags_exceeded, + :media_metadata_tag_value_too_large, + :media_metadata_url_too_long, + :media_metadata_invalid_url, + :media_metadata_invalid_hash, + :media_metadata_invalid_mime, + :media_metadata_mime_not_allowed, + :media_metadata_unsupported_version, :mls_disabled ], do: EventPolicy.error_message(reason) diff --git a/test/parrhesia/config_test.exs b/test/parrhesia/config_test.exs index a8d8484..f4061ce 100644 --- a/test/parrhesia/config_test.exs +++ b/test/parrhesia/config_test.exs @@ -8,6 +8,8 @@ defmodule Parrhesia.ConfigTest do assert Parrhesia.Config.get([:limits, :max_outbound_queue]) == 256 assert Parrhesia.Config.get([:limits, :max_filter_limit]) == 500 assert Parrhesia.Config.get([:policies, :auth_required_for_writes]) == false + assert Parrhesia.Config.get([:policies, :marmot_media_max_imeta_tags_per_event]) == 8 + assert Parrhesia.Config.get([:policies, :marmot_media_reject_mip04_v1]) == true assert Parrhesia.Config.get([:features, :nip_50_search]) == true assert Parrhesia.Config.get([:features, :nip_ee_mls]) == false end diff --git a/test/parrhesia/policy/event_policy_test.exs b/test/parrhesia/policy/event_policy_test.exs index 9274b79..04cb861 100644 --- a/test/parrhesia/policy/event_policy_test.exs +++ b/test/parrhesia/policy/event_policy_test.exs @@ -98,6 +98,131 @@ defmodule Parrhesia.Policy.EventPolicyTest do EventPolicy.authorize_read([wide_window], MapSet.new()) end + test "accepts MIP-04 media metadata events as regular Nostr events" do + media_event = %{ + "kind" => 1, + "tags" => [ + [ + "imeta", + "url", + "https://media.example/blob", + "m", + "image/jpeg", + "x", + String.duplicate("a", 64), + "v", + "mip04-v2" + ] + ], + "pubkey" => String.duplicate("d", 64), + "id" => "" + } + + assert :ok = + EventPolicy.authorize_write(media_event, MapSet.new([String.duplicate("d", 64)])) + end + + test "enforces media metadata tag limits" do + Application.put_env( + :parrhesia, + :policies, + marmot_media_max_imeta_tags_per_event: 1, + marmot_media_max_field_value_bytes: 1024, + marmot_media_max_url_bytes: 2048, + marmot_media_allowed_mime_prefixes: [], + marmot_media_reject_mip04_v1: true + ) + + event = %{ + "kind" => 1, + "tags" => [ + [ + "imeta", + "url", + "https://media.example/1", + "m", + "image/jpeg", + "x", + String.duplicate("a", 64) + ], + [ + "imeta", + "url", + "https://media.example/2", + "m", + "image/jpeg", + "x", + String.duplicate("b", 64) + ] + ], + "pubkey" => String.duplicate("d", 64), + "id" => "" + } + + assert {:error, :media_metadata_tags_exceeded} = + EventPolicy.authorize_write(event, MapSet.new([String.duplicate("d", 64)])) + end + + test "rejects disallowed media mime types and unsupported versions" do + Application.put_env( + :parrhesia, + :policies, + marmot_media_max_imeta_tags_per_event: 8, + marmot_media_max_field_value_bytes: 1024, + marmot_media_max_url_bytes: 2048, + marmot_media_allowed_mime_prefixes: ["image/"], + marmot_media_reject_mip04_v1: true + ) + + invalid_mime_event = %{ + "kind" => 1, + "tags" => [ + [ + "imeta", + "url", + "https://media.example/1", + "m", + "video/mp4", + "x", + String.duplicate("a", 64) + ] + ], + "pubkey" => String.duplicate("d", 64), + "id" => "" + } + + unsupported_version_event = %{ + "kind" => 1, + "tags" => [ + [ + "imeta", + "url", + "https://media.example/1", + "m", + "image/jpeg", + "x", + String.duplicate("a", 64), + "v", + "mip04-v1" + ] + ], + "pubkey" => String.duplicate("d", 64), + "id" => "" + } + + assert {:error, :media_metadata_mime_not_allowed} = + EventPolicy.authorize_write( + invalid_mime_event, + MapSet.new([String.duplicate("d", 64)]) + ) + + assert {:error, :media_metadata_unsupported_version} = + EventPolicy.authorize_write( + unsupported_version_event, + MapSet.new([String.duplicate("d", 64)]) + ) + 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 66f3d70..2eb24a6 100644 --- a/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs +++ b/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs @@ -248,6 +248,44 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsQueryCountTest do assert {:ok, 0} = Events.count(%{}, filters, requester_pubkeys: []) end + test "query/3 combines search and media metadata tag filters" do + media_hash = String.duplicate("a", 64) + + matching = + persist_event(%{ + "kind" => 1, + "tags" => [ + ["imeta", "url", "https://media.example/blob", "m", "image/jpeg", "x", media_hash], + ["m", "image/jpeg"], + ["x", media_hash] + ], + "content" => "photo attachment from group" + }) + + _wrong_mime = + persist_event(%{ + "kind" => 1, + "tags" => [["m", "video/mp4"], ["x", media_hash]], + "content" => "photo attachment from group" + }) + + _wrong_search = + persist_event(%{ + "kind" => 1, + "tags" => [["m", "image/jpeg"], ["x", media_hash]], + "content" => "document attachment" + }) + + filters = [ + %{"kinds" => [1], "search" => "photo", "#m" => ["image/jpeg"], "#x" => [media_hash]} + ] + + assert {:ok, [result]} = Events.query(%{}, filters, []) + assert result["id"] == matching["id"] + + assert {:ok, 1} = Events.count(%{}, filters, []) + end + test "query/3 supports #i keypackage reference lookups" do keypackage_ref = String.duplicate("a", 64) diff --git a/test/parrhesia/web/connection_test.exs b/test/parrhesia/web/connection_test.exs index 75604c7..d16303c 100644 --- a/test/parrhesia/web/connection_test.exs +++ b/test/parrhesia/web/connection_test.exs @@ -189,6 +189,40 @@ defmodule Parrhesia.Web.ConnectionTest do ] end + test "unsupported media metadata version EVENT is rejected by policy" do + state = connection_state() + + event = + valid_event() + |> Map.put("kind", 1) + |> Map.put("tags", [ + [ + "imeta", + "url", + "https://media.example/blob", + "m", + "image/jpeg", + "x", + String.duplicate("a", 64), + "v", + "mip04-v1" + ] + ]) + |> then(&Map.put(&1, "id", EventValidator.compute_id(&1))) + + payload = Jason.encode!(["EVENT", event]) + + assert {:push, {:text, response}, ^state} = + Connection.handle_in({payload, [opcode: :text]}, state) + + assert Jason.decode!(response) == [ + "OK", + event["id"], + false, + "blocked: media metadata version is not supported" + ] + end + test "NEG sessions open and close" do state = connection_state()