Lock signature verification and add per-IP ingest limits

This commit is contained in:
2026-03-18 16:46:32 +01:00
parent a2bdf11139
commit dce473662f
14 changed files with 332 additions and 8 deletions

View File

@@ -8,6 +8,12 @@ defmodule Parrhesia.Protocol.EventValidator do
@default_max_event_future_skew_seconds 900
@default_max_tags_per_event 256
@default_nip43_request_max_age_seconds 300
@verify_event_signatures_locked Application.compile_env(
:parrhesia,
[:features, :verify_event_signatures_locked?],
false
)
@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]
@@ -254,7 +260,7 @@ defmodule Parrhesia.Protocol.EventValidator do
end
defp validate_signature(event) do
if verify_event_signatures?() do
if @verify_event_signatures_locked or verify_event_signatures?() do
verify_signature(event)
else
:ok

View File

@@ -18,6 +18,7 @@ defmodule Parrhesia.Runtime do
Parrhesia.Telemetry,
Parrhesia.Config,
Parrhesia.Web.EventIngestLimiter,
Parrhesia.Web.IPEventIngestLimiter,
Parrhesia.Storage.Supervisor,
Parrhesia.Subscriptions.Supervisor,
Parrhesia.Auth.Supervisor,

View File

@@ -17,6 +17,7 @@ defmodule Parrhesia.Web.Connection do
alias Parrhesia.Subscriptions.Index
alias Parrhesia.Telemetry
alias Parrhesia.Web.EventIngestLimiter
alias Parrhesia.Web.IPEventIngestLimiter
alias Parrhesia.Web.Listener
@default_max_subscriptions_per_connection 32
@@ -64,6 +65,7 @@ defmodule Parrhesia.Web.Connection do
max_frame_bytes: @default_max_frame_bytes,
max_event_bytes: @default_max_event_bytes,
event_ingest_limiter: EventIngestLimiter,
remote_ip_event_ingest_limiter: IPEventIngestLimiter,
max_event_ingest_per_window: @default_event_ingest_rate_limit,
event_ingest_window_seconds: @default_event_ingest_window_seconds,
event_ingest_window_started_at_ms: 0,
@@ -99,6 +101,7 @@ defmodule Parrhesia.Web.Connection do
max_frame_bytes: pos_integer(),
max_event_bytes: pos_integer(),
event_ingest_limiter: GenServer.server() | nil,
remote_ip_event_ingest_limiter: GenServer.server() | nil,
max_event_ingest_per_window: pos_integer(),
event_ingest_window_seconds: pos_integer(),
event_ingest_window_started_at_ms: integer(),
@@ -126,6 +129,7 @@ defmodule Parrhesia.Web.Connection do
max_frame_bytes: max_frame_bytes(opts),
max_event_bytes: max_event_bytes(opts),
event_ingest_limiter: event_ingest_limiter(opts),
remote_ip_event_ingest_limiter: remote_ip_event_ingest_limiter(opts),
max_event_ingest_per_window: max_event_ingest_per_window(opts),
event_ingest_window_seconds: event_ingest_window_seconds(opts),
event_ingest_window_started_at_ms: System.monotonic_time(:millisecond),
@@ -289,7 +293,12 @@ defmodule Parrhesia.Web.Connection do
end
defp maybe_publish_ingested_event(next_state, state, event, event_id) do
with :ok <- maybe_allow_relay_event_ingest(next_state.event_ingest_limiter),
with :ok <-
maybe_allow_remote_ip_event_ingest(
next_state.remote_ip,
next_state.remote_ip_event_ingest_limiter
),
:ok <- maybe_allow_relay_event_ingest(next_state.event_ingest_limiter),
:ok <- authorize_listener_write(next_state, event) do
publish_event_response(next_state, event)
else
@@ -574,6 +583,9 @@ defmodule Parrhesia.Web.Connection do
defp error_message_for_ingest_failure(:event_rate_limited),
do: "rate-limited: too many EVENT messages"
defp error_message_for_ingest_failure(:ip_event_rate_limited),
do: "rate-limited: too many EVENT messages from this IP"
defp error_message_for_ingest_failure(:relay_event_rate_limited),
do: "rate-limited: relay-wide EVENT ingress exceeded"
@@ -1571,6 +1583,16 @@ defmodule Parrhesia.Web.Connection do
defp event_ingest_limiter(_opts), do: EventIngestLimiter
defp remote_ip_event_ingest_limiter(opts) when is_list(opts) do
Keyword.get(opts, :remote_ip_event_ingest_limiter, IPEventIngestLimiter)
end
defp remote_ip_event_ingest_limiter(opts) when is_map(opts) do
Map.get(opts, :remote_ip_event_ingest_limiter, IPEventIngestLimiter)
end
defp remote_ip_event_ingest_limiter(_opts), do: IPEventIngestLimiter
defp event_ingest_window_seconds(opts) when is_list(opts) do
opts
|> Keyword.get(:event_ingest_window_seconds)
@@ -1671,6 +1693,15 @@ defmodule Parrhesia.Web.Connection do
end
end
defp maybe_allow_remote_ip_event_ingest(_remote_ip, nil), do: :ok
defp maybe_allow_remote_ip_event_ingest(remote_ip, server) do
IPEventIngestLimiter.allow(remote_ip, server)
catch
:exit, {:noproc, _details} -> :ok
:exit, {:normal, _details} -> :ok
end
defp maybe_allow_relay_event_ingest(nil), do: :ok
defp maybe_allow_relay_event_ingest(server) do

View File

@@ -0,0 +1,169 @@
defmodule Parrhesia.Web.IPEventIngestLimiter do
@moduledoc """
Per-IP EVENT ingest rate limiting over a fixed time window.
"""
use GenServer
@default_max_events_per_window 1_000
@default_window_seconds 1
@named_table :parrhesia_ip_event_ingest_limiter
@config_key :config
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
max_events_per_window =
normalize_positive_integer(
Keyword.get(opts, :max_events_per_window),
max_events_per_window()
)
window_ms =
normalize_positive_integer(Keyword.get(opts, :window_seconds), window_seconds()) * 1000
init_arg = %{
max_events_per_window: max_events_per_window,
window_ms: window_ms,
named_table?: Keyword.get(opts, :name, __MODULE__) == __MODULE__
}
case Keyword.get(opts, :name, __MODULE__) do
nil -> GenServer.start_link(__MODULE__, init_arg)
name -> GenServer.start_link(__MODULE__, init_arg, name: name)
end
end
@spec allow(tuple() | String.t() | nil, GenServer.server()) ::
:ok | {:error, :ip_event_rate_limited}
def allow(remote_ip, server \\ __MODULE__)
def allow(remote_ip, __MODULE__) do
case normalize_remote_ip(remote_ip) do
nil ->
:ok
normalized_remote_ip ->
case fetch_named_config() do
{:ok, max_events_per_window, window_ms} ->
allow_counter(@named_table, normalized_remote_ip, max_events_per_window, window_ms)
:error ->
:ok
end
end
end
def allow(remote_ip, server), do: GenServer.call(server, {:allow, remote_ip})
@impl true
def init(%{
max_events_per_window: max_events_per_window,
window_ms: window_ms,
named_table?: named_table?
}) do
table = create_table(named_table?)
true = :ets.insert(table, {@config_key, max_events_per_window, window_ms})
{:ok,
%{
table: table,
max_events_per_window: max_events_per_window,
window_ms: window_ms
}}
end
@impl true
def handle_call({:allow, remote_ip}, _from, state) do
reply =
case normalize_remote_ip(remote_ip) do
nil ->
:ok
normalized_remote_ip ->
allow_counter(
state.table,
normalized_remote_ip,
state.max_events_per_window,
state.window_ms
)
end
{:reply, reply, state}
end
defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value
defp normalize_positive_integer(_value, default), do: default
defp create_table(true) do
:ets.new(@named_table, [
:named_table,
:set,
:public,
{:read_concurrency, true},
{:write_concurrency, true}
])
end
defp create_table(false) do
:ets.new(__MODULE__, [:set, :public, {:read_concurrency, true}, {:write_concurrency, true}])
end
defp fetch_named_config do
case :ets.lookup(@named_table, @config_key) do
[{@config_key, max_events_per_window, window_ms}] -> {:ok, max_events_per_window, window_ms}
_other -> :error
end
rescue
ArgumentError -> :error
end
defp allow_counter(table, remote_ip, max_events_per_window, window_ms) do
window_id = System.monotonic_time(:millisecond) |> div(window_ms)
key = {:window, remote_ip, window_id}
count = :ets.update_counter(table, key, {2, 1}, {key, 0})
if count == 1 do
prune_expired_windows(table, window_id)
end
if count <= max_events_per_window do
:ok
else
{:error, :ip_event_rate_limited}
end
rescue
ArgumentError -> :ok
end
defp prune_expired_windows(table, window_id) do
:ets.select_delete(table, [
{{{:window, :"$1", :"$2"}, :_}, [{:<, :"$2", window_id}], [true]}
])
end
defp normalize_remote_ip({_, _, _, _} = remote_ip), do: :inet.ntoa(remote_ip) |> to_string()
defp normalize_remote_ip({_, _, _, _, _, _, _, _} = remote_ip),
do: :inet.ntoa(remote_ip) |> to_string()
defp normalize_remote_ip(remote_ip) when is_binary(remote_ip) and remote_ip != "", do: remote_ip
defp normalize_remote_ip(_remote_ip), do: nil
defp max_events_per_window do
case Application.get_env(:parrhesia, :limits, [])
|> Keyword.get(:ip_max_event_ingest_per_window) do
value when is_integer(value) and value > 0 -> value
_other -> @default_max_events_per_window
end
end
defp window_seconds do
case Application.get_env(:parrhesia, :limits, [])
|> Keyword.get(:ip_event_ingest_window_seconds) do
value when is_integer(value) and value > 0 -> value
_other -> @default_window_seconds
end
end
end