IP Block List (#3761)

* Add Ecto.Network dependency

* Migration: Add ip block list table

* If Cachex errors out, mark the cache as not ready

* Add IPRule schema

* Seed IPRules

* Add Shields context module

* Implement IPRuleCache

* Start IPRuleCache

* Drop blocklisted IPs on ingestion

* Cosmetic rename

* Add settings sidebar item

* Consider IPRuleCache readiness on health checks

* Fix typo

* Implement IP blocklist live view

* Update moduledocs

* Extend contextual module tests

* Convert IPRules LiveView into LiveComponent

* Keep live flashes on the tabs view

* Update changelog

* Format

* Credo

* Remove garbage

* Update drop reason typespecs

* Update typespecs for cache keys

* Keep track of who added a rule and when

* Test if adding via LV prefills the updated_by tooltip

* Update ecto_network dependency

* s/updated_by/added_by

* s/drop_blocklist_ip/drop_shield_rule_ip

* Add docs link

* s/Updated/Added
This commit is contained in:
hq1 2024-02-12 14:55:20 +01:00 committed by GitHub
parent bc467996ab
commit 99fe03701e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1070 additions and 16 deletions

View File

@ -2,6 +2,7 @@
All notable changes to this project will be documented in this file.
### Added
- IP Block List in Site Settings
- Allow filtering by multiple custom properties
- Wildcard and member filtering on the Stats API `event:goal` property
- Allow filtering with `contains`/`matches` operator for custom properties

View File

@ -41,6 +41,21 @@ defmodule Plausible.Application do
interval: :timer.seconds(30),
warmer_fn: :refresh_updated_recently
]},
{Plausible.Shield.IPRuleCache, []},
{Plausible.Cache.Warmer,
[
child_name: Plausible.Shield.IPRuleCache.All,
cache_impl: Plausible.Shield.IPRuleCache,
interval: :timer.minutes(3) + Enum.random(1..:timer.seconds(10)),
warmer_fn: :refresh_all
]},
{Plausible.Cache.Warmer,
[
child_name: Plausible.Shield.IPRuleCache.RecentlyUpdated,
cache_impl: Plausible.Shield.IPRuleCache,
interval: :timer.seconds(35),
warmer_fn: :refresh_updated_recently
]},
{Plausible.Auth.TOTP.Vault, key: totp_vault_key()},
PlausibleWeb.Endpoint,
{Oban, Application.get_env(:plausible, Oban)},

View File

@ -53,6 +53,7 @@ defmodule Plausible.Cache do
Application.fetch_env!(:plausible, __MODULE__)[:enabled] == true
end
# credo:disable-for-this-file Credo.Check.Refactor.LongQuoteBlocks
defmacro __using__(_opts) do
quote do
require Logger
@ -60,7 +61,7 @@ defmodule Plausible.Cache do
@behaviour Plausible.Cache
@modes [:all, :updated_recently]
@spec get(String.t(), Keyword.t()) :: any() | nil
@spec get(any(), Keyword.t()) :: any() | nil
def get(key, opts \\ []) do
cache_name = Keyword.get(opts, :cache_name, name())
force? = Keyword.get(opts, :force?, false)
@ -158,6 +159,9 @@ defmodule Plausible.Cache do
0 ->
count_all() == 0
_ ->
false
end
end

View File

@ -26,6 +26,7 @@ defmodule Plausible.Ingestion.Event do
| GateKeeper.policy()
| :invalid
| :dc_ip
| :site_ip_blocklist
@type t() :: %__MODULE__{
domain: String.t() | nil,
@ -94,7 +95,8 @@ defmodule Plausible.Ingestion.Event do
defp pipeline() do
[
&put_ip_classification/1,
&drop_datacenter_ip/1,
&drop_shield_rule_ip/1,
&put_user_agent/1,
&put_basic_info/1,
&put_referrer/1,
@ -141,7 +143,7 @@ defmodule Plausible.Ingestion.Event do
struct!(event, clickhouse_event_attrs: Map.merge(event.clickhouse_event_attrs, attrs))
end
defp put_ip_classification(%__MODULE__{} = event) do
defp drop_datacenter_ip(%__MODULE__{} = event) do
case event.request.ip_classification do
"dc_ip" ->
drop(event, :dc_ip)
@ -151,6 +153,19 @@ defmodule Plausible.Ingestion.Event do
end
end
defp drop_shield_rule_ip(%__MODULE__{} = event) do
domain = event.domain
address = event.request.remote_ip
case Plausible.Shield.IPRuleCache.get({domain, address}) do
%Plausible.Shield.IPRule{action: :deny} ->
drop(event, :site_ip_blocklist)
_ ->
event
end
end
defp put_user_agent(%__MODULE__{} = event) do
case parse_user_agent(event.request) do
%UAInspector.Result{client: %UAInspector.Result.Client{name: "Headless Chrome"}} ->

View File

@ -104,7 +104,7 @@ defmodule Plausible.Ingestion.Request do
end
defp put_remote_ip(changeset, conn) do
Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIp.get(conn))
Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIP.get(conn))
end
defp parse_body(conn) do

View File

@ -0,0 +1,42 @@
defmodule Plausible.Shield.IPRule do
@moduledoc """
Schema for IP block list
"""
use Ecto.Schema
import Ecto.Changeset
@type t() :: %__MODULE__{}
@primary_key {:id, :binary_id, autogenerate: true}
schema "shield_rules_ip" do
belongs_to :site, Plausible.Site
field :inet, EctoNetwork.INET
field :action, Ecto.Enum, values: [:deny, :allow], default: :deny
field :description, :string
field :added_by, :string
# If `from_cache?` is set, the struct might be incomplete - see `Plausible.Site.Shield.Rules.IP.Cache`
field :from_cache?, :boolean, virtual: true, default: false
timestamps()
end
def changeset(rule, attrs) do
rule
|> cast(attrs, [:site_id, :inet, :description, :added_by])
|> validate_required([:site_id, :inet])
|> disallow_netmask(:inet)
|> unique_constraint(:inet,
name: :shield_rules_ip_site_id_inet_index
)
end
defp disallow_netmask(changeset, field) do
case get_field(changeset, field) do
%Postgrex.INET{netmask: netmask} when netmask != 32 and netmask != 128 ->
add_error(changeset, field, "netmask unsupported")
_ ->
changeset
end
end
end

View File

@ -0,0 +1,65 @@
defmodule Plausible.Shield.IPRuleCache do
@moduledoc """
Allows retrieving IP Rules by domain and IP
"""
alias Plausible.Shield.IPRule
import Ecto.Query
use Plausible.Cache
@cache_name :ip_blocklist_by_domain
@cached_schema_fields ~w(
id
inet
action
)a
@impl true
def name(), do: @cache_name
@impl true
def child_id(), do: :cachex_ip_blocklist
@impl true
def count_all() do
Plausible.Repo.aggregate(IPRule, :count)
end
@impl true
def base_db_query() do
from rule in IPRule,
inner_join: s in assoc(rule, :site),
select: {
s.domain,
s.domain_changed_from,
%{struct(rule, ^@cached_schema_fields) | from_cache?: true}
}
end
@impl true
def get_from_source({domain, address}) do
query =
base_db_query()
|> where([rule, site], rule.inet == ^address and site.domain == ^domain)
case Plausible.Repo.one(query) do
{_, _, rule} -> %IPRule{rule | from_cache?: false}
_any -> nil
end
end
@impl true
def unwrap_cache_keys(items) do
Enum.reduce(items, [], fn
{domain, nil, object}, acc ->
[{{domain, to_string(object.inet)}, object} | acc]
{domain, domain_changed_from, object}, acc ->
[
{{domain, to_string(object.inet)}, object},
{{domain_changed_from, to_string(object.inet)}, object} | acc
]
end)
end
end

72
lib/plausible/shields.ex Normal file
View File

@ -0,0 +1,72 @@
defmodule Plausible.Shields do
@moduledoc """
Contextual interface for shields.
"""
import Ecto.Query
alias Plausible.Repo
alias Plausible.Shield
@maximum_ip_rules 30
def maximum_ip_rules(), do: @maximum_ip_rules
@spec list_ip_rules(Plausible.Site.t() | non_neg_integer()) :: [Shield.IPRule.t()]
def list_ip_rules(site_id) when is_integer(site_id) do
Repo.all(
from r in Shield.IPRule,
where: r.site_id == ^site_id,
order_by: [desc: r.inserted_at]
)
end
def list_ip_rules(%Plausible.Site{id: id}) do
list_ip_rules(id)
end
@spec add_ip_rule(Plausible.Site.t() | non_neg_integer(), map()) ::
{:ok, Shield.IPRule.t()} | {:error, Ecto.Changeset.t()}
def add_ip_rule(site_id, params) when is_integer(site_id) do
Repo.transaction(fn ->
result =
if count_ip_rules(site_id) >= @maximum_ip_rules do
changeset =
%Shield.IPRule{}
|> Shield.IPRule.changeset(Map.put(params, "site_id", site_id))
|> Ecto.Changeset.add_error(:inet, "maximum reached")
{:error, changeset}
else
%Shield.IPRule{}
|> Shield.IPRule.changeset(Map.put(params, "site_id", site_id))
|> Repo.insert()
end
case result do
{:ok, rule} -> rule
{:error, changeset} -> Repo.rollback(changeset)
end
end)
end
def add_ip_rule(%Plausible.Site{id: id}, params) do
add_ip_rule(id, params)
end
@spec remove_ip_rule(Plausible.Site.t() | non_neg_integer(), String.t()) :: :ok
def remove_ip_rule(site_id, rule_id) when is_integer(site_id) do
Repo.delete_all(from(r in Shield.IPRule, where: r.site_id == ^site_id and r.id == ^rule_id))
:ok
end
def remove_ip_rule(%Plausible.Site{id: site_id}, rule_id) do
remove_ip_rule(site_id, rule_id)
end
@spec count_ip_rules(Plausible.Site.t() | non_neg_integer()) :: non_neg_integer()
def count_ip_rules(site_id) when is_integer(site_id) do
Repo.aggregate(from(r in Shield.IPRule, where: r.site_id == ^site_id), :count)
end
def count_ip_rules(%Plausible.Site{id: id}) do
count_ip_rules(id)
end
end

View File

@ -134,9 +134,10 @@ defmodule PlausibleWeb.Components.Generic do
end
attr :id, :any, default: nil
attr :href, :string, required: true
attr :href, :string, default: "#"
attr :new_tab, :boolean, default: false
attr :class, :string, default: ""
attr :rest, :global
slot :inner_block
def styled_link(assigns) do
@ -145,6 +146,7 @@ defmodule PlausibleWeb.Components.Generic do
new_tab={@new_tab}
href={@href}
class={"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600 " <> @class}
{@rest}
>
<%= render_slot(@inner_block) %>
</.unstyled_link>

View File

@ -60,13 +60,14 @@ defmodule PlausibleWeb.Api.ExternalController do
e -> "error: #{inspect(e)}"
end
sites_cache_health =
if postgres_health == "ok" and Plausible.Site.Cache.ready?() do
cache_health =
if postgres_health == "ok" and Plausible.Site.Cache.ready?() and
Plausible.Shield.IPRuleCache.ready?() do
"ok"
end
status =
case {postgres_health, clickhouse_health, sites_cache_health} do
case {postgres_health, clickhouse_health, cache_health} do
{"ok", "ok", "ok"} -> 200
_ -> 500
end
@ -75,7 +76,7 @@ defmodule PlausibleWeb.Api.ExternalController do
|> json(%{
postgres: postgres_health,
clickhouse: clickhouse_health,
sites_cache: sites_cache_health
sites_cache: cache_health
})
end

View File

@ -304,7 +304,7 @@ defmodule PlausibleWeb.AuthController do
@email_change_interval :timer.hours(1)
defp check_ip_rate_limit(conn) do
ip_address = PlausibleWeb.RemoteIp.get(conn)
ip_address = PlausibleWeb.RemoteIP.get(conn)
case RateLimit.check_rate("login:ip:#{ip_address}", @login_interval, @login_limit) do
{:allow, _} -> :ok

View File

@ -253,6 +253,18 @@ defmodule PlausibleWeb.SiteController do
)
end
def settings_shields(conn, _params) do
site = conn.assigns.site
conn
|> render("settings_shields.html",
site: site,
dogfood_page_path: "/:dashboard/settings/shields",
connect_live_socket: true,
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
end
def update_google_auth(conn, %{"google_auth" => attrs}) do
site = conn.assigns[:site] |> Repo.preload(:google_auth)

View File

@ -53,7 +53,7 @@ defmodule PlausibleWeb.Live.Components.Modal do
to ensure that.
* `Modal.close/2` - to close the modal from the backend; usually
done inside wrapped component's `handle_event/2`. The example
qouted above shows one way to implement this, under that assumption
quoted above shows one way to implement this, under that assumption
that the component exposes a callback, like this:
```

View File

@ -45,7 +45,7 @@ defmodule PlausibleWeb.Live.Flash do
def flash_messages(assigns) do
~H"""
<div id="liveview-flash">
<div>
<div
:if={@flash != %{} or Application.get_env(:plausible, :environment) == "dev"}
class="inset-0 z-50 fixed flex flex-col-reverse items-center sm:items-end justify-start sm:justify-end px-4 py-6 pointer-events-none sm:p-6"

View File

@ -0,0 +1,288 @@
defmodule PlausibleWeb.Live.Shields.IPRules do
@moduledoc """
LiveView allowing IP Rules management
"""
use Phoenix.LiveComponent, global_prefixes: ~w(x-)
use Phoenix.HTML
alias PlausibleWeb.Live.Components.Modal
alias Plausible.Shields
alias Plausible.Shield
import PlausibleWeb.Live.Components.Form
import PlausibleWeb.Components.Generic
def update(assigns, socket) do
socket =
socket
|> assign(
ip_rules_count: assigns.ip_rules_count,
remote_ip: assigns.remote_ip,
site: assigns.site,
current_user: assigns.current_user,
form: new_form()
)
|> assign_new(:ip_rules, fn %{site: site} ->
Shields.list_ip_rules(site)
end)
{:ok, socket}
end
def render(assigns) do
~H"""
<section class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden">
<div class="py-6 px-4 sm:p-6">
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
IP Block List
</h2>
<p class="mt-1 mb-4 text-sm leading-5 text-gray-500 dark:text-gray-200">
Reject incoming traffic from specific IP addresses
</p>
<PlausibleWeb.Components.Generic.docs_info slug="excluding" />
</header>
<div class="border-t border-gray-200 pt-4 grid">
<div
:if={@ip_rules_count < Shields.maximum_ip_rules()}
class="mt-4 sm:ml-4 sm:mt-0 justify-self-end"
>
<PlausibleWeb.Components.Generic.button
id="add-ip-rule"
x-data
x-on:click={Modal.JS.open("ip-rule-form-modal")}
>
+ Add IP Address
</PlausibleWeb.Components.Generic.button>
</div>
<PlausibleWeb.Components.Generic.notice
:if={@ip_rules_count >= Shields.maximum_ip_rules()}
class="mt-4"
title="Maximum number of addresses reached"
>
<p>
You've reached the maximum number of IP addresses you can block. Please remove one before adding another.
</p>
</PlausibleWeb.Components.Generic.notice>
</div>
<.live_component module={Modal} id="ip-rule-form-modal">
<.form
:let={f}
for={@form}
phx-submit="save-ip-rule"
phx-target={@myself}
class="max-w-md w-full mx-auto bg-white dark:bg-gray-800"
>
<h2 class="text-xl font-black dark:text-gray-100 mb-8">Add IP to Block List</h2>
<.input
autofocus
field={f[:inet]}
label="IP Address"
class="focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500 w-full p-2 mt-2"
placeholder="e.g. 192.168.127.12"
/>
<div class="mt-4">
<p
:if={not ip_rule_present?(@ip_rules, @remote_ip)}
class="text-sm text-gray-500 dark:text-gray-200 mb-4"
>
Your current IP address is: <span class="font-mono"><%= @remote_ip %></span>
<br />
<.styled_link phx-target={@myself} phx-click="prefill-own-ip-rule">
Click here
</.styled_link>
to block your own traffic, or enter a custom address.
</p>
</div>
<.input
field={f[:description]}
label="Description"
class="focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500 w-full p-2 mt-2"
placeholder="e.g. The Office"
/>
<p class="text-sm mt-2 text-gray-500 dark:text-gray-200">
Once added, we will start rejecting traffic from this IP within a few minutes.
</p>
<div class="py-4 mt-8">
<PlausibleWeb.Components.Generic.button type="submit" class="w-full">
Add IP Address
</PlausibleWeb.Components.Generic.button>
</div>
</.form>
</.live_component>
<p
:if={Enum.empty?(@ip_rules)}
class="text-sm text-gray-800 dark:text-gray-200 mt-12 mb-8 text-center"
>
No IP Rules configured for this Site.
</p>
<div
:if={not Enum.empty?(@ip_rules)}
class="mt-8 overflow-hidden border-b border-gray-200 shadow dark:border-gray-900 sm:rounded-lg"
>
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-900">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th
scope="col"
class="px-6 py-3 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-100"
>
IP Address
</th>
<th
scope="col"
class="px-6 py-3 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-100"
>
Status
</th>
<th
scope="col"
class="px-6 py-3 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-100"
>
Description
</th>
<th scope="col" class="px-6 py-3">
<span class="sr-only">Remove</span>
</th>
</tr>
</thead>
<tbody>
<%= for rule <- @ip_rules do %>
<tr class="text-gray-900 dark:text-gray-100">
<td class="px-6 py-4 text-xs font-medium">
<div class="flex items-center">
<span
id={"inet-#{rule.id}"}
class="font-mono mr-4 cursor-help border-b border-dotted border-gray-400"
title={"Added at #{rule.updated_at} by #{rule.added_by}"}
>
<%= rule.inet %>
</span>
<span
:if={to_string(rule.inet) == @remote_ip}
class="inline-flex items-center gap-x-1.5 rounded-md px-2 py-1 text-xs font-medium text-gray-700 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700"
>
<svg class="h-1.5 w-1.5 fill-green-400" viewBox="0 0 6 6" aria-hidden="true">
<circle cx="3" cy="3" r="3" />
</svg>
YOU
</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<span :if={rule.action == :deny}>
Blocked
</span>
<span :if={rule.action == :allow}>
Allowed
</span>
</td>
<td class="px-6 py-4 text-sm font-normal whitespace-nowrap truncate max-w-xs">
<span :if={rule.description} title={rule.description}>
<%= rule.description %>
</span>
<span :if={!rule.description} class="text-gray-400 dark:text-gray-600">
--
</span>
</td>
<td class="px-6 py-4 text-sm font-medium text-right">
<button
id={"remove-ip-rule-#{rule.id}"}
phx-target={@myself}
phx-click="remove-ip-rule"
phx-value-rule-id={rule.id}
class="text-sm text-red-600"
data-confirm="Are you sure you want to revoke this rule?"
>
Remove
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</section>
"""
end
def handle_event("prefill-own-ip-rule", %{}, socket) do
form =
%Plausible.Shield.IPRule{}
|> Plausible.Shield.IPRule.changeset(%{
inet: socket.assigns.remote_ip,
description: socket.assigns.current_user.name
})
|> to_form()
{:noreply, assign(socket, form: form)}
end
def handle_event("save-ip-rule", %{"ip_rule" => params}, socket) do
user = socket.assigns.current_user
case Shields.add_ip_rule(
socket.assigns.site.id,
Map.put(params, "added_by", "#{user.name} <#{user.email}>")
) do
{:ok, rule} ->
socket =
socket
|> Modal.close("ip-rule-form-modal")
|> assign(
form: new_form(),
ip_rules: [rule | socket.assigns.ip_rules],
ip_rules_count: socket.assigns.ip_rules_count + 1
)
send_flash(
:success,
"IP rule added successfully. Traffic will be rejected within a few minutes."
)
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
def handle_event("remove-ip-rule", %{"rule-id" => rule_id}, socket) do
Shields.remove_ip_rule(socket.assigns.site.id, rule_id)
send_flash(
:success,
"IP rule removed successfully. Traffic will be resumed within a few minutes."
)
{:noreply,
socket
|> assign(
ip_rules_count: socket.assigns.ip_rules_count - 1,
ip_rules: Enum.reject(socket.assigns.ip_rules, &(&1.id == rule_id))
)}
end
def send_flash(kind, message) do
send(self(), {:flash, kind, message})
end
defp new_form() do
%Shield.IPRule{}
|> Shield.IPRule.changeset(%{})
|> to_form()
end
defp ip_rule_present?(rules, ip) do
not is_nil(Enum.find(rules, &(to_string(&1.inet) == ip)))
end
end

View File

@ -0,0 +1,59 @@
defmodule PlausibleWeb.Live.Shields.Tabs do
@moduledoc """
Currently only a placeholder module. Once more shields
are implemented it will display tabs with counters,
linking to their respective live views.
"""
use PlausibleWeb, :live_view
use Phoenix.HTML
alias Plausible.Shields
alias Plausible.Sites
def mount(
_params,
%{
"remote_ip" => remote_ip,
"domain" => domain,
"current_user_id" => user_id
},
socket
) do
socket =
socket
|> assign_new(:site, fn ->
Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
end)
|> assign_new(:ip_rules_count, fn %{site: site} ->
Shields.count_ip_rules(site)
end)
|> assign_new(:current_user, fn ->
Plausible.Repo.get(Plausible.Auth.User, user_id)
end)
|> assign_new(:remote_ip, fn -> remote_ip end)
|> assign(:current_tab, :ip_rules)
{:ok, socket}
end
def render(assigns) do
~H"""
<div>
<.flash_messages flash={@flash} />
<.live_component
module={PlausibleWeb.Live.Shields.IPRules}
current_user={@current_user}
ip_rules_count={@ip_rules_count}
site={@site}
remote_ip={@remote_ip}
id="ip-rules-#{@current_user.id}"
/>
</div>
"""
end
def handle_info({:flash, kind, message}, socket) do
socket = put_live_flash(socket, kind, message)
{:noreply, socket}
end
end

View File

@ -1,4 +1,8 @@
defmodule PlausibleWeb.RemoteIp do
defmodule PlausibleWeb.RemoteIP do
@moduledoc """
Implements the strategy of retrieving client's remote IP
"""
def get(conn) do
x_plausible_ip = List.first(Plug.Conn.get_req_header(conn, "x-plausible-ip"))
cf_connecting_ip = List.first(Plug.Conn.get_req_header(conn, "cf-connecting-ip"))
@ -24,7 +28,7 @@ defmodule PlausibleWeb.RemoteIp do
|> Map.get("for")
# IPv6 addresses are enclosed in quote marks and square brackets: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
|> String.trim("\"")
|> clean_ip
|> clean_ip()
true ->
to_string(:inet_parse.ntoa(conn.remote_ip))
@ -50,6 +54,6 @@ defmodule PlausibleWeb.RemoteIp do
String.split(header, ",")
|> Enum.map(&String.trim/1)
|> List.first()
|> clean_ip
|> clean_ip()
end
end

View File

@ -354,6 +354,7 @@ defmodule PlausibleWeb.Router do
get "/:website/settings/email-reports", SiteController, :settings_email_reports
get "/:website/settings/danger-zone", SiteController, :settings_danger_zone
get "/:website/settings/integrations", SiteController, :settings_integrations
get "/:website/settings/shields", SiteController, :settings_shields
put "/:website/settings/features/visibility/:setting",
SiteController,

View File

@ -0,0 +1,7 @@
<%= live_render(@conn, PlausibleWeb.Live.Shields.Tabs,
session: %{
"site_id" => @site.id,
"domain" => @site.domain,
"remote_ip" => PlausibleWeb.RemoteIP.get(@conn)
}
) %>

View File

@ -51,6 +51,7 @@ defmodule PlausibleWeb.LayoutView do
end,
[key: "Custom Properties", value: "properties", icon: :document_text],
[key: "Integrations", value: "integrations", icon: :arrow_path_rounded_square],
[key: "Shields", value: "shields", icon: :shield_exclamation],
[key: "Email Reports", value: "email-reports", icon: :envelope],
if conn.assigns[:current_user_role] == :owner do
[key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle]

View File

@ -134,7 +134,8 @@ defmodule Plausible.MixProject do
{:scrivener_ecto, "~> 2.0"},
{:esbuild, "~> 0.7", runtime: Mix.env() in [:dev, :small_dev]},
{:tailwind, "~> 0.2.0", runtime: Mix.env() in [:dev, :small_dev]},
{:ex_json_logger, "~> 1.4.0"}
{:ex_json_logger, "~> 1.4.0"},
{:ecto_network, "~> 1.5.0"}
]
end

View File

@ -33,6 +33,7 @@
"earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
"ecto_ch": {:hex, :ecto_ch, "0.3.2", "b6e7d0a6ad412662d7727ba1b5128a1a30a0835e1b4168db79e5b2551a27ba50", [:mix], [{:ch, "~> 0.2.0", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "a195d0f09d1177d9b11f00ef608895db1e02711d0a18730e98fbb50055758e6d"},
"ecto_network": {:hex, :ecto_network, "1.5.0", "a930c910975e7a91237b858ebf0f4ad7b2aae32fa846275aa203cb858459ec73", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "4d614434ae3e6d373a2f693d56aafaa3f3349714668ffd6d24e760caf578aa2f"},
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
"elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"},
"envy": {:hex, :envy, "1.1.1", "0bc9bd654dec24fcdf203f7c5aa1b8f30620f12cfb28c589d5e9c38fe1b07475", [:mix], [], "hexpm", "7061eb1a47415fd757145d8dec10dc0b1e48344960265cb108f194c4252c3a89"},

View File

@ -49,6 +49,8 @@ site =
]
)
Plausible.Factory.insert_list(29, :ip_rule, site: site)
Plausible.Factory.insert(:google_auth,
user: user,
site: site,

View File

@ -52,6 +52,10 @@ defmodule Plausible.CacheTest do
assert log =~ "Error retrieving key from 'NonExistingCache': :no_cache"
end
test "cache is not ready when it doesn't exist", %{test: test} do
refute ExampleCache.ready?(test)
end
end
describe "merging cache items" do

View File

@ -117,6 +117,26 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :dc_ip
end
test "event pipeline drops a request when ip is on blocklist" do
site = insert(:site)
payload = %{
name: "pageview",
url: "http://dummy.site",
domain: site.domain
}
conn = build_conn(:post, "/api/events", payload)
conn = %{conn | remote_ip: {127, 7, 7, 7}}
{:ok, _} = Plausible.Shields.add_ip_rule(site, %{"inet" => "127.7.7.7"})
assert {:ok, request} = Request.build(conn)
assert {:ok, %{buffered: [], dropped: [dropped]}} = Event.build_and_buffer(request)
assert dropped.drop_reason == :site_ip_blocklist
end
test "event pipeline drops events for site with accept_trafic_until in the past" do
yesterday = Date.add(Date.utc_today(), -1)

View File

@ -0,0 +1,80 @@
defmodule Plausible.Shield.IPRuleCacheTest do
use Plausible.DataCase, async: true
alias Plausible.Shield.IPRule
alias Plausible.Shield.IPRuleCache
alias Plausible.Shields
describe "public cache interface" do
test "cache caches IP rules", %{test: test} do
{:ok, _} =
Supervisor.start_link([{IPRuleCache, [cache_name: test, child_id: :ip_rules_cache_id]}],
strategy: :one_for_one,
name: :"cache_supervisor_#{test}"
)
site = insert(:site, domain: "site1.example.com")
{:ok, %{id: rid1}} = Shields.add_ip_rule(site, %{"inet" => "1.1.1.1"})
{:ok, %{id: rid2}} = Shields.add_ip_rule(site, %{"inet" => "2.2.2.2"})
:ok = IPRuleCache.refresh_all(cache_name: test)
:ok = Shields.remove_ip_rule(site, rid1)
assert IPRuleCache.size(test) == 2
assert %IPRule{from_cache?: true, id: ^rid1} =
IPRuleCache.get({site.domain, "1.1.1.1"}, force?: true, cache_name: test)
assert %IPRule{from_cache?: true, id: ^rid2} =
IPRuleCache.get({site.domain, "2.2.2.2"}, force?: true, cache_name: test)
refute IPRuleCache.get({site.domain, "3.3.3.3"}, cache_name: test, force?: true)
end
test "cache allows lookups for sites with changed domain", %{test: test} do
{:ok, _} = start_test_cache(test)
site = insert(:site, domain: "new.example.com", domain_changed_from: "old.example.com")
{:ok, _} = Shields.add_ip_rule(site, %{"inet" => "1.1.1.1"})
:ok = IPRuleCache.refresh_all(cache_name: test)
assert IPRuleCache.get({"old.example.com", "1.1.1.1"}, force?: true, cache_name: test)
assert IPRuleCache.get({"new.example.com", "1.1.1.1"}, force?: true, cache_name: test)
end
test "refreshes only recently added rules", %{test: test} do
{:ok, _} = start_test_cache(test)
domain = "site1.example.com"
site = insert(:site, domain: domain)
cache_opts = [cache_name: test, force?: true]
yesterday = DateTime.utc_now() |> DateTime.add(-1 * 60 * 60 * 24)
insert(:ip_rule,
site: site,
inserted_at: yesterday,
updated_at: yesterday,
inet: "1.1.1.1"
)
insert(:ip_rule, site: site, inet: "2.2.2.2")
assert IPRuleCache.get({domain, "1.1.1.1"}, cache_opts) == nil
assert IPRuleCache.get({domain, "2.2.2.2"}, cache_opts) == nil
assert :ok = IPRuleCache.refresh_updated_recently(cache_opts)
refute IPRuleCache.get({domain, "1.1.1.1"}, cache_opts)
assert %IPRule{} = IPRuleCache.get({domain, "2.2.2.2"}, cache_opts)
end
end
defp start_test_cache(cache_name) do
%{start: {m, f, a}} = IPRuleCache.child_spec(cache_name: cache_name)
apply(m, f, a)
end
end

View File

@ -0,0 +1,141 @@
defmodule Plausible.ShieldsTest do
use Plausible.DataCase
import Plausible.Shields
setup do
site = insert(:site)
{:ok, %{site: site}}
end
describe "add_ip_rule/2" do
test "no input", %{site: site} do
assert {:error, changeset} = add_ip_rule(site, %{})
assert changeset.errors == [inet: {"can't be blank", [validation: :required]}]
refute changeset.valid?
end
test "unsupported netmask", %{site: site} do
assert {:error, changeset} = add_ip_rule(site, %{"inet" => "127.0.0.0/24"})
assert changeset.errors == [inet: {"netmask unsupported", []}]
refute changeset.valid?
assert {:ok, _} = add_ip_rule(site, %{"inet" => "127.0.0.0/32"})
end
test "incorrect ip", %{site: site} do
assert {:error, changeset} = add_ip_rule(site, %{"inet" => "999.999.999.999"})
assert changeset.errors == [
inet: {"is invalid", [{:type, EctoNetwork.INET}, {:validation, :cast}]}
]
refute changeset.valid?
end
test "non-strict IPs", %{site: site} do
assert {:error, _} = add_ip_rule(site, %{"inet" => "111"})
end
test "double insert", %{site: site} do
assert {:ok, _} = add_ip_rule(site, %{"inet" => "0.0.0.111"})
assert {:error, changeset} = add_ip_rule(site, %{"inet" => "0.0.0.111"})
refute changeset.valid?
assert changeset.errors == [
inet:
{"has already been taken",
[
{:constraint, :unique},
{:constraint_name, "shield_rules_ip_site_id_inet_index"}
]}
]
end
test "ipv6", %{site: site} do
assert {:ok, rule} =
add_ip_rule(site, %{"inet" => "2001:0000:130F:0000:0000:09C0:876A:130B"})
assert ^rule = Repo.get(Plausible.Shield.IPRule, rule.id)
end
test "ipv4", %{site: site} do
assert {:ok, rule} =
add_ip_rule(site, %{"inet" => "1.1.1.1"})
assert ^rule = Repo.get(Plausible.Shield.IPRule, rule.id)
end
test "over limit", %{site: site} do
for i <- 1..maximum_ip_rules() do
assert {:ok, _} =
add_ip_rule(site, %{"inet" => "1.1.1.#{i}"})
end
assert count_ip_rules(site) == maximum_ip_rules()
assert {:error, changeset} =
add_ip_rule(site, %{"inet" => "1.1.1.31"})
refute changeset.valid?
assert changeset.errors == [inet: {"maximum reached", []}]
end
test "with added_by", %{site: site} do
assert {:ok, rule} = add_ip_rule(site, %{"inet" => "1.1.1.1", "added_by" => "test"})
assert rule.added_by == "test"
end
end
describe "remove_ip_rule/2" do
test "is idempontent", %{site: site} do
{:ok, rule} = add_ip_rule(site, %{"inet" => "127.0.0.1"})
assert remove_ip_rule(site, rule.id) == :ok
refute Repo.get(Plausible.Shield.IPRule, rule.id)
assert remove_ip_rule(site, rule.id) == :ok
end
end
describe "list_ip_rules/1" do
test "empty", %{site: site} do
assert(list_ip_rules(site) == [])
end
@tag :slow
test "many", %{site: site} do
{:ok, r1} = add_ip_rule(site, %{"inet" => "127.0.0.1"})
:timer.sleep(1000)
{:ok, r2} = add_ip_rule(site, %{"inet" => "127.0.0.2"})
assert [^r2, ^r1] = list_ip_rules(site)
end
end
describe "count_ip_rules/1" do
test "counts", %{site: site} do
assert count_ip_rules(site) == 0
{:ok, _} = add_ip_rule(site, %{"inet" => "127.0.0.1"})
assert count_ip_rules(site) == 1
{:ok, _} = add_ip_rule(site, %{"inet" => "127.0.0.2"})
assert count_ip_rules(site) == 2
end
end
describe "IP Rules" do
test "end to end", %{site: site} do
site2 = insert(:site)
assert count_ip_rules(site.id) == 0
assert list_ip_rules(site.id) == []
assert {:ok, rule} =
add_ip_rule(site.id, %{"inet" => "127.0.0.1", "description" => "Localhost"})
add_ip_rule(site2, %{"inet" => "127.0.0.1", "description" => "Localhost"})
assert count_ip_rules(site) == 1
assert [^rule] = list_ip_rules(site)
assert rule.inet == %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}
assert rule.description == "Localhost"
assert rule.action == :deny
refute rule.from_cache?
end
end
end

View File

@ -0,0 +1,208 @@
defmodule PlausibleWeb.Live.ShieldsTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
alias Plausible.Shields
setup [:create_user, :create_site, :log_in]
describe "IP Rules - static" do
test "renders ip rules page with empty list", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/shields")
resp = html_response(conn, 200)
assert resp =~ "No IP Rules configured for this Site"
assert resp =~ "IP Block List"
end
test "lists ip rules with remove actions", %{conn: conn, site: site} do
{:ok, r1} =
Shields.add_ip_rule(site, %{"inet" => "127.0.0.1", "description" => "Alice"})
{:ok, r2} =
Shields.add_ip_rule(site, %{"inet" => "127.0.0.2", "description" => "Bob"})
conn = get(conn, "/#{site.domain}/settings/shields")
resp = html_response(conn, 200)
assert resp =~ "127.0.0.1"
assert resp =~ "Alice"
assert resp =~ "127.0.0.2"
assert resp =~ "Bob"
assert element_exists?(
resp,
~s/button[phx-click="remove-ip-rule"][phx-value-rule-id="#{r1.id}"]#remove-ip-rule-#{r1.id}/
)
assert element_exists?(
resp,
~s/button[phx-click="remove-ip-rule"][phx-value-rule-id="#{r2.id}"]#remove-ip-rule-#{r2.id}/
)
end
test "add rule button is rendered", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/shields")
resp = html_response(conn, 200)
assert element_exists?(resp, ~s/button#add-ip-rule[x-data]/)
attr = text_of_attr(resp, ~s/button#add-ip-rule/, "x-on:click")
assert attr =~ "open-modal"
assert attr =~ "ip-rule-form-modal"
end
test "add rule button is not rendered when maximum reached", %{conn: conn, site: site} do
for i <- 1..Shields.maximum_ip_rules() do
assert {:ok, _} =
Shields.add_ip_rule(site, %{"inet" => "1.1.1.#{i}"})
end
conn = get(conn, "/#{site.domain}/settings/shields")
resp = html_response(conn, 200)
refute element_exists?(resp, ~s/button#add-ip-rule[x-data]/)
assert resp =~ "Maximum number of addresses reached"
end
end
describe "IP Rules - LiveView" do
test "modal contains form", %{site: site, conn: conn} do
lv = get_liveview(conn, site)
html = render(lv)
assert element_exists?(
html,
~s/form[phx-submit="save-ip-rule"] input[name="ip_rule\[inet\]"]/
)
assert element_exists?(
html,
~s/form[phx-submit="save-ip-rule"] input[name="ip_rule\[description\]"]/
)
assert submit_button(html, ~s/form[phx-submit="save-ip-rule"]/)
end
test "form modal contains link to add own IP", %{site: site, conn: conn} do
ip = PlausibleWeb.RemoteIP.get(conn)
lv = get_liveview(conn, site)
html = render(lv)
assert text(html) =~ "Your current IP address is: #{ip}"
assert element_exists?(html, ~s/a[phx-click="prefill-own-ip-rule"]/)
end
test "form modal does not contains link to add own IP if already added", %{
site: site,
conn: conn
} do
ip = PlausibleWeb.RemoteIP.get(conn)
{:ok, _} =
Shields.add_ip_rule(site, %{
"inet" => ip,
"description" => "Alice"
})
lv = get_liveview(conn, site)
html = render(lv)
refute text(html) =~ "Your current IP address is: #{ip}"
refute element_exists?(html, ~s/a[phx-click="prefill-own-ip-rule"]/)
end
test "clicking the link prefills own IP", %{conn: conn, site: site, user: user} do
lv = get_liveview(conn, site)
lv |> element(~s/a[phx-click="prefill-own-ip-rule"]/) |> render_click()
html = render(lv)
assert text_of_attr(html, "input[name=\"ip_rule[inet]\"]", "value") ==
PlausibleWeb.RemoteIP.get(conn)
assert text_of_attr(html, "input[name=\"ip_rule[description]\"]", "value") == user.name
end
test "submitting own IP saves it", %{conn: conn, site: site, user: user} do
ip = PlausibleWeb.RemoteIP.get(conn)
assert [] = Shields.list_ip_rules(site)
lv = get_liveview(conn, site)
lv |> element(~s/a[phx-click="prefill-own-ip-rule"]/) |> render_click()
lv |> element(~s/form/) |> render_submit()
html = render(lv)
assert html =~ ip
assert html =~ user.name
assert [%{id: id}] = Shields.list_ip_rules(site)
tooltip = text_of_attr(html, "#inet-#{id}", "title")
assert tooltip =~ "Added at #{Date.utc_today()}"
assert tooltip =~ "by #{user.name} <#{user.email}>"
assert [_] = Shields.list_ip_rules(site)
end
test "submitting a valid IP saves it", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
lv
|> element("form")
|> render_submit(%{
"ip_rule[inet]" => "1.1.1.1",
"ip_rule[description]" => "A happy song"
})
html = render(lv)
assert html =~ "1.1.1.1"
assert html =~ "A happy song"
assert [%{inet: ip, description: "A happy song"}] = Shields.list_ip_rules(site)
assert to_string(ip) == "1.1.1.1"
end
test "submitting invalid IP renders error", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
lv
|> element("form")
|> render_submit(%{
"ip_rule[inet]" => "WRONG"
})
html = render(lv)
assert html =~ "is invalid"
end
test "clicking Remove deletes the rule", %{conn: conn, site: site} do
{:ok, _} =
Shields.add_ip_rule(site, %{"inet" => "2.2.2.2", "description" => "Alice"})
lv = get_liveview(conn, site)
html = render(lv)
assert html =~ "2.2.2.2"
lv |> element(~s/button[phx-click="remove-ip-rule"]/) |> render_click()
html = render(lv)
refute html =~ "2.2.2.2"
assert Shields.count_ip_rules(site) == 0
end
defp get_liveview(conn, site) do
conn = assign(conn, :live_module, PlausibleWeb.Live.Shields)
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields")
lv
end
end
end

View File

@ -278,6 +278,14 @@ defmodule Plausible.Factory do
}
end
def ip_rule_factory do
%Plausible.Shield.IPRule{
inet: Plausible.TestUtils.random_ip(),
description: "Test IP Rule",
added_by: "Mr Seed <user@plausible.test>"
}
end
defp hash_key() do
Keyword.fetch!(
Application.get_env(:plausible, PlausibleWeb.Endpoint),