Harden ingress limits, AUTH validation, and search escaping

This commit is contained in:
2026-03-14 04:09:02 +01:00
parent 238b44ff03
commit c7a9f152f9
9 changed files with 551 additions and 66 deletions

View File

@@ -4,6 +4,7 @@ config :postgrex, :json_library, JSON
config :parrhesia, config :parrhesia,
moderation_cache_enabled: true, moderation_cache_enabled: true,
relay_url: "ws://localhost:4000/relay",
limits: [ limits: [
max_frame_bytes: 1_048_576, max_frame_bytes: 1_048_576,
max_event_bytes: 262_144, max_event_bytes: 262_144,
@@ -11,6 +12,9 @@ config :parrhesia,
max_filter_limit: 500, max_filter_limit: 500,
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,
event_ingest_window_seconds: 1,
auth_max_age_seconds: 600,
max_outbound_queue: 256, max_outbound_queue: 256,
outbound_drain_batch_size: 64, outbound_drain_batch_size: 64,
outbound_overflow_strategy: :close outbound_overflow_strategy: :close

View File

@@ -624,11 +624,19 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do
defp maybe_filter_search(query, nil), do: query defp maybe_filter_search(query, nil), do: query
defp maybe_filter_search(query, search) when is_binary(search) and search != "" do defp maybe_filter_search(query, search) when is_binary(search) and search != "" do
where(query, [event], ilike(event.content, ^"%#{search}%")) escaped_search = escape_like_pattern(search)
where(query, [event], ilike(event.content, ^"%#{escaped_search}%"))
end end
defp maybe_filter_search(query, _search), do: query defp maybe_filter_search(query, _search), do: query
defp escape_like_pattern(search) do
search
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
defp filter_by_tags(query, filter) do defp filter_by_tags(query, filter) do
filter filter
|> tag_filters() |> tag_filters()

View File

@@ -20,6 +20,11 @@ defmodule Parrhesia.Web.Connection do
@default_max_outbound_queue 256 @default_max_outbound_queue 256
@default_outbound_drain_batch_size 64 @default_outbound_drain_batch_size 64
@default_outbound_overflow_strategy :close @default_outbound_overflow_strategy :close
@default_max_frame_bytes 1_048_576
@default_max_event_bytes 262_144
@default_event_ingest_rate_limit 120
@default_event_ingest_window_seconds 1
@default_auth_max_age_seconds 600
@drain_outbound_queue :drain_outbound_queue @drain_outbound_queue :drain_outbound_queue
@post_ack_ingest :post_ack_ingest @post_ack_ingest :post_ack_ingest
@outbound_queue_pressure_threshold 0.75 @outbound_queue_pressure_threshold 0.75
@@ -43,13 +48,21 @@ defmodule Parrhesia.Web.Connection do
subscription_index: Index, subscription_index: Index,
auth_challenges: Challenges, auth_challenges: Challenges,
auth_challenge: nil, auth_challenge: nil,
relay_url: nil,
negentropy_sessions: Sessions, negentropy_sessions: Sessions,
outbound_queue: :queue.new(), outbound_queue: :queue.new(),
outbound_queue_size: 0, outbound_queue_size: 0,
max_outbound_queue: @default_max_outbound_queue, max_outbound_queue: @default_max_outbound_queue,
outbound_overflow_strategy: @default_outbound_overflow_strategy, outbound_overflow_strategy: @default_outbound_overflow_strategy,
outbound_drain_batch_size: @default_outbound_drain_batch_size, outbound_drain_batch_size: @default_outbound_drain_batch_size,
drain_scheduled?: false drain_scheduled?: false,
max_frame_bytes: @default_max_frame_bytes,
max_event_bytes: @default_max_event_bytes,
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,
event_ingest_count: 0,
auth_max_age_seconds: @default_auth_max_age_seconds
@type overflow_strategy :: :close | :drop_oldest | :drop_newest @type overflow_strategy :: :close | :drop_oldest | :drop_newest
@@ -65,13 +78,21 @@ defmodule Parrhesia.Web.Connection do
subscription_index: GenServer.server() | nil, subscription_index: GenServer.server() | nil,
auth_challenges: GenServer.server() | nil, auth_challenges: GenServer.server() | nil,
auth_challenge: String.t() | nil, auth_challenge: String.t() | nil,
relay_url: String.t() | nil,
negentropy_sessions: GenServer.server() | nil, negentropy_sessions: GenServer.server() | nil,
outbound_queue: :queue.queue({String.t(), map()}), outbound_queue: :queue.queue({String.t(), map()}),
outbound_queue_size: non_neg_integer(), outbound_queue_size: non_neg_integer(),
max_outbound_queue: pos_integer(), max_outbound_queue: pos_integer(),
outbound_overflow_strategy: overflow_strategy(), outbound_overflow_strategy: overflow_strategy(),
outbound_drain_batch_size: pos_integer(), outbound_drain_batch_size: pos_integer(),
drain_scheduled?: boolean() drain_scheduled?: boolean(),
max_frame_bytes: pos_integer(),
max_event_bytes: pos_integer(),
max_event_ingest_per_window: pos_integer(),
event_ingest_window_seconds: pos_integer(),
event_ingest_window_started_at_ms: integer(),
event_ingest_count: non_neg_integer(),
auth_max_age_seconds: pos_integer()
} }
@impl true @impl true
@@ -83,10 +104,17 @@ defmodule Parrhesia.Web.Connection do
subscription_index: subscription_index(opts), subscription_index: subscription_index(opts),
auth_challenges: auth_challenges, auth_challenges: auth_challenges,
auth_challenge: maybe_issue_auth_challenge(auth_challenges), auth_challenge: maybe_issue_auth_challenge(auth_challenges),
relay_url: relay_url(opts),
negentropy_sessions: negentropy_sessions(opts), negentropy_sessions: negentropy_sessions(opts),
max_outbound_queue: max_outbound_queue(opts), max_outbound_queue: max_outbound_queue(opts),
outbound_overflow_strategy: outbound_overflow_strategy(opts), outbound_overflow_strategy: outbound_overflow_strategy(opts),
outbound_drain_batch_size: outbound_drain_batch_size(opts) outbound_drain_batch_size: outbound_drain_batch_size(opts),
max_frame_bytes: max_frame_bytes(opts),
max_event_bytes: max_event_bytes(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),
auth_max_age_seconds: auth_max_age_seconds(opts)
} }
{:ok, state} {:ok, state}
@@ -94,13 +122,23 @@ defmodule Parrhesia.Web.Connection do
@impl true @impl true
def handle_in({payload, [opcode: :text]}, %__MODULE__{} = state) do def handle_in({payload, [opcode: :text]}, %__MODULE__{} = state) do
case Protocol.decode_client(payload) do if byte_size(payload) > state.max_frame_bytes do
{:ok, decoded_message} -> response =
handle_decoded_message(decoded_message, state) Protocol.encode_relay({
:notice,
"invalid: websocket frame exceeds max frame size"
})
{:error, reason} -> {:push, {:text, response}, state}
response = Protocol.encode_relay({:notice, Protocol.decode_error_notice(reason)}) else
{:push, {:text, response}, state} case Protocol.decode_client(payload) do
{:ok, decoded_message} ->
handle_decoded_message(decoded_message, state)
{:error, reason} ->
response = Protocol.encode_relay({:notice, Protocol.decode_error_notice(reason)})
{:push, {:text, response}, state}
end
end end
end end
@@ -187,30 +225,39 @@ defmodule Parrhesia.Web.Connection do
started_at = System.monotonic_time() started_at = System.monotonic_time()
event_id = Map.get(event, "id", "") event_id = Map.get(event, "id", "")
with :ok <- Protocol.validate_event(event), case maybe_allow_event_ingest(state) do
:ok <- EventPolicy.authorize_write(event, state.authenticated_pubkeys), {:ok, next_state} ->
:ok <- maybe_process_group_event(event), with :ok <- validate_event_payload_size(event, next_state.max_event_bytes),
{:ok, _result, message} <- persist_event(event) do :ok <- Protocol.validate_event(event),
Telemetry.emit( :ok <- EventPolicy.authorize_write(event, next_state.authenticated_pubkeys),
[:parrhesia, :ingest, :stop], :ok <- maybe_process_group_event(event),
%{duration: System.monotonic_time() - started_at}, {:ok, _result, message} <- persist_event(event) do
telemetry_metadata_for_event(event) Telemetry.emit(
) [:parrhesia, :ingest, :stop],
%{duration: System.monotonic_time() - started_at},
telemetry_metadata_for_event(event)
)
send(self(), {@post_ack_ingest, event}) send(self(), {@post_ack_ingest, event})
response = Protocol.encode_relay({:ok, event_id, true, message})
{:push, {:text, response}, next_state}
else
{:error, reason} ->
message = error_message_for_ingest_failure(reason)
response = Protocol.encode_relay({:ok, event_id, false, message})
if reason in [:auth_required, :protected_event_requires_auth] do
with_auth_challenge_frame(next_state, {:push, {:text, response}, next_state})
else
{:push, {:text, response}, next_state}
end
end
response = Protocol.encode_relay({:ok, event_id, true, message})
{:push, {:text, response}, state}
else
{:error, reason} -> {:error, reason} ->
message = error_message_for_ingest_failure(reason) message = error_message_for_ingest_failure(reason)
response = Protocol.encode_relay({:ok, event_id, false, message}) response = Protocol.encode_relay({:ok, event_id, false, message})
{:push, {:text, response}, state}
if reason in [:auth_required, :protected_event_requires_auth] do
with_auth_challenge_frame(state, {:push, {:text, response}, state})
else
{:push, {:text, response}, state}
end
end end
end end
@@ -359,7 +406,7 @@ defmodule Parrhesia.Web.Connection do
event_id = Map.get(auth_event, "id", "") event_id = Map.get(auth_event, "id", "")
with :ok <- Protocol.validate_event(auth_event), with :ok <- Protocol.validate_event(auth_event),
:ok <- validate_auth_event(auth_event), :ok <- validate_auth_event(state, auth_event),
:ok <- validate_auth_challenge(state, auth_event) do :ok <- validate_auth_challenge(state, auth_event) do
pubkey = Map.get(auth_event, "pubkey") pubkey = Map.get(auth_event, "pubkey")
@@ -418,18 +465,26 @@ defmodule Parrhesia.Web.Connection do
end end
defp persist_event(event) do defp persist_event(event) do
case Map.get(event, "kind") do kind = Map.get(event, "kind")
5 ->
cond do
kind == 5 ->
with {:ok, deleted_count} <- Storage.events().delete_by_request(%{}, event) do with {:ok, deleted_count} <- Storage.events().delete_by_request(%{}, event) do
{:ok, deleted_count, "ok: deletion request processed"} {:ok, deleted_count, "ok: deletion request processed"}
end end
62 -> kind == 62 ->
with {:ok, deleted_count} <- Storage.events().vanish(%{}, event) do with {:ok, deleted_count} <- Storage.events().vanish(%{}, event) do
{:ok, deleted_count, "ok: vanish request processed"} {:ok, deleted_count, "ok: vanish request processed"}
end end
_other -> ephemeral_kind?(kind) and accept_ephemeral_events?() ->
{:ok, :ephemeral, "ok: ephemeral event accepted"}
ephemeral_kind?(kind) ->
{:error, :ephemeral_events_disabled}
true ->
case Storage.events().put_event(%{}, event) do case Storage.events().put_event(%{}, event) do
{:ok, persisted_event} -> {:ok, persisted_event, "ok: event stored"} {:ok, persisted_event} -> {:ok, persisted_event, "ok: event stored"}
{:error, :duplicate_event} -> {:error, :duplicate_event} {:error, :duplicate_event} -> {:error, :duplicate_event}
@@ -441,6 +496,15 @@ defmodule Parrhesia.Web.Connection do
defp error_message_for_ingest_failure(:duplicate_event), defp error_message_for_ingest_failure(:duplicate_event),
do: "duplicate: event already stored" do: "duplicate: event already stored"
defp error_message_for_ingest_failure(:event_rate_limited),
do: "rate-limited: too many EVENT messages"
defp error_message_for_ingest_failure(:event_too_large),
do: "invalid: event exceeds max event size"
defp error_message_for_ingest_failure(:ephemeral_events_disabled),
do: "blocked: ephemeral events are disabled"
defp error_message_for_ingest_failure(reason) defp error_message_for_ingest_failure(reason)
when reason in [ when reason in [
:auth_required, :auth_required,
@@ -570,7 +634,7 @@ defmodule Parrhesia.Web.Connection do
with_auth_challenge_frame(state, {:push, {:text, response}, state}) with_auth_challenge_frame(state, {:push, {:text, response}, state})
end end
defp validate_auth_event(%{"kind" => 22_242} = auth_event) do defp validate_auth_event(%__MODULE__{} = state, %{"kind" => 22_242} = auth_event) do
tags = Map.get(auth_event, "tags", []) tags = Map.get(auth_event, "tags", [])
challenge_tag? = challenge_tag? =
@@ -579,10 +643,14 @@ defmodule Parrhesia.Web.Connection do
_tag -> false _tag -> false
end) end)
if challenge_tag?, do: :ok, else: {:error, :missing_challenge_tag} with :ok <- maybe_validate(challenge_tag?, :missing_challenge_tag),
:ok <- validate_auth_relay_tag(state, tags),
:ok <- validate_auth_created_at_freshness(auth_event, state.auth_max_age_seconds) do
:ok
end
end end
defp validate_auth_event(_auth_event), do: {:error, :invalid_auth_kind} defp validate_auth_event(_state, _auth_event), do: {:error, :invalid_auth_kind}
defp validate_auth_challenge(%__MODULE__{auth_challenge: nil}, _auth_event), defp validate_auth_challenge(%__MODULE__{auth_challenge: nil}, _auth_event),
do: {:error, :missing_challenge} do: {:error, :missing_challenge}
@@ -599,8 +667,45 @@ defmodule Parrhesia.Web.Connection do
if challenge_tag_matches?, do: :ok, else: {:error, :challenge_mismatch} if challenge_tag_matches?, do: :ok, else: {:error, :challenge_mismatch}
end end
defp validate_auth_relay_tag(%__MODULE__{relay_url: relay_url}, tags)
when is_binary(relay_url) do
relay_tag_matches? =
Enum.any?(tags, fn
["relay", ^relay_url | _rest] -> true
_tag -> false
end)
if relay_tag_matches?, do: :ok, else: {:error, :invalid_relay_tag}
end
defp validate_auth_relay_tag(%__MODULE__{relay_url: nil}, _tags),
do: {:error, :missing_relay_configuration}
defp validate_auth_created_at_freshness(auth_event, max_age_seconds)
when is_integer(max_age_seconds) and max_age_seconds > 0 do
created_at = Map.get(auth_event, "created_at", -1)
now = System.system_time(:second)
if created_at >= now - max_age_seconds do
:ok
else
{:error, :auth_event_too_old}
end
end
defp validate_auth_created_at_freshness(_auth_event, _max_age_seconds), do: :ok
defp maybe_validate(true, _reason), do: :ok
defp maybe_validate(false, reason), do: {:error, reason}
defp auth_error_message(:invalid_auth_kind), do: "invalid: AUTH event kind must be 22242" defp auth_error_message(:invalid_auth_kind), do: "invalid: AUTH event kind must be 22242"
defp auth_error_message(:missing_challenge_tag), do: "invalid: AUTH event missing challenge tag" defp auth_error_message(:missing_challenge_tag), do: "invalid: AUTH event missing challenge tag"
defp auth_error_message(:invalid_relay_tag), do: "invalid: AUTH relay tag mismatch"
defp auth_error_message(:missing_relay_configuration),
do: "invalid: relay URL is not configured"
defp auth_error_message(:auth_event_too_old), do: "invalid: AUTH event is too old"
defp auth_error_message(:challenge_mismatch), do: "invalid: AUTH challenge mismatch" defp auth_error_message(:challenge_mismatch), do: "invalid: AUTH challenge mismatch"
defp auth_error_message(:missing_challenge), do: "invalid: AUTH challenge unavailable" defp auth_error_message(:missing_challenge), do: "invalid: AUTH challenge unavailable"
defp auth_error_message(reason) when is_binary(reason), do: reason defp auth_error_message(reason) when is_binary(reason), do: reason
@@ -1137,4 +1242,200 @@ defmodule Parrhesia.Web.Connection do
|> Application.get_env(:limits, []) |> Application.get_env(:limits, [])
|> Keyword.get(:outbound_overflow_strategy, @default_outbound_overflow_strategy) |> Keyword.get(:outbound_overflow_strategy, @default_outbound_overflow_strategy)
end end
defp relay_url(opts) when is_list(opts) do
opts
|> Keyword.get(:relay_url)
|> normalize_relay_url()
|> maybe_default_relay_url()
end
defp relay_url(opts) when is_map(opts) do
opts
|> Map.get(:relay_url)
|> normalize_relay_url()
|> maybe_default_relay_url()
end
defp relay_url(_opts), do: configured_relay_url()
defp normalize_relay_url(relay_url) when is_binary(relay_url) and relay_url != "", do: relay_url
defp normalize_relay_url(_relay_url), do: nil
defp maybe_default_relay_url(nil), do: configured_relay_url()
defp maybe_default_relay_url(relay_url), do: relay_url
defp configured_relay_url do
:parrhesia
|> Application.get_env(:relay_url)
|> normalize_relay_url()
end
defp max_frame_bytes(opts) when is_list(opts) do
opts
|> Keyword.get(:max_frame_bytes)
|> normalize_max_frame_bytes()
end
defp max_frame_bytes(opts) when is_map(opts) do
opts
|> Map.get(:max_frame_bytes)
|> normalize_max_frame_bytes()
end
defp max_frame_bytes(_opts), do: configured_max_frame_bytes()
defp normalize_max_frame_bytes(value) when is_integer(value) and value > 0, do: value
defp normalize_max_frame_bytes(_value), do: configured_max_frame_bytes()
defp configured_max_frame_bytes do
:parrhesia
|> Application.get_env(:limits, [])
|> Keyword.get(:max_frame_bytes, @default_max_frame_bytes)
end
defp max_event_bytes(opts) when is_list(opts) do
opts
|> Keyword.get(:max_event_bytes)
|> normalize_max_event_bytes()
end
defp max_event_bytes(opts) when is_map(opts) do
opts
|> Map.get(:max_event_bytes)
|> normalize_max_event_bytes()
end
defp max_event_bytes(_opts), do: configured_max_event_bytes()
defp normalize_max_event_bytes(value) when is_integer(value) and value > 0, do: value
defp normalize_max_event_bytes(_value), do: configured_max_event_bytes()
defp configured_max_event_bytes do
:parrhesia
|> Application.get_env(:limits, [])
|> Keyword.get(:max_event_bytes, @default_max_event_bytes)
end
defp max_event_ingest_per_window(opts) when is_list(opts) do
opts
|> Keyword.get(:max_event_ingest_per_window)
|> normalize_max_event_ingest_per_window()
end
defp max_event_ingest_per_window(opts) when is_map(opts) do
opts
|> Map.get(:max_event_ingest_per_window)
|> normalize_max_event_ingest_per_window()
end
defp max_event_ingest_per_window(_opts), do: configured_max_event_ingest_per_window()
defp normalize_max_event_ingest_per_window(value) when is_integer(value) and value > 0,
do: value
defp normalize_max_event_ingest_per_window(_value),
do: configured_max_event_ingest_per_window()
defp configured_max_event_ingest_per_window do
:parrhesia
|> Application.get_env(:limits, [])
|> Keyword.get(:max_event_ingest_per_window, @default_event_ingest_rate_limit)
end
defp event_ingest_window_seconds(opts) when is_list(opts) do
opts
|> Keyword.get(:event_ingest_window_seconds)
|> normalize_event_ingest_window_seconds()
end
defp event_ingest_window_seconds(opts) when is_map(opts) do
opts
|> Map.get(:event_ingest_window_seconds)
|> normalize_event_ingest_window_seconds()
end
defp event_ingest_window_seconds(_opts), do: configured_event_ingest_window_seconds()
defp normalize_event_ingest_window_seconds(value) when is_integer(value) and value > 0,
do: value
defp normalize_event_ingest_window_seconds(_value), do: configured_event_ingest_window_seconds()
defp configured_event_ingest_window_seconds do
:parrhesia
|> Application.get_env(:limits, [])
|> Keyword.get(:event_ingest_window_seconds, @default_event_ingest_window_seconds)
end
defp auth_max_age_seconds(opts) when is_list(opts) do
opts
|> Keyword.get(:auth_max_age_seconds)
|> normalize_auth_max_age_seconds()
end
defp auth_max_age_seconds(opts) when is_map(opts) do
opts
|> Map.get(:auth_max_age_seconds)
|> normalize_auth_max_age_seconds()
end
defp auth_max_age_seconds(_opts), do: configured_auth_max_age_seconds()
defp normalize_auth_max_age_seconds(value) when is_integer(value) and value > 0, do: value
defp normalize_auth_max_age_seconds(_value), do: configured_auth_max_age_seconds()
defp configured_auth_max_age_seconds do
:parrhesia
|> Application.get_env(:limits, [])
|> Keyword.get(:auth_max_age_seconds, @default_auth_max_age_seconds)
end
defp maybe_allow_event_ingest(
%__MODULE__{
event_ingest_window_started_at_ms: window_started_at_ms,
event_ingest_window_seconds: window_seconds,
event_ingest_count: count,
max_event_ingest_per_window: max_event_ingest_per_window
} = state
) do
now_ms = System.monotonic_time(:millisecond)
window_ms = window_seconds * 1000
cond do
now_ms - window_started_at_ms >= window_ms ->
{:ok,
%__MODULE__{
state
| event_ingest_window_started_at_ms: now_ms,
event_ingest_count: 1
}}
count < max_event_ingest_per_window ->
{:ok, %__MODULE__{state | event_ingest_count: count + 1}}
true ->
{:error, :event_rate_limited}
end
end
defp validate_event_payload_size(event, max_event_bytes)
when is_map(event) and is_integer(max_event_bytes) and max_event_bytes > 0 do
if byte_size(JSON.encode!(event)) <= max_event_bytes do
:ok
else
{:error, :event_too_large}
end
end
defp validate_event_payload_size(_event, _max_event_bytes), do: :ok
defp ephemeral_kind?(kind) when is_integer(kind), do: kind >= 20_000 and kind < 30_000
defp ephemeral_kind?(_kind), do: false
defp accept_ephemeral_events? do
:parrhesia
|> Application.get_env(:policies, [])
|> Keyword.get(:accept_ephemeral_events, true)
end
end end

View File

@@ -50,7 +50,12 @@ defmodule Parrhesia.Web.Router do
|> send_resp(200, body) |> send_resp(200, body)
else else
conn conn
|> WebSockAdapter.upgrade(Parrhesia.Web.Connection, %{}, timeout: 60_000) |> WebSockAdapter.upgrade(
Parrhesia.Web.Connection,
%{relay_url: relay_url(conn)},
timeout: 60_000,
max_frame_size: max_frame_bytes()
)
|> halt() |> halt()
end end
end end
@@ -64,4 +69,25 @@ defmodule Parrhesia.Web.Router do
|> get_req_header("accept") |> get_req_header("accept")
|> Enum.any?(&String.contains?(&1, "application/nostr+json")) |> Enum.any?(&String.contains?(&1, "application/nostr+json"))
end end
defp relay_url(conn) do
ws_scheme = if conn.scheme == :https, do: "wss", else: "ws"
port_segment =
if default_http_port?(conn.scheme, conn.port) do
""
else
":#{conn.port}"
end
"#{ws_scheme}://#{conn.host}#{port_segment}#{conn.request_path}"
end
defp default_http_port?(:http, 80), do: true
defp default_http_port?(:https, 443), do: true
defp default_http_port?(_scheme, _port), do: false
defp max_frame_bytes do
Parrhesia.Config.get([:limits, :max_frame_bytes], 1_048_576)
end
end end

View File

@@ -350,7 +350,7 @@ async function requestGiftWrapsWithAuth({ relayUrl, relayHttpUrl, signer, recipi
created_at: unixNow(), created_at: unixNow(),
tags: [ tags: [
["challenge", challenge], ["challenge", challenge],
["relay", relayHttpUrl], ["relay", relayUrl],
], ],
content: "", content: "",
}); });

View File

@@ -5,8 +5,12 @@ defmodule Parrhesia.ConfigTest do
assert Parrhesia.Config.get([:limits, :max_frame_bytes]) == 1_048_576 assert Parrhesia.Config.get([:limits, :max_frame_bytes]) == 1_048_576
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, :event_ingest_window_seconds]) == 1
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
assert Parrhesia.Config.get([:limits, :max_filter_limit]) == 500 assert Parrhesia.Config.get([:limits, :max_filter_limit]) == 500
assert Parrhesia.Config.get([:relay_url]) == "ws://localhost:4000/relay"
assert Parrhesia.Config.get([:policies, :auth_required_for_writes]) == false 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_max_imeta_tags_per_event]) == 8
assert Parrhesia.Config.get([:policies, :marmot_media_reject_mip04_v1]) == true assert Parrhesia.Config.get([:policies, :marmot_media_reject_mip04_v1]) == true

View File

@@ -248,6 +248,26 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsQueryCountTest do
assert {:ok, 0} = Events.count(%{}, filters, requester_pubkeys: []) assert {:ok, 0} = Events.count(%{}, filters, requester_pubkeys: [])
end end
test "search treats % and _ as literals" do
matching =
persist_event(%{
"kind" => 1,
"content" => "literal 100%_match value"
})
_other =
persist_event(%{
"kind" => 1,
"content" => "literal 100Xmatch value"
})
filters = [%{"kinds" => [1], "search" => "100%_match"}]
assert {:ok, [result]} = Events.query(%{}, filters, [])
assert result["id"] == matching["id"]
assert {:ok, 1} = Events.count(%{}, filters, [])
end
test "query/3 combines search and media metadata tag filters" do test "query/3 combines search and media metadata tag filters" do
media_hash = String.duplicate("a", 64) media_hash = String.duplicate("a", 64)

View File

@@ -37,7 +37,7 @@ defmodule Parrhesia.Web.ConformanceTest do
event = valid_event() event = valid_event()
assert {:push, {:text, frame}, ^state} = assert {:push, {:text, frame}, _next_state} =
Connection.handle_in({JSON.encode!(["EVENT", event]), [opcode: :text]}, state) Connection.handle_in({JSON.encode!(["EVENT", event]), [opcode: :text]}, state)
assert JSON.decode!(frame) == ["OK", event["id"], true, "ok: event stored"] assert JSON.decode!(frame) == ["OK", event["id"], true, "ok: event stored"]
@@ -54,7 +54,7 @@ defmodule Parrhesia.Web.ConformanceTest do
"content" => "encrypted-welcome-payload" "content" => "encrypted-welcome-payload"
}) })
assert {:push, {:text, ok_frame}, ^state} = assert {:push, {:text, ok_frame}, _next_state} =
Connection.handle_in( Connection.handle_in(
{JSON.encode!(["EVENT", wrapped_welcome]), [opcode: :text]}, {JSON.encode!(["EVENT", wrapped_welcome]), [opcode: :text]},
state state
@@ -64,7 +64,7 @@ defmodule Parrhesia.Web.ConformanceTest do
req_payload = JSON.encode!(["REQ", "sub-welcome", %{"kinds" => [1059], "#p" => [recipient]}]) req_payload = JSON.encode!(["REQ", "sub-welcome", %{"kinds" => [1059], "#p" => [recipient]}])
assert {:push, restricted_frames, ^state} = assert {:push, restricted_frames, _next_state} =
Connection.handle_in({req_payload, [opcode: :text]}, state) Connection.handle_in({req_payload, [opcode: :text]}, state)
decoded_restricted = decoded_restricted =
@@ -106,7 +106,7 @@ defmodule Parrhesia.Web.ConformanceTest do
"content" => Base.encode64("commit-envelope") "content" => Base.encode64("commit-envelope")
}) })
assert {:push, {:text, commit_ok_frame}, ^state} = assert {:push, {:text, commit_ok_frame}, _next_state} =
Connection.handle_in( Connection.handle_in(
{JSON.encode!(["EVENT", commit_event]), [opcode: :text]}, {JSON.encode!(["EVENT", commit_event]), [opcode: :text]},
state state
@@ -124,7 +124,7 @@ defmodule Parrhesia.Web.ConformanceTest do
"content" => "encrypted-welcome-payload" "content" => "encrypted-welcome-payload"
}) })
assert {:push, {:text, welcome_ok_frame}, ^state} = assert {:push, {:text, welcome_ok_frame}, _next_state} =
Connection.handle_in( Connection.handle_in(
{JSON.encode!(["EVENT", wrapped_welcome]), [opcode: :text]}, {JSON.encode!(["EVENT", wrapped_welcome]), [opcode: :text]},
state state
@@ -187,7 +187,7 @@ defmodule Parrhesia.Web.ConformanceTest do
"content" => "encrypted-push" "content" => "encrypted-push"
}) })
assert {:push, {:text, relay_ok_frame}, ^state} = assert {:push, {:text, relay_ok_frame}, _next_state} =
Connection.handle_in( Connection.handle_in(
{JSON.encode!(["EVENT", relay_list_event]), [opcode: :text]}, {JSON.encode!(["EVENT", relay_list_event]), [opcode: :text]},
state state
@@ -200,7 +200,7 @@ defmodule Parrhesia.Web.ConformanceTest do
"ok: event stored" "ok: event stored"
] ]
assert {:push, {:text, trigger_ok_frame}, ^state} = assert {:push, {:text, trigger_ok_frame}, _next_state} =
Connection.handle_in( Connection.handle_in(
{JSON.encode!(["EVENT", push_trigger]), [opcode: :text]}, {JSON.encode!(["EVENT", push_trigger]), [opcode: :text]},
state state
@@ -232,11 +232,13 @@ defmodule Parrhesia.Web.ConformanceTest do
end end
defp valid_auth_event(challenge, pubkey) do defp valid_auth_event(challenge, pubkey) do
relay_url = Parrhesia.Config.get([:relay_url])
event = %{ event = %{
"pubkey" => pubkey, "pubkey" => pubkey,
"created_at" => System.system_time(:second), "created_at" => System.system_time(:second),
"kind" => 22_242, "kind" => 22_242,
"tags" => [["challenge", challenge]], "tags" => [["challenge", challenge], ["relay", relay_url]],
"content" => "", "content" => "",
"sig" => String.duplicate("8", 128) "sig" => String.duplicate("8", 128)
} }

View File

@@ -34,7 +34,7 @@ defmodule Parrhesia.Web.ConnectionTest do
payload = JSON.encode!(["COUNT", "sub-count", %{"kinds" => [1]}]) payload = JSON.encode!(["COUNT", "sub-count", %{"kinds" => [1]}])
assert {:push, {:text, response}, ^state} = assert {:push, {:text, response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert ["COUNT", "sub-count", payload] = JSON.decode!(response) assert ["COUNT", "sub-count", payload] = JSON.decode!(response)
@@ -62,7 +62,7 @@ defmodule Parrhesia.Web.ConnectionTest do
auth_event = valid_auth_event("wrong-challenge") auth_event = valid_auth_event("wrong-challenge")
payload = JSON.encode!(["AUTH", auth_event]) payload = JSON.encode!(["AUTH", auth_event])
assert {:push, frames, ^state} = Connection.handle_in({payload, [opcode: :text]}, state) assert {:push, frames, _next_state} = Connection.handle_in({payload, [opcode: :text]}, state)
decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end) decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end)
@@ -73,6 +73,38 @@ defmodule Parrhesia.Web.ConnectionTest do
end) end)
end end
test "AUTH rejects relay tag mismatch" do
state = connection_state(relay_url: "ws://localhost:4000/relay")
auth_event = valid_auth_event(state.auth_challenge, relay_url: "ws://attacker.example/relay")
payload = JSON.encode!(["AUTH", auth_event])
assert {:push, frames, _next_state} = Connection.handle_in({payload, [opcode: :text]}, state)
decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end)
assert ["OK", _, false, "invalid: AUTH relay tag mismatch"] =
Enum.find(decoded, fn frame -> List.first(frame) == "OK" end)
end
test "AUTH rejects stale events" do
state = connection_state(auth_max_age_seconds: 600)
stale_auth_event =
valid_auth_event(state.auth_challenge,
created_at: System.system_time(:second) - 601
)
payload = JSON.encode!(["AUTH", stale_auth_event])
assert {:push, frames, _next_state} = Connection.handle_in({payload, [opcode: :text]}, state)
decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end)
assert ["OK", _, false, "invalid: AUTH event is too old"] =
Enum.find(decoded, fn frame -> List.first(frame) == "OK" end)
end
test "protected event is rejected unless authenticated" do test "protected event is rejected unless authenticated" do
state = connection_state() state = connection_state()
@@ -83,7 +115,7 @@ defmodule Parrhesia.Web.ConnectionTest do
payload = JSON.encode!(["EVENT", event]) payload = JSON.encode!(["EVENT", event])
assert {:push, frames, ^state} = Connection.handle_in({payload, [opcode: :text]}, state) assert {:push, frames, _next_state} = Connection.handle_in({payload, [opcode: :text]}, state)
decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end) decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end)
@@ -98,7 +130,8 @@ defmodule Parrhesia.Web.ConnectionTest do
req_payload = JSON.encode!(["REQ", "sub-445", %{"kinds" => [445]}]) req_payload = JSON.encode!(["REQ", "sub-445", %{"kinds" => [445]}])
assert {:push, frames, ^state} = Connection.handle_in({req_payload, [opcode: :text]}, state) assert {:push, frames, _next_state} =
Connection.handle_in({req_payload, [opcode: :text]}, state)
decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end) decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end)
@@ -112,19 +145,99 @@ defmodule Parrhesia.Web.ConnectionTest do
event = valid_event() event = valid_event()
payload = JSON.encode!(["EVENT", event]) payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, response}, ^state} = assert {:push, {:text, response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(response) == ["OK", event["id"], true, "ok: event stored"] assert JSON.decode!(response) == ["OK", event["id"], true, "ok: event stored"]
end end
test "ephemeral events are accepted without persistence" do
previous_policies = Application.get_env(:parrhesia, :policies, [])
Application.put_env(
:parrhesia,
:policies,
Keyword.put(previous_policies, :accept_ephemeral_events, true)
)
on_exit(fn ->
Application.put_env(:parrhesia, :policies, previous_policies)
end)
state = connection_state()
event = valid_event() |> Map.put("kind", 20_001) |> recalculate_event_id()
payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(response) == ["OK", event["id"], true, "ok: ephemeral event accepted"]
assert {:ok, nil} = Parrhesia.Storage.events().get_event(%{}, event["id"])
end
test "EVENT ingest enforces per-connection rate limits" do
state = connection_state(max_event_ingest_per_window: 1, event_ingest_window_seconds: 60)
first_event = valid_event(%{"content" => "first"})
second_event = valid_event(%{"content" => "second"})
assert {:push, {:text, first_response}, next_state} =
Connection.handle_in({JSON.encode!(["EVENT", first_event]), [opcode: :text]}, state)
assert JSON.decode!(first_response) == ["OK", first_event["id"], true, "ok: event stored"]
assert {:push, {:text, second_response}, ^next_state} =
Connection.handle_in(
{JSON.encode!(["EVENT", second_event]), [opcode: :text]},
next_state
)
assert JSON.decode!(second_response) == [
"OK",
second_event["id"],
false,
"rate-limited: too many EVENT messages"
]
end
test "EVENT ingest enforces max event bytes" do
state = connection_state(max_event_bytes: 128)
large_event =
valid_event(%{"content" => String.duplicate("x", 256)})
|> recalculate_event_id()
assert {:push, {:text, response}, _next_state} =
Connection.handle_in({JSON.encode!(["EVENT", large_event]), [opcode: :text]}, state)
assert JSON.decode!(response) == [
"OK",
large_event["id"],
false,
"invalid: event exceeds max event size"
]
end
test "text frame size is rejected before JSON decoding" do
state = connection_state(max_frame_bytes: 16)
assert {:push, {:text, response}, _next_state} =
Connection.handle_in({String.duplicate("x", 17), [opcode: :text]}, state)
assert JSON.decode!(response) == [
"NOTICE",
"invalid: websocket frame exceeds max frame size"
]
end
test "invalid EVENT replies with OK false invalid prefix" do test "invalid EVENT replies with OK false invalid prefix" do
state = connection_state() state = connection_state()
event = valid_event() |> Map.put("sig", "nope") event = valid_event() |> Map.put("sig", "nope")
payload = JSON.encode!(["EVENT", event]) payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, response}, ^state} = assert {:push, {:text, response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(response) == [ assert JSON.decode!(response) == [
@@ -147,7 +260,7 @@ defmodule Parrhesia.Web.ConnectionTest do
payload = JSON.encode!(["EVENT", event]) payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, response}, ^state} = assert {:push, {:text, response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(response) == [ assert JSON.decode!(response) == [
@@ -170,7 +283,7 @@ defmodule Parrhesia.Web.ConnectionTest do
payload = JSON.encode!(["EVENT", event]) payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, response}, ^state} = assert {:push, {:text, response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(response) == [ assert JSON.decode!(response) == [
@@ -204,7 +317,7 @@ defmodule Parrhesia.Web.ConnectionTest do
payload = JSON.encode!(["EVENT", event]) payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, response}, ^state} = assert {:push, {:text, response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(response) == [ assert JSON.decode!(response) == [
@@ -255,7 +368,7 @@ defmodule Parrhesia.Web.ConnectionTest do
payload = JSON.encode!(["EVENT", event]) payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, response}, ^state} = assert {:push, {:text, response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(response) == [ assert JSON.decode!(response) == [
@@ -306,12 +419,12 @@ defmodule Parrhesia.Web.ConnectionTest do
payload = JSON.encode!(["EVENT", event]) payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, first_response}, ^state} = assert {:push, {:text, first_response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(first_response) == ["OK", event["id"], true, "ok: event stored"] assert JSON.decode!(first_response) == ["OK", event["id"], true, "ok: event stored"]
assert {:push, {:text, second_response}, ^state} = assert {:push, {:text, second_response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, state) Connection.handle_in({payload, [opcode: :text]}, state)
assert JSON.decode!(second_response) == [ assert JSON.decode!(second_response) == [
@@ -327,7 +440,7 @@ defmodule Parrhesia.Web.ConnectionTest do
open_payload = JSON.encode!(["NEG-OPEN", "neg-1", %{"cursor" => 0}]) open_payload = JSON.encode!(["NEG-OPEN", "neg-1", %{"cursor" => 0}])
assert {:push, {:text, open_response}, ^state} = assert {:push, {:text, open_response}, _next_state} =
Connection.handle_in({open_payload, [opcode: :text]}, state) Connection.handle_in({open_payload, [opcode: :text]}, state)
assert ["NEG-MSG", "neg-1", %{"status" => "open", "cursor" => 0}] = assert ["NEG-MSG", "neg-1", %{"status" => "open", "cursor" => 0}] =
@@ -335,7 +448,7 @@ defmodule Parrhesia.Web.ConnectionTest do
close_payload = JSON.encode!(["NEG-CLOSE", "neg-1"]) close_payload = JSON.encode!(["NEG-CLOSE", "neg-1"])
assert {:push, {:text, close_response}, ^state} = assert {:push, {:text, close_response}, _next_state} =
Connection.handle_in({close_payload, [opcode: :text]}, state) Connection.handle_in({close_payload, [opcode: :text]}, state)
assert JSON.decode!(close_response) == ["NEG-MSG", "neg-1", %{"status" => "closed"}] assert JSON.decode!(close_response) == ["NEG-MSG", "neg-1", %{"status" => "closed"}]
@@ -470,14 +583,15 @@ defmodule Parrhesia.Web.ConnectionTest do
} }
end end
defp valid_auth_event(challenge) do defp valid_auth_event(challenge, opts \\ []) do
now = System.system_time(:second) now = Keyword.get(opts, :created_at, System.system_time(:second))
relay_url = Keyword.get(opts, :relay_url, Parrhesia.Config.get([:relay_url]))
base = %{ base = %{
"pubkey" => String.duplicate("9", 64), "pubkey" => String.duplicate("9", 64),
"created_at" => now, "created_at" => now,
"kind" => 22_242, "kind" => 22_242,
"tags" => [["challenge", challenge]], "tags" => [["challenge", challenge], ["relay", relay_url]],
"content" => "", "content" => "",
"sig" => String.duplicate("8", 128) "sig" => String.duplicate("8", 128)
} }
@@ -510,7 +624,7 @@ defmodule Parrhesia.Web.ConnectionTest do
end end
end end
defp valid_event do defp valid_event(overrides \\ %{}) do
base_event = %{ base_event = %{
"pubkey" => String.duplicate("1", 64), "pubkey" => String.duplicate("1", 64),
"created_at" => System.system_time(:second), "created_at" => System.system_time(:second),
@@ -520,6 +634,12 @@ defmodule Parrhesia.Web.ConnectionTest do
"sig" => String.duplicate("3", 128) "sig" => String.duplicate("3", 128)
} }
Map.put(base_event, "id", EventValidator.compute_id(base_event)) base_event
|> Map.merge(overrides)
|> recalculate_event_id()
end
defp recalculate_event_id(event) do
Map.put(event, "id", EventValidator.compute_id(event))
end end
end end