Rate limit content removal by client network

This commit is contained in:
SimpleTest 2026-08-03 01:36:32 +03:00
parent f5c0d1d9b2
commit accc0845bd
4 changed files with 76 additions and 8 deletions

View File

@ -152,10 +152,12 @@ resource when an app allows account creation:
## Abuse controls and operational limits ## Abuse controls and operational limits
`support_request`, `support_request_ip`, and `content_removal_notice` are `support_request`, `support_request_ip`, `content_removal_notice`, and
supported names in the shared PostgreSQL rate limiter. Public support intake `content_removal_notice_ip` are supported names in the shared PostgreSQL rate
checks both its normalized account/email scope and the trusted client-IP scope limiter. Public support and content-removal intake each check both their
in one database statement. As with the other actions, no numeric policy is 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 enabled unless the operator supplies values justified by measured traffic
through `RATE_LIMIT_POLICIES_JSON`. CSRF protection, validation, exact URL through `RATE_LIMIT_POLICIES_JSON`. CSRF protection, validation, exact URL
limits, contact verification, staff authorization, and audit events apply limits, contact verification, staff authorization, and audit events apply

View File

@ -22,7 +22,11 @@ defmodule WhoNeedHelp.ContentRemoval do
Notice.submission_changeset(notice, attrs) Notice.submission_changeset(notice, attrs)
end 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) user = scope_user(scope)
attrs = normalize_keys(attrs) attrs = normalize_keys(attrs)
attrs = maybe_use_user_email(attrs, user) attrs = maybe_use_user_email(attrs, user)
@ -32,8 +36,8 @@ defmodule WhoNeedHelp.ContentRemoval do
response_due_at = response_due_at =
if regime == :take_it_down, do: DateTime.add(DateTime.utc_now(:second), 48, :hour) if regime == :take_it_down, do: DateTime.add(DateTime.utc_now(:second), 48, :hour)
with {:ok, _limit} <- with {:ok, _limits} <-
RateLimiter.check(:content_removal_notice, rate_scope(user, attrs["contact_email"])) do RateLimiter.check_many(rate_scopes(user, attrs["contact_email"], client_scope)) do
Repo.transact(fn -> Repo.transact(fn ->
with {:ok, notice} <- with {:ok, notice} <-
%Notice{ %Notice{
@ -363,6 +367,17 @@ defmodule WhoNeedHelp.ContentRemoval do
defp maybe_use_user_email(attrs, nil), do: attrs 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(%User{id: id}, _email), do: "user:#{id}"
defp rate_scope(nil, email) when is_binary(email), defp rate_scope(nil, email) when is_binary(email),

View File

@ -3,6 +3,7 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
alias WhoNeedHelp.ContentRemoval alias WhoNeedHelp.ContentRemoval
alias WhoNeedHelp.ContentRemoval.Notice alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelpWeb.ClientIp
def index(conn, _params) do def index(conn, _params) do
notices = ContentRemoval.list_for_user(conn.assigns.current_scope) notices = ContentRemoval.list_for_user(conn.assigns.current_scope)
@ -62,7 +63,12 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
end end
defp create_notice(conn, regime, params) do 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} -> {:ok, notice} ->
redirect(conn, redirect(conn,
to: ~p"/legal/content-removal/received?reference=#{notice.reference}" to: ~p"/legal/content-removal/received?reference=#{notice.reference}"

View File

@ -120,6 +120,51 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
assert_email_sent() assert_email_sent()
end 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 test "rejects malformed public form shapes without crashing", %{conn: conn} do
assert response(post(conn, ~p"/support", %{"support_request" => "invalid"}), 400) assert response(post(conn, ~p"/support", %{"support_request" => "invalid"}), 400)