diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index 4a84c3a..18bc380 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -152,10 +152,12 @@ resource when an app allows account creation: ## Abuse controls and operational limits -`support_request`, `support_request_ip`, and `content_removal_notice` are -supported names in the shared PostgreSQL rate limiter. Public support intake -checks both its normalized account/email scope and the trusted client-IP scope -in one database statement. As with the other actions, no numeric policy is +`support_request`, `support_request_ip`, `content_removal_notice`, and +`content_removal_notice_ip` are supported names in the shared PostgreSQL rate +limiter. Public support and content-removal intake each check both their +normalized account/email scope and the trusted client-IP scope in one database +statement. This prevents a public submitter from evading the network policy by +rotating contact addresses. As with the other actions, no numeric policy is enabled unless the operator supplies values justified by measured traffic through `RATE_LIMIT_POLICIES_JSON`. CSRF protection, validation, exact URL limits, contact verification, staff authorization, and audit events apply diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex index d9c0ee9..15129a2 100644 --- a/lib/who_need_help/content_removal.ex +++ b/lib/who_need_help/content_removal.ex @@ -22,7 +22,11 @@ defmodule WhoNeedHelp.ContentRemoval do Notice.submission_changeset(notice, attrs) end - def create_notice(scope, regime, attrs) when regime in [:general, :dsa, :take_it_down] do + def create_notice(scope, regime, attrs) when regime in [:general, :dsa, :take_it_down], + do: create_notice(scope, regime, attrs, nil) + + def create_notice(scope, regime, attrs, client_scope) + when regime in [:general, :dsa, :take_it_down] do user = scope_user(scope) attrs = normalize_keys(attrs) attrs = maybe_use_user_email(attrs, user) @@ -32,8 +36,8 @@ defmodule WhoNeedHelp.ContentRemoval do response_due_at = if regime == :take_it_down, do: DateTime.add(DateTime.utc_now(:second), 48, :hour) - with {:ok, _limit} <- - RateLimiter.check(:content_removal_notice, rate_scope(user, attrs["contact_email"])) do + with {:ok, _limits} <- + RateLimiter.check_many(rate_scopes(user, attrs["contact_email"], client_scope)) do Repo.transact(fn -> with {:ok, notice} <- %Notice{ @@ -363,6 +367,17 @@ defmodule WhoNeedHelp.ContentRemoval do defp maybe_use_user_email(attrs, nil), do: attrs + defp rate_scopes(user, contact_email, client_scope) do + [{:content_removal_notice, rate_scope(user, contact_email)}] + |> maybe_add_client_rate_scope(client_scope) + end + + defp maybe_add_client_rate_scope(scopes, client_scope) + when is_binary(client_scope) and client_scope != "", + do: [{:content_removal_notice_ip, client_scope} | scopes] + + defp maybe_add_client_rate_scope(scopes, _client_scope), do: scopes + defp rate_scope(%User{id: id}, _email), do: "user:#{id}" defp rate_scope(nil, email) when is_binary(email), diff --git a/lib/who_need_help_web/controllers/content_removal_controller.ex b/lib/who_need_help_web/controllers/content_removal_controller.ex index aff35dc..4570ce6 100644 --- a/lib/who_need_help_web/controllers/content_removal_controller.ex +++ b/lib/who_need_help_web/controllers/content_removal_controller.ex @@ -3,6 +3,7 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do alias WhoNeedHelp.ContentRemoval alias WhoNeedHelp.ContentRemoval.Notice + alias WhoNeedHelpWeb.ClientIp def index(conn, _params) do notices = ContentRemoval.list_for_user(conn.assigns.current_scope) @@ -62,7 +63,12 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do end defp create_notice(conn, regime, params) do - case ContentRemoval.create_notice(conn.assigns.current_scope, regime, params) do + case ContentRemoval.create_notice( + conn.assigns.current_scope, + regime, + params, + ClientIp.rate_limit_scope(conn) + ) do {:ok, notice} -> redirect(conn, to: ~p"/legal/content-removal/received?reference=#{notice.reference}" diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs index 838fed3..ff7a82c 100644 --- a/test/who_need_help_web/controllers/support_controller_test.exs +++ b/test/who_need_help_web/controllers/support_controller_test.exs @@ -120,6 +120,51 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do assert_email_sent() end + test "removal intake enforces independent contact and client IP limits", %{conn: conn} do + previous = Application.get_env(:who_need_help, :rate_limit_policies) + + Application.put_env(:who_need_help, :rate_limit_policies, %{ + "content_removal_notice" => %{"limit" => 1, "window_seconds" => 60}, + "content_removal_notice_ip" => %{"limit" => 2, "window_seconds" => 60} + }) + + on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end) + + attrs = fn email, suffix -> + %{ + "notice" => %{ + "category" => "privacy_violation", + "submitter_name" => "Rate limit reporter", + "contact_email" => email, + "relationship" => "self", + "content_locations" => "https://example.test/requests/removal-rate-#{suffix}", + "explanation" => + "This valid notice verifies both contact and network-scoped intake limits.", + "electronic_signature" => "Rate limit reporter", + "good_faith" => "true", + "accurate_complete" => "true" + } + } + end + + assert conn + |> post(~p"/legal/content-removal", attrs.("first-removal@example.com", "one")) + |> redirected_to() =~ "/legal/content-removal/received" + + assert conn + |> Map.put(:remote_ip, {198, 51, 100, 22}) + |> post(~p"/legal/content-removal", attrs.("first-removal@example.com", "two")) + |> response(429) + + assert conn + |> post(~p"/legal/content-removal", attrs.("second-removal@example.com", "three")) + |> redirected_to() =~ "/legal/content-removal/received" + + assert conn + |> post(~p"/legal/content-removal", attrs.("third-removal@example.com", "four")) + |> response(429) + end + test "rejects malformed public form shapes without crashing", %{conn: conn} do assert response(post(conn, ~p"/support", %{"support_request" => "invalid"}), 400)