diff --git a/lib/graphql/middleware/rate_limiter.ex b/lib/graphql/middleware/rate_limiter.ex new file mode 100644 index 000000000..0954ab36b --- /dev/null +++ b/lib/graphql/middleware/rate_limiter.ex @@ -0,0 +1,42 @@ +defmodule Mobilizon.GraphQL.Middleware.RateLimiter do + @moduledoc """ + Absinthe Error Handler + """ + alias Mobilizon.Actors.Actor + alias Mobilizon.Users + alias Mobilizon.Users.User + + @behaviour Absinthe.Middleware + + @impl Absinthe.Middleware + @spec call(Absinthe.Resolution.t(), any) :: Absinthe.Resolution.t() + def call( + %Absinthe.Resolution{context: %{current_user: %User{id: user_id} = user} = context} = + resolution, + _config + ) do + case Cachex.fetch(:default_actors, to_string(user_id), fn -> default(user) end) do + {status, %Actor{preferred_username: preferred_username} = current_actor} + when status in [:ok, :commit] -> + Sentry.Context.set_user_context(%{name: preferred_username}) + context = Map.put(context, :current_actor, current_actor) + %Absinthe.Resolution{resolution | context: context} + + {_, nil} -> + resolution + end + end + + def call(%Absinthe.Resolution{} = resolution, _config), do: resolution + + @spec default(User.t()) :: {:commit, Actor.t()} | {:ignore, nil} + defp default(%User{} = user) do + case Users.get_actor_for_user(user) do + %Actor{} = actor -> + {:commit, actor} + + nil -> + {:ignore, nil} + end + end +end diff --git a/lib/mobilizon.ex b/lib/mobilizon.ex index f200c5254..cb1bfe423 100644 --- a/lib/mobilizon.ex +++ b/lib/mobilizon.ex @@ -48,6 +48,7 @@ defmodule Mobilizon do Guardian.DB.Token.SweeperServer, ActivityPub.Federator, TzWorld.Backend.DetsWithIndexCache, + {PlugAttack.Storage.Ets, name: Mobilizon.Web.Plugs.PlugAttack.Storage, clean_period: 60_000}, cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1), cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1), cachex_spec( diff --git a/lib/web/plugs/plug_attack.ex b/lib/web/plugs/plug_attack.ex new file mode 100644 index 000000000..a79f4e43a --- /dev/null +++ b/lib/web/plugs/plug_attack.ex @@ -0,0 +1,76 @@ +defmodule Mobilizon.Web.Plugs.PlugAttack do + @moduledoc false + use PlugAttack + import Plug.Conn + + @alg :sha512 + + rule "allow local", conn do + allow(conn.remote_ip == {127, 0, 0, 1}) + end + + # rule "throttle by ip", conn do + # throttle hash_ip(@alg, convert_ip(conn.remote_ip)), + # period: 60_000, limit: 5, + # storage: {PlugAttack.Storage.Ets, Mobilizon.Web.Plugs.PlugAttack.Storage} + # end + + rule "fail2ban by ip", conn do + fail2ban(hash_ip(@alg, convert_ip(conn.remote_ip)), + period: 6_000, + limit: 5, + ban_for: 90_000, + storage: {PlugAttack.Storage.Ets, Mobilizon.Web.Plugs.PlugAttack.Storage} + ) + end + + def allow_action(conn, {:throttle, data}, opts) do + conn + |> add_throttling_headers(data) + |> allow_action(true, opts) + end + + def allow_action(conn, _data, _opts) do + conn + end + + def block_action(conn, {:throttle, data}, opts) do + conn + |> add_throttling_headers(data) + |> block_action(false, opts) + end + + def block_action(conn, _data, _opts) do + conn + |> send_resp(:forbidden, "Forbidden\n") + # It's important to halt connection once we send a response early + |> halt + end + + defp add_throttling_headers(conn, data) do + # The expires_at value is a unix time in milliseconds, we want to return one + # in seconds + reset = div(data[:expires_at], 1_000) + + conn + |> put_resp_header("x-ratelimit-limit", to_string(data[:limit])) + |> put_resp_header("x-ratelimit-remaining", to_string(data[:remaining])) + |> put_resp_header("x-ratelimit-reset", to_string(reset)) + end + + defp hash_ip(alg, ip) do + ip_hash_secret = + :mobilizon + |> Application.get_env(Mobilizon.Web.Endpoint) + |> Keyword.get(:secret_key_base, "HASH_IP_SECRET") + + :crypto.mac(:hmac, alg, ip_hash_secret, ip) + end + + defp convert_ip(ip) do + ip + |> Tuple.to_list() + |> List.to_charlist() + |> IO.chardata_to_string() + end +end diff --git a/lib/web/router.ex b/lib/web/router.ex index a7e400160..267fc518b 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -3,20 +3,31 @@ defmodule Mobilizon.Web.Router do Router for mobilizon app """ use Mobilizon.Web, :router + alias Cldr.Plug.AcceptLanguage + + alias Mobilizon.Web.Plugs.{ + HTTPSecurityPlug, + HTTPSignatures, + MappedSignatureToIdentity, + PlugAttack, + SetLocalePlug + } + import Mobilizon.Web.RequestContext pipeline :graphql do # plug(:accepts, ["json"]) plug(:put_request_context) plug(Mobilizon.Web.Auth.Pipeline) - plug(Mobilizon.Web.Plugs.SetLocalePlug) + plug(SetLocalePlug) + plug(PlugAttack) end pipeline :graphiql do plug(Mobilizon.Web.Auth.Pipeline) - plug(Mobilizon.Web.Plugs.SetLocalePlug) + plug(SetLocalePlug) - plug(Mobilizon.Web.Plugs.HTTPSecurityPlug, + plug(HTTPSecurityPlug, script_src: ["cdn.jsdelivr.net 'sha256-zkCwvTwqwJMew/8TKv7bTLh94XRSNBvT/o/NZCuf5Kc='"], style_src: ["cdn.jsdelivr.net 'unsafe-inline'"], font_src: ["cdn.jsdelivr.net"] @@ -25,40 +36,46 @@ defmodule Mobilizon.Web.Router do pipeline :host_meta do plug(:put_request_context) + plug(PlugAttack) plug(:accepts, ["xrd-xml"]) end pipeline :well_known do plug(:put_request_context) + plug(PlugAttack) plug(:accepts, ["json", "jrd-json"]) end pipeline :activity_pub_signature do plug(:put_request_context) - plug(Mobilizon.Web.Plugs.HTTPSignatures) - plug(Mobilizon.Web.Plugs.MappedSignatureToIdentity) + plug(HTTPSignatures) + plug(MappedSignatureToIdentity) + plug(PlugAttack) end pipeline :relay do plug(:put_request_context) - plug(Mobilizon.Web.Plugs.HTTPSignatures) - plug(Mobilizon.Web.Plugs.MappedSignatureToIdentity) + plug(HTTPSignatures) + plug(MappedSignatureToIdentity) + plug(PlugAttack) plug(:accepts, ["activity-json", "json"]) end pipeline :activity_pub do plug(:put_request_context) + plug(PlugAttack) plug(:accepts, ["activity-json"]) end pipeline :activity_pub_and_html do plug(:put_request_context) + plug(PlugAttack) plug(:accepts, ["html", "activity-json"]) plug(:put_secure_browser_headers) - plug(Mobilizon.Web.Plugs.SetLocalePlug) + plug(SetLocalePlug) - plug(Cldr.Plug.AcceptLanguage, + plug(AcceptLanguage, cldr_backend: Mobilizon.Cldr, no_match_log_level: :debug ) @@ -67,16 +84,18 @@ defmodule Mobilizon.Web.Router do pipeline :atom_and_ical do plug(:put_request_context) plug(:put_secure_browser_headers) + plug(PlugAttack) plug(:accepts, ["atom", "ics", "html", "xml"]) end pipeline :browser do plug(:put_request_context) + plug(PlugAttack) plug(Plug.Static, at: "/", from: "priv/static") - plug(Mobilizon.Web.Plugs.SetLocalePlug) + plug(SetLocalePlug) - plug(Cldr.Plug.AcceptLanguage, + plug(AcceptLanguage, cldr_backend: Mobilizon.Cldr, no_match_log_level: :debug ) diff --git a/mix.exs b/mix.exs index 835749c3f..15db61420 100644 --- a/mix.exs +++ b/mix.exs @@ -204,6 +204,7 @@ defmodule Mobilizon.Mixfile do {:tz_world, "~> 1.0"}, {:tzdata, "~> 1.1"}, {:codepagex, "~> 0.1.6"}, + {:plug_attack, "~> 0.4.2"}, # Dev and test dependencies {:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]}, {:ex_machina, "~> 2.3", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 80088787d..cc136d115 100644 --- a/mix.lock +++ b/mix.lock @@ -107,6 +107,7 @@ "phoenix_swoosh": {:hex, :phoenix_swoosh, "1.0.1", "0db6eb6405a6b06cae4fdf4144659b3f4fee4553e2856fe8a53ba12e9fb21a74", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "e34890004baec08f0fa12bd8c77bf64bfb4156b84a07fb79da9322fa94bc3781"}, "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, + "plug_attack": {:hex, :plug_attack, "0.4.3", "88e6c464d68b1491aa083a0347d59d58ba71a7e591a7f8e1b675e8c7792a0ba8", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9ed6fb8a6f613a36040f2875130a21187126c5625092f24bc851f7f12a8cbdc1"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, "postgrex": {:hex, :postgrex, "0.16.2", "0f83198d0e73a36e8d716b90f45f3bde75b5eebf4ade4f43fa1f88c90a812f74", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "a9ea589754d9d4d076121090662b7afe155b374897a6550eb288f11d755acfa0"},