Rate limit content removal by client network
This commit is contained in:
parent
f5c0d1d9b2
commit
accc0845bd
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user