From 962f3d2fecadf086df855c5233f930f292ceb57a Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 16 Jul 2026 08:42:24 +0200 Subject: [PATCH 01/15] Implement private/reserved IP lookup interface --- lib/ip/tools.ex | 56 +++++ lib/ip/tools/registry.ex | 149 ++++++++++++++ lib/mix/tasks/download_ip_registries.ex | 39 ++++ mix.exs | 1 + priv/ip/iana-ipv4-special-registry.csv | 27 +++ priv/ip/iana-ipv6-special-registry.csv | 28 +++ test/plausible/ip/tools_test.exs | 262 ++++++++++++++++++++++++ 7 files changed, 562 insertions(+) create mode 100644 lib/ip/tools.ex create mode 100644 lib/ip/tools/registry.ex create mode 100644 lib/mix/tasks/download_ip_registries.ex create mode 100644 priv/ip/iana-ipv4-special-registry.csv create mode 100644 priv/ip/iana-ipv6-special-registry.csv create mode 100644 test/plausible/ip/tools_test.exs diff --git a/lib/ip/tools.ex b/lib/ip/tools.ex new file mode 100644 index 000000000000..e5a4e8525436 --- /dev/null +++ b/lib/ip/tools.ex @@ -0,0 +1,56 @@ +defmodule Plausible.IP.Tools do + @moduledoc """ + Recognizes reserved and private IPv4/IPv6 addresses. + See: `Plausible.IP.Tools.Registry` + """ + + @external_resource Plausible.IP.Tools.Registry.ipv4_registry_path() + @external_resource Plausible.IP.Tools.Registry.ipv6_registry_path() + + @clauses Plausible.IP.Tools.Registry.entries() + + @doc """ + Returns the ranges used in `reserved?/1`, for testing purposes. + """ + @spec ranges() :: [%{cidr: String.t(), name: String.t(), reserved: boolean()}] + def ranges do + Enum.map(@clauses, &Map.take(&1, [:cidr, :name, :reserved])) + end + + @doc """ + Determines whether IP falls within a reserved or private address range. + Accepts already parsed `:inet` address tuples. + + IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) are unwrapped and delegate to + the IPv4 rules for the embedded address, rather than being uniformly + treated as reserved. + """ + @spec reserved?(:inet.ip_address()) :: boolean() + for %{pattern: pattern, guard: guard, reserved: reserved} <- @clauses do + def reserved?(unquote(pattern)) when unquote(guard), do: unquote(reserved) + end + + def reserved?({0, 0, 0, 0, 0, 0xFFFF, hi, lo}) do + reserved?({div(hi, 256), rem(hi, 256), div(lo, 256), rem(lo, 256)}) + end + + def reserved?(ip) when is_tuple(ip) and tuple_size(ip) == 4, do: false + def reserved?(ip) when is_tuple(ip) and tuple_size(ip) == 8, do: false + + @doc """ + Determines if IP is allowed, i.e. valid and not reserved/private. + """ + @spec allowed?(:inet.ip_address() | String.t()) :: boolean() + def allowed?(ip) when is_binary(ip) do + case :inet.parse_address(String.to_charlist(ip)) do + {:ok, address} -> allowed?(address) + {:error, _reason} -> false + end + end + + def allowed?(ip) when is_tuple(ip) and tuple_size(ip) in [4, 8] do + not reserved?(ip) + end + + def allowed?(_ip), do: false +end diff --git a/lib/ip/tools/registry.ex b/lib/ip/tools/registry.ex new file mode 100644 index 000000000000..1e8387b40ecf --- /dev/null +++ b/lib/ip/tools/registry.ex @@ -0,0 +1,149 @@ +defmodule Plausible.IP.Tools.Registry do + @moduledoc """ + Compile-time-only helpers for `Plausible.IP.Tools`. + + Parses IANA RFC4180 CSV plus supplemental multicast ranges + into a list of function clause meta data. + """ + + import Bitwise + + def ipv4_registry_path, + do: Application.app_dir(:plausible, "priv/ip/iana-ipv4-special-registry.csv") + + def ipv6_registry_path, + do: Application.app_dir(:plausible, "priv/ip/iana-ipv6-special-registry.csv") + + def entries() do + ipv4 = load(ipv4_registry_path()) + + ipv6 = + ipv6_registry_path() + |> load() + # IPv4-mapped IPv6 is excluded here: it isn't a range + # that's uniformly reserved or public, it's a container for an entire + # embedded IPv4 address. Plausible.IP.Tools handles it by unwrapping + # the embedded address and delegating back to the IPv4 clauses instead. + |> Enum.reject(&(&1.cidr == "::ffff:0:0/96")) + + (ipv4 ++ ipv6 ++ multicast()) + |> Enum.sort_by(&(-&1.prefix_len)) + |> Enum.map(&to_clause/1) + end + + defp multicast do + [ + entry_from_cidr("224.0.0.0/4", "Multicast", true), + entry_from_cidr("ff00::/8", "Multicast", true) + ] + end + + defp rows_to_entries(rows), do: Enum.flat_map(rows, &row_to_entry/1) + + # Address Block,Name,RFC,Allocation Date,Termination Date,Source, + # Destination,Forwardable,Globally Reachable,Reserved-by-Protocol + defp row_to_entry(row) do + address_block = Enum.at(row, 0) + name = Enum.at(row, 1) + termination_date = row |> Enum.at(4) |> strip_footnotes() + globally_reachable = row |> Enum.at(8) |> strip_footnotes() + + reserved? = termination_date == "N/A" and globally_reachable == "False" + + address_block + |> strip_footnotes() + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.map(&entry_from_cidr(&1, name, reserved?)) + end + + defp strip_footnotes(nil), do: nil + + defp strip_footnotes(field) do + field + |> String.replace(~r/\s*\[\d+\]/, "") + |> String.trim() + end + + defp entry_from_cidr(cidr, name, reserved?) do + [addr_str, prefix_str] = String.split(cidr, "/") + {:ok, address} = :inet.parse_address(String.to_charlist(addr_str)) + + %{ + cidr: cidr, + name: name, + words: Tuple.to_list(address), + prefix_len: String.to_integer(prefix_str), + reserved: reserved? + } + end + + defp to_clause(%{ + words: words, + prefix_len: prefix_len, + reserved: reserved, + cidr: cidr, + name: name + }) do + word_bits = if length(words) == 4, do: 8, else: 16 + indexed_words = Enum.with_index(words) + + pattern_words = Enum.map(indexed_words, &pattern_word(&1, word_bits, prefix_len)) + + guard = + indexed_words + |> Enum.map(&guard_for_word(&1, word_bits, prefix_len)) + |> Enum.reject(&is_nil/1) + |> combine_guards() + + %{pattern: {:{}, [], pattern_words}, guard: guard, reserved: reserved, cidr: cidr, name: name} + end + + defp pattern_word({word, index}, word_bits, prefix_len) do + bits_before = index * word_bits + bits_after = bits_before + word_bits + + cond do + bits_after <= prefix_len -> word + bits_before >= prefix_len -> Macro.var(:"_w#{index}", nil) + true -> Macro.var(:"w#{index}", nil) + end + end + + defp guard_for_word({word, index}, word_bits, prefix_len) do + bits_before = index * word_bits + bits_after = bits_before + word_bits + + if bits_before < prefix_len and bits_after > prefix_len do + var = Macro.var(:"w#{index}", nil) + bits_in_word = prefix_len - bits_before + free_bits = word_bits - bits_in_word + full_mask = (1 <<< word_bits) - 1 + mask = full_mask - ((1 <<< free_bits) - 1) + masked_value = word &&& mask + + quote do + band(unquote(var), unquote(mask)) === unquote(masked_value) + end + end + end + + defp combine_guards([]), do: true + defp combine_guards([guard]), do: guard + + defp combine_guards([guard | rest]) do + Enum.reduce(rest, guard, fn g, acc -> + quote do + unquote(acc) and unquote(g) + end + end) + end + + defp load(path) do + path + |> File.read!() + |> NimbleCSV.RFC4180.parse_string() + |> rows_to_entries() + end +end diff --git a/lib/mix/tasks/download_ip_registries.ex b/lib/mix/tasks/download_ip_registries.ex new file mode 100644 index 000000000000..96cd7bd98125 --- /dev/null +++ b/lib/mix/tasks/download_ip_registries.ex @@ -0,0 +1,39 @@ +defmodule Mix.Tasks.DownloadIpRegistries do + @moduledoc """ + Refreshes the IANA special-purpose address registries used by + `Plausible.IP.Tools` at compile time to recognize reserved/private IPs. + """ + + use Mix.Task + require Logger + + alias Plausible.IP + + # coveralls-ignore-start + + @sources %{ + IP.Tools.Registry.ipv4_registry_path() => + "https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry-1.csv", + IP.Tools.Registry.ipv6_registry_path() => + "https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry-1.csv" + } + + def run(_) do + Application.ensure_all_started(:req) + + for {path, url} <- @sources do + path |> Path.dirname() |> File.mkdir_p!() + + Logger.notice("Downloading #{url}") + + case Req.get(url) do + {:ok, %{status: 200, body: body}} -> + File.write!(path, body) + Logger.notice("Saved #{path}") + + other -> + Logger.error("Unable to download #{url}. Response: #{inspect(other)}") + end + end + end +end diff --git a/mix.exs b/mix.exs index 052f80b29441..63aa67da359e 100644 --- a/mix.exs +++ b/mix.exs @@ -103,6 +103,7 @@ defmodule Plausible.MixProject do {:location, git: "https://github.com/plausible/location.git"}, {:mox, "~> 1.0", only: [:test, :ce_test, :e2e_test]}, {:nanoid, "~> 2.1.0"}, + {:nimble_csv, "~> 1.3"}, {:nimble_totp, "~> 1.0"}, {:oban, "~> 2.20.1"}, {:observer_cli, "~> 1.7"}, diff --git a/priv/ip/iana-ipv4-special-registry.csv b/priv/ip/iana-ipv4-special-registry.csv new file mode 100644 index 000000000000..c5996a45a72c --- /dev/null +++ b/priv/ip/iana-ipv4-special-registry.csv @@ -0,0 +1,27 @@ +Address Block,Name,RFC,Allocation Date,Termination Date,Source,Destination,Forwardable,Globally Reachable,Reserved-by-Protocol +0.0.0.0/8,"""This network""","[RFC791], Section 3.2",1981-09,N/A,True,False,False,False,True +0.0.0.0/32,"""This host on this network""","[RFC1122], Section 3.2.1.3",1981-09,N/A,True,False,False,False,True +10.0.0.0/8,Private-Use,[RFC1918],1996-02,N/A,True,True,True,False,False +100.64.0.0/10,Shared Address Space,[RFC6598],2012-04,N/A,True,True,True,False,False +127.0.0.0/8,Loopback,"[RFC1122], Section 3.2.1.3",1981-09,N/A,False [1],False [1],False [1],False [1],True +169.254.0.0/16,Link Local,[RFC3927],2005-05,N/A,True,True,False,False,True +172.16.0.0/12,Private-Use,[RFC1918],1996-02,N/A,True,True,True,False,False +192.0.0.0/24 [2],IETF Protocol Assignments,"[RFC6890], Section 2.1",2010-01,N/A,False,False,False,False,False +192.0.0.0/29,IPv4 Service Continuity Prefix,[RFC7335],2011-06,N/A,True,True,True,False,False +192.0.0.8/32,IPv4 dummy address,[RFC7600],2015-03,N/A,True,False,False,False,False +192.0.0.9/32,Port Control Protocol Anycast,[RFC7723],2015-10,N/A,True,True,True,True,False +192.0.0.10/32,Traversal Using Relays around NAT Anycast,[RFC8155],2017-02,N/A,True,True,True,True,False +"192.0.0.170/32, 192.0.0.171/32",NAT64/DNS64 Discovery,"[RFC8880][RFC7050], Section 2.2",2013-02,N/A,False,False,False,False,True +192.0.2.0/24,Documentation (TEST-NET-1),[RFC5737],2010-01,N/A,False,False,False,False,False +192.31.196.0/24,AS112-v4,[RFC7535],2014-12,N/A,True,True,True,True,False +192.52.193.0/24,AMT,[RFC7450],2014-12,N/A,True,True,True,True,False +192.88.99.0/24,Deprecated (6to4 Relay Anycast),[RFC7526],2001-06,2015-03,,,,, +192.88.99.2/32,6a44-relay anycast address,[RFC6751],2012-10,N/A,True,True,True,False,False +192.168.0.0/16,Private-Use,[RFC1918],1996-02,N/A,True,True,True,False,False +192.175.48.0/24,Direct Delegation AS112 Service,[RFC7534],1996-01,N/A,True,True,True,True,False +198.18.0.0/15,Benchmarking,[RFC2544],1999-03,N/A,True,True,True,False,False +198.51.100.0/24,Documentation (TEST-NET-2),[RFC5737],2010-01,N/A,False,False,False,False,False +203.0.113.0/24,Documentation (TEST-NET-3),[RFC5737],2010-01,N/A,False,False,False,False,False +240.0.0.0/4,Reserved,"[RFC1112], Section 4",1989-08,N/A,False,False,False,False,True +255.255.255.255/32,Limited Broadcast,"[RFC8190] + [RFC919], Section 7",1984-10,N/A,False,True,False,False,True diff --git a/priv/ip/iana-ipv6-special-registry.csv b/priv/ip/iana-ipv6-special-registry.csv new file mode 100644 index 000000000000..93ead430cef6 --- /dev/null +++ b/priv/ip/iana-ipv6-special-registry.csv @@ -0,0 +1,28 @@ +Address Block,Name,RFC,Allocation Date,Termination Date,Source,Destination,Forwardable,Globally Reachable,Reserved-by-Protocol +::1/128,Loopback Address,[RFC4291],2006-02,N/A,False,False,False,False,True +::/128,Unspecified Address,[RFC4291],2006-02,N/A,True,False,False,False,True +::ffff:0:0/96,IPv4-mapped Address,[RFC4291],2006-02,N/A,False,False,False,False,True +64:ff9b::/96,IPv4-IPv6 Translat.,[RFC6052],2010-10,N/A,True,True,True,True,False +64:ff9b:1::/48,IPv4-IPv6 Translat.,[RFC8215],2017-06,N/A,True,True,True,False,False +100::/64,Discard-Only Address Block,[RFC6666],2012-06,N/A,True,True,True,False,False +100:0:0:1::/64,Dummy IPv6 Prefix,[RFC9780],2025-04,N/A,True,False,False,False,False +2001::/23,IETF Protocol Assignments,[RFC2928],2000-09,N/A,False [1],False [1],False [1],False [1],False +2001::/32,TEREDO,"[RFC4380] + [RFC8190]",2006-01,N/A,True,True,True,N/A [2],False +2001:1::1/128,Port Control Protocol Anycast,[RFC7723],2015-10,N/A,True,True,True,True,False +2001:1::2/128,Traversal Using Relays around NAT Anycast,[RFC8155],2017-02,N/A,True,True,True,True,False +2001:1::3/128,DNS-SD Service Registration Protocol Anycast,[RFC9665],2024-04,N/A,True,True,True,True,False +2001:2::/48,Benchmarking,[RFC5180][RFC Errata 1752],2008-04,N/A,True,True,True,False,False +2001:3::/32,AMT,[RFC7450],2014-12,N/A,True,True,True,True,False +2001:4:112::/48,AS112-v6,[RFC7535],2014-12,N/A,True,True,True,True,False +2001:10::/28,Deprecated (previously ORCHID),[RFC4843],2007-03,2014-03,,,,, +2001:20::/28,ORCHIDv2,[RFC7343],2014-07,N/A,True,True,True,True,False +2001:30::/28,Drone Remote ID Protocol Entity Tags (DETs) Prefix,[RFC9374],2022-12,N/A,True,True,True,True,False +2001:db8::/32,Documentation,[RFC3849],2004-07,N/A,False,False,False,False,False +2002::/16 [3],6to4,[RFC3056],2001-02,N/A,True,True,True,N/A [3],False +2620:4f:8000::/48,Direct Delegation AS112 Service,[RFC7534],2011-05,N/A,True,True,True,True,False +3fff::/20,Documentation,[RFC9637],2024-07,N/A,False,False,False,False,False +5f00::/16,Segment Routing (SRv6) SIDs,[RFC9602],2024-04,N/A,True,True,True,False,False +fc00::/7,Unique-Local,"[RFC4193] + [RFC8190]",2005-10,N/A,True,True,True,False [4],False +fe80::/10,Link-Local Unicast,[RFC4291],2006-02,N/A,True,True,False,False,True diff --git a/test/plausible/ip/tools_test.exs b/test/plausible/ip/tools_test.exs new file mode 100644 index 000000000000..a27ac3e8a8b8 --- /dev/null +++ b/test/plausible/ip/tools_test.exs @@ -0,0 +1,262 @@ +defmodule Plausible.IP.ToolsTest do + use ExUnit.Case, async: true + + alias Plausible.IP + + defp ip!(str) do + {:ok, ip} = :inet.parse_address(String.to_charlist(str)) + ip + end + + describe "reserved?/1 - IPv4" do + test "RFC 1918 private-use ranges" do + assert IP.Tools.reserved?(ip!("10.0.0.0")) + assert IP.Tools.reserved?(ip!("10.255.255.255")) + assert IP.Tools.reserved?(ip!("172.16.0.0")) + assert IP.Tools.reserved?(ip!("172.31.255.255")) + assert IP.Tools.reserved?(ip!("192.168.0.0")) + assert IP.Tools.reserved?(ip!("192.168.255.255")) + + refute IP.Tools.reserved?(ip!("172.15.255.255")) + refute IP.Tools.reserved?(ip!("172.32.0.0")) + refute IP.Tools.reserved?(ip!("11.0.0.0")) + refute IP.Tools.reserved?(ip!("192.167.255.255")) + refute IP.Tools.reserved?(ip!("192.169.0.0")) + end + + test "loopback 127.0.0.0/8" do + assert IP.Tools.reserved?(ip!("127.0.0.1")) + assert IP.Tools.reserved?(ip!("127.255.255.255")) + refute IP.Tools.reserved?(ip!("126.255.255.255")) + refute IP.Tools.reserved?(ip!("128.0.0.0")) + end + + test "this-network 0.0.0.0/8" do + assert IP.Tools.reserved?(ip!("0.0.0.0")) + assert IP.Tools.reserved?(ip!("0.255.255.255")) + end + + test "link-local 169.254.0.0/16" do + assert IP.Tools.reserved?(ip!("169.254.0.0")) + assert IP.Tools.reserved?(ip!("169.254.255.255")) + refute IP.Tools.reserved?(ip!("169.253.255.255")) + refute IP.Tools.reserved?(ip!("169.255.0.0")) + end + + test "shared address space (CGNAT) 100.64.0.0/10" do + assert IP.Tools.reserved?(ip!("100.64.0.0")) + assert IP.Tools.reserved?(ip!("100.127.255.255")) + refute IP.Tools.reserved?(ip!("100.63.255.255")) + refute IP.Tools.reserved?(ip!("100.128.0.0")) + end + + test "benchmarking 198.18.0.0/15" do + assert IP.Tools.reserved?(ip!("198.18.0.0")) + assert IP.Tools.reserved?(ip!("198.19.255.255")) + refute IP.Tools.reserved?(ip!("198.17.255.255")) + refute IP.Tools.reserved?(ip!("198.20.0.0")) + end + + test "documentation TEST-NET ranges" do + assert IP.Tools.reserved?(ip!("192.0.2.0")) + assert IP.Tools.reserved?(ip!("192.0.2.255")) + assert IP.Tools.reserved?(ip!("198.51.100.0")) + assert IP.Tools.reserved?(ip!("198.51.100.255")) + assert IP.Tools.reserved?(ip!("203.0.113.0")) + assert IP.Tools.reserved?(ip!("203.0.113.255")) + end + + test "reserved-for-future-use 240.0.0.0/4 (class E)" do + assert IP.Tools.reserved?(ip!("240.0.0.0")) + assert IP.Tools.reserved?(ip!("255.255.255.254")) + end + + test "limited broadcast 255.255.255.255/32" do + assert IP.Tools.reserved?(ip!("255.255.255.255")) + end + + test "multicast 224.0.0.0/4" do + assert IP.Tools.reserved?(ip!("224.0.0.0")) + assert IP.Tools.reserved?(ip!("224.0.0.1")) + assert IP.Tools.reserved?(ip!("239.255.255.255")) + refute IP.Tools.reserved?(ip!("223.255.255.255")) + end + + test "IETF protocol assignments 192.0.0.0/24 but not its globally reachable carve-outs" do + assert IP.Tools.reserved?(ip!("192.0.0.0")) + assert IP.Tools.reserved?(ip!("192.0.0.8")) + assert IP.Tools.reserved?(ip!("192.0.0.170")) + assert IP.Tools.reserved?(ip!("192.0.0.171")) + + refute IP.Tools.reserved?(ip!("192.0.0.9")) + refute IP.Tools.reserved?(ip!("192.0.0.10")) + end + + test "AS112/AMT anycast carve-outs are globally reachable, not reserved" do + refute IP.Tools.reserved?(ip!("192.31.196.1")) + refute IP.Tools.reserved?(ip!("192.52.193.1")) + refute IP.Tools.reserved?(ip!("192.175.48.1")) + end + + test "deprecated 6to4 relay anycast block reverted to ordinary space" do + refute IP.Tools.reserved?(ip!("192.88.99.1")) + assert IP.Tools.reserved?(ip!("192.88.99.2")) + end + + test "well-known public addresses are not reserved" do + refute IP.Tools.reserved?(ip!("8.8.8.8")) + refute IP.Tools.reserved?(ip!("1.1.1.1")) + refute IP.Tools.reserved?(ip!("93.184.216.34")) + refute IP.Tools.reserved?(ip!("172.217.0.0")) + end + end + + describe "reserved?/1 - IPv6" do + test "loopback and unspecified" do + assert IP.Tools.reserved?(ip!("::1")) + assert IP.Tools.reserved?(ip!("::")) + end + + test "unique local addresses fc00::/7" do + assert IP.Tools.reserved?(ip!("fc00::1")) + assert IP.Tools.reserved?(ip!("fd00::1")) + refute IP.Tools.reserved?(ip!("fe00::1")) + end + + test "link-local unicast fe80::/10" do + assert IP.Tools.reserved?(ip!("fe80::1")) + assert IP.Tools.reserved?(ip!("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff")) + refute IP.Tools.reserved?(ip!("fec0::1")) + end + + test "documentation ranges" do + assert IP.Tools.reserved?(ip!("2001:db8::1")) + assert IP.Tools.reserved?(ip!("3fff::1")) + end + + test "benchmarking and discard-only blocks" do + assert IP.Tools.reserved?(ip!("2001:2::1")) + assert IP.Tools.reserved?(ip!("100::1")) + end + + test "multicast ff00::/8" do + assert IP.Tools.reserved?(ip!("ff02::1")) + assert IP.Tools.reserved?(ip!("ff00::")) + refute IP.Tools.reserved?(ip!("feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")) + end + + test "IETF protocol assignments 2001::/23 is reserved by default" do + assert IP.Tools.reserved?(ip!("2001:5::1")) + assert IP.Tools.reserved?(ip!("2001:1ff::1")) + end + + test "carve-outs inside 2001::/23 that are globally reachable are not reserved" do + refute IP.Tools.reserved?(ip!("2001:1::1")) + refute IP.Tools.reserved?(ip!("2001:1::2")) + refute IP.Tools.reserved?(ip!("2001:1::3")) + refute IP.Tools.reserved?(ip!("2001:3::1")) + refute IP.Tools.reserved?(ip!("2001:4:112::1")) + refute IP.Tools.reserved?(ip!("2001:20::1")) + refute IP.Tools.reserved?(ip!("2001:30::1")) + end + + test "TEREDO 2001::/32 is not treated as reserved" do + refute IP.Tools.reserved?(ip!("2001:0:4136:e378:8000:63bf:3fff:fdd2")) + end + + test "6to4 2002::/16 is not treated as reserved" do + refute IP.Tools.reserved?(ip!("2002:c000:0204::")) + end + + test "deprecated ORCHID block reverted to ordinary space" do + refute IP.Tools.reserved?(ip!("2001:10::1")) + end + + test "well-known public addresses are not reserved" do + refute IP.Tools.reserved?(ip!("2606:4700:4700::1111")) + refute IP.Tools.reserved?(ip!("2001:4860:4860::8888")) + end + end + + describe "reserved?/1 - IPv4-mapped IPv6 (::ffff:a.b.c.d)" do + test "delegates to the embedded IPv4 address instead of blanket-reserving ::ffff:0:0/96" do + assert IP.Tools.reserved?(ip!("::ffff:127.0.0.1")) + assert IP.Tools.reserved?(ip!("::ffff:10.0.0.1")) + assert IP.Tools.reserved?(ip!("::ffff:192.168.1.1")) + + refute IP.Tools.reserved?(ip!("::ffff:8.8.8.8")) + refute IP.Tools.reserved?(ip!("::ffff:1.1.1.1")) + end + + test "matches reserved?/1 applied directly to the embedded address" do + for str <- ["127.0.0.1", "10.1.2.3", "8.8.8.8", "192.0.2.1", "224.0.0.1"] do + {a, b, c, d} = v4 = ip!(str) + mapped = {0, 0, 0, 0, 0, 0xFFFF, a * 256 + b, c * 256 + d} + + assert IP.Tools.reserved?(mapped) == IP.Tools.reserved?(v4), + "expected ::ffff:#{str} to match reserved?/1 of #{str}" + end + end + end + + describe "allowed?/1" do + test "accepts already-parsed tuples" do + assert IP.Tools.allowed?({8, 8, 8, 8}) + refute IP.Tools.allowed?({127, 0, 0, 1}) + assert IP.Tools.allowed?({0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111}) + refute IP.Tools.allowed?({0, 0, 0, 0, 0, 0, 0, 1}) + end + + test "accepts binaries" do + assert IP.Tools.allowed?("8.8.8.8") + refute IP.Tools.allowed?("192.168.1.1") + assert IP.Tools.allowed?("2606:4700:4700::1111") + refute IP.Tools.allowed?("::1") + end + + test "disallows invalid inputs" do + refute IP.Tools.allowed?({1, 2, 3}) + refute IP.Tools.allowed?({1, 2, 3, 4, 5}) + refute IP.Tools.allowed?(nil) + refute IP.Tools.allowed?(:not_an_ip) + refute IP.Tools.allowed?("not an ip") + refute IP.Tools.allowed?("") + refute IP.Tools.allowed?("999.999.999.999") + end + end + + describe "ranges/0" do + test "every range is well formed" do + ranges = IP.Tools.ranges() + + assert length(ranges) > 30 + + for %{cidr: cidr, reserved: reserved, name: name} <- ranges do + assert is_binary(cidr) + assert is_binary(name) + assert is_boolean(reserved) + end + end + + test "is sorted from most to least specific prefix" do + prefix_lengths = + Enum.map(IP.Tools.ranges(), fn %{cidr: cidr} -> + [_addr, prefix] = String.split(cidr, "/") + String.to_integer(prefix) + end) + + assert prefix_lengths == Enum.sort(prefix_lengths, :desc) + end + + test "includes the supplemental multicast ranges" do + cidrs = Enum.map(IP.Tools.ranges(), & &1.cidr) + + assert "224.0.0.0/4" in cidrs + assert "ff00::/8" in cidrs + end + + test "excludes ::ffff:0:0/96 in favor of delegating to IPv4 rules" do + refute "::ffff:0:0/96" in Enum.map(IP.Tools.ranges(), & &1.cidr) + end + end +end From bba7e36e23da44e0c247970a0091187628bbfc43 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 16 Jul 2026 08:54:43 +0200 Subject: [PATCH 02/15] :nail-care: --- lib/ip/tools/registry.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ip/tools/registry.ex b/lib/ip/tools/registry.ex index 1e8387b40ecf..99c84aef52a8 100644 --- a/lib/ip/tools/registry.ex +++ b/lib/ip/tools/registry.ex @@ -27,7 +27,7 @@ defmodule Plausible.IP.Tools.Registry do |> Enum.reject(&(&1.cidr == "::ffff:0:0/96")) (ipv4 ++ ipv6 ++ multicast()) - |> Enum.sort_by(&(-&1.prefix_len)) + |> Enum.sort_by(& &1.prefix_len, :desc) |> Enum.map(&to_clause/1) end From 2e56f5d351a506cbbb0dd81fd0ca483a401f480a Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 16 Jul 2026 12:10:14 +0200 Subject: [PATCH 03/15] CI - check if registry is up to date --- .github/workflows/iana-registry-check.yml | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/iana-registry-check.yml diff --git a/.github/workflows/iana-registry-check.yml b/.github/workflows/iana-registry-check.yml new file mode 100644 index 000000000000..7e6504a4d176 --- /dev/null +++ b/.github/workflows/iana-registry-check.yml @@ -0,0 +1,57 @@ +name: IANA registry check + +on: + # TODO + push: + # schedule: + # # Runs at 03:00 UTC on the 1st of every month. + # - cron: "0 3 1 * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-iana-registries: + name: Check IANA IP registries up to date + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: marocchino/tool-versions-action@18a164fa2b0db1cc1edf7305fcb17ace36d1c306 # v1.2.0 + id: versions + + - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 + with: + elixir-version: ${{ steps.versions.outputs.elixir }} + otp-version: ${{ steps.versions.outputs.erlang }} + + - run: mix deps.get + + - run: mix download_ip_registries + + - name: Fail if the downloaded registries differ from what's committed + run: | + if ! git diff --exit-code --quiet -- priv/ip/iana-ipv4-special-registry.csv priv/ip/iana-ipv6-special-registry.csv; then + echo "IANA special-purpose registries are out of date. Run 'mix download_ip_registries' and commit the changes." + git diff -- priv/ip/iana-ipv4-special-registry.csv priv/ip/iana-ipv6-special-registry.csv + exit 1 + fi + + - name: Notify team on success + if: ${{ success() }} + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "

IANA IP registry is up to date.

"}' + + - name: Notify team on failure + if: ${{ failure() }} + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "

⚠️ IANA IP registry check failed.

"}' From 73ee7548b21c9a54c024bba8390a757a36ee3ef6 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 16 Jul 2026 12:19:44 +0200 Subject: [PATCH 04/15] Ignore spaces at eol --- .github/workflows/iana-registry-check.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/iana-registry-check.yml b/.github/workflows/iana-registry-check.yml index 7e6504a4d176..79b7c4779e07 100644 --- a/.github/workflows/iana-registry-check.yml +++ b/.github/workflows/iana-registry-check.yml @@ -32,9 +32,11 @@ jobs: - name: Fail if the downloaded registries differ from what's committed run: | - if ! git diff --exit-code --quiet -- priv/ip/iana-ipv4-special-registry.csv priv/ip/iana-ipv6-special-registry.csv; then + # IANA serves these CSVs with CRLF line endings while the committed files use LF; + # --ignore-space-at-eol treats that difference as a non-change so only real content changes fail the check. + if ! git diff --exit-code --quiet --ignore-space-at-eol -- priv/ip/iana-ipv4-special-registry.csv priv/ip/iana-ipv6-special-registry.csv; then echo "IANA special-purpose registries are out of date. Run 'mix download_ip_registries' and commit the changes." - git diff -- priv/ip/iana-ipv4-special-registry.csv priv/ip/iana-ipv6-special-registry.csv + git diff --ignore-space-at-eol -- priv/ip/iana-ipv4-special-registry.csv priv/ip/iana-ipv6-special-registry.csv exit 1 fi From 518f6d8f246a2792df4f28c391ea25cc0f6717db Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 16 Jul 2026 12:25:12 +0200 Subject: [PATCH 05/15] Run monthly --- .github/workflows/iana-registry-check.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/iana-registry-check.yml b/.github/workflows/iana-registry-check.yml index 79b7c4779e07..947e642a9dd2 100644 --- a/.github/workflows/iana-registry-check.yml +++ b/.github/workflows/iana-registry-check.yml @@ -1,11 +1,9 @@ name: IANA registry check on: - # TODO - push: - # schedule: - # # Runs at 03:00 UTC on the 1st of every month. - # - cron: "0 3 1 * *" + schedule: + # Runs at 09:00 UTC on the 1st of every month. + - cron: "0 9 1 * *" workflow_dispatch: permissions: From 46fee37f4c5cf5d903c13406999c7d87ca37bb80 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 07:53:39 +0200 Subject: [PATCH 06/15] Implement SSRF-safe client on top of #6512 --- lib/plausible/ssrf.ex | 130 +++++++++++++++++++++++ mix.lock | 8 +- test/plausible/ssrf_test.exs | 198 +++++++++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 lib/plausible/ssrf.ex create mode 100644 test/plausible/ssrf_test.exs diff --git a/lib/plausible/ssrf.ex b/lib/plausible/ssrf.ex new file mode 100644 index 000000000000..cb11f144cf8d --- /dev/null +++ b/lib/plausible/ssrf.ex @@ -0,0 +1,130 @@ +defmodule Plausible.SSRF do + @moduledoc """ + Guards outbound HTTP requests and DNS lookups against customer-supplied + hostnames reaching internal, private or local network space + """ + + @type error_reason :: + :invalid_url + | :invalid_host + | :dns_resolution_failed + | :restricted_address + | :too_many_redirects + + @redirect_statuses [301, 302, 303, 307, 308] + @default_max_redirects 4 + @dns_timeout 1_000 + + # Every distinct `connect_options: [hostname: ...]` value we pass below + # makes Req start a brand new, dedicated Finch instance under the hood + # (Req can't vary `conn_opts` per-request on a shared pool, so it forks one + # per connect_options fingerprint) - and since these hostnames are + # customer-controlled, that's an unbounded number of them over time. + @default_pool_max_idle_time :timer.minutes(5) + + @doc """ + Resolves bare hostname or literal IP and returns its address(es), + only if every all are allowed + """ + @spec resolve_host(String.t()) :: {:ok, [:inet.ip_address()]} | {:error, error_reason()} + def resolve_host(host) when is_binary(host) do + case :inet.parse_address(to_charlist(host)) do + {:ok, ip} -> + validate_ips([ip]) + + {:error, :einval} -> + if String.contains?(host, ".") do + host |> dns_lookup() |> validate_ips() + else + {:error, :invalid_host} + end + end + end + + @doc """ + Performs a GET request against arbitrary URL, refusing to connect to (or be + redirected to) a restricted address + """ + @spec get(String.t(), keyword()) :: + {:ok, Req.Response.t()} | {:error, error_reason() | Exception.t()} + def get(url, opts \\ []) do + max_redirects = Keyword.get(opts, :max_redirects, @default_max_redirects) + request(url, opts, max_redirects) + end + + defp request(url, opts, redirects_left) do + with {:ok, uri} <- parse_url(url), + {:ok, ips} <- resolve_host(uri.host), + {:ok, resp} <- do_request(uri, hd(ips), opts) do + handle_response(resp, uri, opts, redirects_left) + end + end + + defp parse_url(url) do + case URI.new(url) do + {:ok, %URI{scheme: scheme, host: host} = uri} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + {:ok, uri} + + _ -> + {:error, :invalid_url} + end + end + + defp do_request(uri, ip, opts) do + host = uri.host + pinned_url = %{uri | host: ip |> :inet.ntoa() |> to_string()} + + request_opts = + opts + |> Keyword.put_new(:pool_max_idle_time, @default_pool_max_idle_time) + |> Keyword.merge( + method: :get, + url: pinned_url, + headers: [{"host", host}], + connect_options: [hostname: host], + redirect: false + ) + + Req.request(request_opts) + end + + defp handle_response(%Req.Response{status: status} = resp, uri, opts, redirects_left) + when status in @redirect_statuses do + case Req.Response.get_header(resp, "location") do + [location | _] when redirects_left > 0 -> + next_url = uri |> URI.merge(location) |> URI.to_string() + request(next_url, opts, redirects_left - 1) + + [_ | _] -> + {:error, :too_many_redirects} + + [] -> + {:ok, resp} + end + end + + defp handle_response(resp, _uri, _opts, _redirects_left), do: {:ok, resp} + + defp dns_lookup(host) do + charlist_host = to_charlist(host) + opts = [timeout: @dns_timeout] + + a = Plausible.DnsLookup.impl().lookup(charlist_host, :in, :a, opts, @dns_timeout) + aaaa = Plausible.DnsLookup.impl().lookup(charlist_host, :in, :aaaa, opts, @dns_timeout) + + a ++ aaaa + end + + defp validate_ips([]) do + {:error, :dns_resolution_failed} + end + + defp validate_ips(ips) do + if Enum.all?(ips, &Plausible.IP.Tools.allowed?/1) do + {:ok, ips} + else + {:error, :restricted_address} + end + end +end diff --git a/mix.lock b/mix.lock index 1ec2693e1faf..e1c545d3f8ce 100644 --- a/mix.lock +++ b/mix.lock @@ -7,7 +7,7 @@ "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "castore": {:hex, :castore, "1.0.20", "455e48f7115eca98c9f2b0e7a152b5a2e8f2a8a4f964c96e95bd31645ee5fa59", [:mix], [], "hexpm", "940eafbfd8b14bee649f083bc11b3b54ec555b54c3e4ea8213351ff6fee39c10"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "ch": {:hex, :ch, "0.7.1", "116c08094b30d095c3bd6a8fe4ebe19fdaaf3dce84e2413cfdd6af157baf6303", [:mix], [{:db_connection, "~> 2.9.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "3c1c900291ff9c4c077cd1dc0c265051a3f1d26320d58b37ed9e91b33d41a868"}, @@ -83,7 +83,7 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, - "mint": {:hex, :mint, "1.9.1", "3bc120b743ed2e99ad920910f2613e9faebabb2257731b0e2ea4d8ccd9eceede", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "831101bd560b086316fab5f7adb21a4f3455717d8e4bc8368b052e09aa9163e0"}, + "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, "mjml": {:hex, :mjml, "4.0.0", "3418c1a975112766f92b8786a17d53640a4a1c8c7dedb621016a08e1e1447d20", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7.0", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "0886a411f9a850bc4ac6b878c9c0dbea168669176fde1f6583acf55fa8b628dd"}, "mjml_eex": {:hex, :mjml_eex, "0.12.0", "ee7e58a0b2f361812c024ddd2dd57ccd4107e486632016fcf707d4d0eaa04d62", [:mix], [{:erlexec, "~> 2.0.7", [hex: :erlexec, repo: "hexpm", optional: true]}, {:mjml, "~> 4.0", [hex: :mjml, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6ca41f6252669b709d31d8b7c74db00c8bc3784dea1913b406a68c12763c5bcb"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, @@ -129,7 +129,7 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "php_serializer": {:hex, :php_serializer, "2.0.0", "b43f31aca22ed7321f32da2b94fe2ddf9b6739a965cb51541969119e572e821d", [:mix], [], "hexpm", "61e402e99d9062c0225a3f4fcf7e43b4cba1b8654944c0e7c139c3ca9de481da"}, - "plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "polymorphic_embed": {:hex, :polymorphic_embed, "5.0.3", "37444e0af941026a2c29b0539b6471bdd6737a6492a19264bf2bb0118e3ac242", [:mix], [{:attrs, "~> 0.6", [hex: :attrs, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "2fed44f57abf0a0fc7642e0eb0807a55b65de1562712cc0620772cbbb80e49c1"}, @@ -141,7 +141,7 @@ "recon": {:hex, :recon, "2.5.4", "05dd52a119ee4059fa9daa1ab7ce81bc7a8161a2f12e9d42e9d551ffd2ba901c", [:mix, :rebar3], [], "hexpm", "e9ab01ac7fc8572e41eb59385efeb3fb0ff5bf02103816535bacaedf327d0263"}, "ref_inspector": {:hex, :ref_inspector, "2.0.0", "f3e97e51d9782de4c792f56eed26c80903bc39174c878285392ce76d5e67fe98", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:yamerl, "~> 0.7", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "bf62f3f1a87d6b8b30f457a480668f965373e64f184611282b5e89d8dd81fd33"}, "referrer_blocklist": {:git, "https://github.com/plausible/referrer-blocklist.git", "d6f52c225cccb4f04b80e3a5d588868ec234139d", []}, - "req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"}, + "req": {:hex, :req, "0.6.3", "7fe5e68792ff0546e45d5919104fa1764a13694cfe3e48c8a0f32ad051ae77e4", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e85b5c6c990e6c3f52bbba68e6f099118f2b8252825f96c7c3636b97a3de307d"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.7.3", "42cb9449785cd86c87453e39afdd27a0bdfa5c77a4ec5dc5ce45112e06b9f89b", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "cbc4b3777682e5f6f43ed39b0e0b4a42dccde8053aba91b4514e8f5ff9a5ac6d"}, "saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"}, "sentry": {:hex, :sentry, "11.0.4", "60371c96cefd247e0fc98840bba2648f64f19aa0b8db8e938f5a98421f55b619", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "feaafc284dc204c82aadaddc884227aeaa3480decb274d30e184b9d41a700c66"}, diff --git a/test/plausible/ssrf_test.exs b/test/plausible/ssrf_test.exs new file mode 100644 index 000000000000..60a997822e27 --- /dev/null +++ b/test/plausible/ssrf_test.exs @@ -0,0 +1,198 @@ +defmodule Plausible.SSRFTest do + use ExUnit.Case, async: true + use Plausible.TestUtils + use Plausible.Test.Support.DNS + + alias Plausible.SSRF + + describe "resolve_host/1" do + test "rejects bare single-label hosts (no dot)" do + expect_no_dns_lookup() + + assert SSRF.resolve_host("localhost") == {:error, :invalid_host} + assert SSRF.resolve_host("metadata") == {:error, :invalid_host} + end + + test "validates a literal public IPv4 host directly, without any DNS lookup" do + expect_no_dns_lookup() + + assert SSRF.resolve_host("93.184.216.34") == {:ok, [{93, 184, 216, 34}]} + end + + test "rejects a literal private/loopback/link-local IPv4 host, without any DNS lookup" do + expect_no_dns_lookup() + + assert SSRF.resolve_host("127.0.0.1") == {:error, :restricted_address} + assert SSRF.resolve_host("10.0.0.1") == {:error, :restricted_address} + assert SSRF.resolve_host("169.254.169.254") == {:error, :restricted_address} + end + + test "rejects a literal private/loopback IPv6 host, without any DNS lookup" do + expect_no_dns_lookup() + + assert SSRF.resolve_host("::1") == {:error, :restricted_address} + assert SSRF.resolve_host("fc00::1") == {:error, :restricted_address} + end + + test "resolves a domain via DNS to a public address" do + stub_dns(%{"example.com" => {[{93, 184, 216, 34}], []}}) + + assert SSRF.resolve_host("example.com") == {:ok, [{93, 184, 216, 34}]} + end + + test "rejects a domain whose only DNS answer is a private/reserved address" do + stub_dns(%{"example.com" => {[{192, 168, 1, 1}], []}}) + + assert SSRF.resolve_host("example.com") == {:error, :restricted_address} + end + + test "rejects a domain whose AAAA answer is a private/reserved address" do + stub_dns(%{"example.com" => {[], [{0xFC00, 0, 0, 0, 0, 0, 0, 1}]}}) + + assert SSRF.resolve_host("example.com") == {:error, :restricted_address} + end + + test "rejects a domain when DNS returns a mix of public and private addresses" do + stub_dns(%{"example.com" => {[{93, 184, 216, 34}, {169, 254, 169, 254}], []}}) + + assert SSRF.resolve_host("example.com") == {:error, :restricted_address} + end + + test "reports a domain with no A/AAAA records as unresolved" do + stub_dns(%{"example.com" => {[], []}}) + + assert SSRF.resolve_host("example.com") == {:error, :dns_resolution_failed} + end + end + + describe "get/2" do + test "fetches a literal public IP host directly (no DNS lookup), pinning the Host header" do + expect_no_dns_lookup() + + Req.Test.stub(__MODULE__, fn conn -> + assert {"host", "93.184.216.34"} in conn.req_headers + Plug.Conn.send_resp(conn, 200, "ok") + end) + + assert {:ok, %Req.Response{status: 200, body: "ok"}} = + SSRF.get("http://93.184.216.34/", plug: {Req.Test, __MODULE__}) + end + + test "resolves via DNS, fetches, and pins the Host header to the original hostname" do + stub_dns(%{"good.example" => {[{93, 184, 216, 34}], []}}) + + Req.Test.stub(__MODULE__, fn conn -> + assert {"host", "good.example"} in conn.req_headers + Plug.Conn.send_resp(conn, 200, "hello") + end) + + assert {:ok, %Req.Response{status: 200, body: "hello"}} = + SSRF.get("http://good.example/", plug: {Req.Test, __MODULE__}) + end + + test "rejects a host whose DNS answer is a private address, without ever reaching the plug" do + stub_dns(%{"evil.example" => {[{127, 0, 0, 1}], []}}) + + Req.Test.stub(__MODULE__, fn _conn -> + raise "should never be called" + end) + + assert SSRF.get("http://evil.example/", plug: {Req.Test, __MODULE__}) == + {:error, :restricted_address} + end + + test "follows a redirect to a still-public host, re-validating before following" do + stub_dns(%{ + "good.example" => {[{93, 184, 216, 34}], []}, + "still-good.example" => {[{93, 184, 216, 35}], []} + }) + + Req.Test.stub(__MODULE__, fn conn -> + case conn.request_path do + "/start" -> + conn + |> Plug.Conn.put_resp_header("location", "http://still-good.example/final") + |> Plug.Conn.send_resp(302, "") + + "/final" -> + Plug.Conn.send_resp(conn, 200, "done") + end + end) + + assert {:ok, %Req.Response{status: 200, body: "done"}} = + SSRF.get("http://good.example/start", plug: {Req.Test, __MODULE__}) + end + + test "rejects a redirect to a host whose DNS answer is a private address" do + stub_dns(%{ + "good.example" => {[{93, 184, 216, 34}], []}, + "internal.example" => {[{169, 254, 169, 254}], []} + }) + + Req.Test.stub(__MODULE__, fn conn -> + case conn.request_path do + "/start" -> + conn + |> Plug.Conn.put_resp_header("location", "http://internal.example/steal-secrets") + |> Plug.Conn.send_resp(302, "") + + "/steal-secrets" -> + raise "should never be called - redirect target must be rejected before follow-up" + end + end) + + assert SSRF.get("http://good.example/start", plug: {Req.Test, __MODULE__}) == + {:error, :restricted_address} + end + + test "gives up after exceeding the redirect budget" do + stub_dns(%{"loopy.example" => {[{93, 184, 216, 34}], []}}) + + Req.Test.stub(__MODULE__, fn conn -> + conn + |> Plug.Conn.put_resp_header("location", "http://loopy.example/") + |> Plug.Conn.send_resp(302, "") + end) + + assert SSRF.get("http://loopy.example/", plug: {Req.Test, __MODULE__}, max_redirects: 2) == + {:error, :too_many_redirects} + end + end + + describe "pool_max_idle_time" do + test "an idle Finch pool is reaped after pool_max_idle_time, instead of lingering forever" do + # This exercises the same connect_options/pool_max_idle_time shape + # SSRF.do_request/3 builds, via a real Finch pool - it can't be driven + # through SSRF.get/2 itself because the only network target reachable + # from a test is loopback, which SSRF.resolve_host/1 correctly (and + # by design) rejects as a restricted address. + test_pid = self() + bypass = Bypass.open() + + handler_id = "ssrf-pool-idle-test-#{System.unique_integer()}" + + :telemetry.attach( + handler_id, + [:finch, :pool_max_idle_time_exceeded], + fn _event, _measurements, metadata, _config -> + if metadata.port == bypass.port, do: send(test_pid, :pool_reaped) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + Bypass.expect_once(bypass, "GET", "/", fn conn -> Plug.Conn.send_resp(conn, 200, "ok") end) + + assert {:ok, %Req.Response{status: 200}} = + Req.request( + method: :get, + url: "http://localhost:#{bypass.port}/", + connect_options: [hostname: "localhost"], + pool_max_idle_time: 50 + ) + + assert_receive :pool_reaped + end + end +end From 98d5aab6a3720ce5e01e71e818b9a7cc80b7b9d6 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 07:56:30 +0200 Subject: [PATCH 07/15] Update Test.Support.DNS --- test/support/dns.ex | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/test/support/dns.ex b/test/support/dns.ex index c3e3800f2d42..3ea1cdc5e7bb 100644 --- a/test/support/dns.ex +++ b/test/support/dns.ex @@ -1,16 +1,39 @@ defmodule Plausible.Test.Support.DNS do @moduledoc false + defmacro __using__(_) do quote do import Mox - def stub_lookup_a_records(domain, a_records \\ [{192, 168, 1, 1}]) do + def stub_dns do + stub(Plausible.DnsLookup.Mock, :lookup, fn _domain, :in, type, _opts, _timeout -> + case type do + :a -> [{93, 184, 216, 34}] + :aaaa -> [] + end + end) + end + + # `%{"domain" => {a_records, aaaa_records}}` + def stub_dns(mapping) when is_map(mapping) do + stub(Plausible.DnsLookup.Mock, :lookup, fn domain, :in, type, _opts, _timeout -> + {a_records, aaaa_records} = Map.fetch!(mapping, List.to_string(domain)) + if type == :a, do: a_records, else: aaaa_records + end) + end + + def expect_dns_lookup(domain, a_records, aaaa_records \\ []) do lookup_domain = to_charlist(domain) Plausible.DnsLookup.Mock - |> expect(:lookup, fn ^lookup_domain, _type, _record, _opts, _timeout -> - a_records - end) + |> expect(:lookup, fn ^lookup_domain, :in, :a, _opts, _timeout -> a_records end) + |> expect(:lookup, fn ^lookup_domain, :in, :aaaa, _opts, _timeout -> aaaa_records end) + end + + def expect_dns_unresolvable(domain), do: expect_dns_lookup(domain, [], []) + + def expect_no_dns_lookup do + expect(Plausible.DnsLookup.Mock, :lookup, 0, fn _, _, _, _, _ -> [] end) end end end From d99f673a92e4884394055ca6ed70149a7413c7bc Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 08:08:56 +0200 Subject: [PATCH 08/15] Temporarily bring back original DNS stub --- test/support/dns.ex | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/support/dns.ex b/test/support/dns.ex index 3ea1cdc5e7bb..2ff0dced1db9 100644 --- a/test/support/dns.ex +++ b/test/support/dns.ex @@ -35,6 +35,15 @@ defmodule Plausible.Test.Support.DNS do def expect_no_dns_lookup do expect(Plausible.DnsLookup.Mock, :lookup, 0, fn _, _, _, _, _ -> [] end) end + + def stub_lookup_a_records(domain, a_records \\ [{192, 168, 1, 1}]) do + lookup_domain = to_charlist(domain) + + Plausible.DnsLookup.Mock + |> expect(:lookup, fn ^lookup_domain, _type, _record, _opts, _timeout -> + a_records + end) + end end end end From 86a410dd9174bc0c1453bd739d1dd5b86ef2242a Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 08:19:10 +0200 Subject: [PATCH 09/15] Update `make sso` instructions --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index 167706e68ac8..ca8037a3a5a5 100644 --- a/Makefile +++ b/Makefile @@ -92,6 +92,8 @@ sso: @echo "- user@plausible.test / plausible" @echo "- user1@plausible.test / plausible" @echo "- user2@plausible.test / plausible" + @echo "" + @echo "Run plausible application server with ALLOW_RESERVED_IPS=true" sso-stop: docker stop idp From a9726334d9689fd266fdb470eb792f3446a1b2fe Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 08:19:26 +0200 Subject: [PATCH 10/15] Optionally allow restricted IPs in dev MIX_ENVs, via env var --- config/runtime.exs | 5 +++++ lib/ip/tools.ex | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/config/runtime.exs b/config/runtime.exs index 3992ddcdf9ef..91f056353d27 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1100,3 +1100,8 @@ unless s3_disabled? do end config :plausible, Plausible.Cache.Adapter, sessions: [partitions: 100] + +config :plausible, Plausible.IP.Tools, + allow_reserved_ips?: + config_env() in [:dev, :ce_dev] and + get_bool_from_path_or_env(config_dir, "ALLOW_RESERVED_IPS", false) diff --git a/lib/ip/tools.ex b/lib/ip/tools.ex index e5a4e8525436..e550802e2f58 100644 --- a/lib/ip/tools.ex +++ b/lib/ip/tools.ex @@ -9,6 +9,8 @@ defmodule Plausible.IP.Tools do @clauses Plausible.IP.Tools.Registry.entries() + require Logger + @doc """ Returns the ranges used in `reserved?/1`, for testing purposes. """ @@ -40,6 +42,7 @@ defmodule Plausible.IP.Tools do @doc """ Determines if IP is allowed, i.e. valid and not reserved/private. """ + @spec allowed?(:inet.ip_address() | String.t()) :: boolean() def allowed?(ip) when is_binary(ip) do case :inet.parse_address(String.to_charlist(ip)) do @@ -49,8 +52,24 @@ defmodule Plausible.IP.Tools do end def allowed?(ip) when is_tuple(ip) and tuple_size(ip) in [4, 8] do - not reserved?(ip) + case allow_reserved_ips?() do + true -> + if reserved?(ip) do + Logger.warning( + "IP #{inspect(ip)} forcibly allowed due to :allow_reserved_ips? env set. " + ) + end + + true + + false -> + not reserved?(ip) + end end def allowed?(_ip), do: false + + defp allow_reserved_ips? do + Application.get_env(:plausible, __MODULE__)[:allow_reserved_ips?] || false + end end From 5c3d4b5102140334f18f14e48ff9615680518e77 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 08:20:39 +0200 Subject: [PATCH 11/15] Implement better SSO domain provisioning --- extra/lib/plausible/auth/sso/domain.ex | 23 +++++----- .../plausible/auth/sso/domain/verification.ex | 43 +++++++++++-------- extra/lib/plausible/auth/sso/domains.ex | 6 +-- .../auth/sso/domain/verification_test.exs | 8 ++++ test/plausible/auth/sso/domains_test.exs | 33 ++++++++------ test/plausible/auth/sso_test.exs | 43 ++++++++++--------- .../controllers/sso_controller_sync_test.exs | 4 +- .../controllers/sso_controller_test.exs | 2 +- .../live/sso_management_test.exs | 11 ++++- .../sso_domain_verification_worker_test.exs | 2 +- 10 files changed, 107 insertions(+), 68 deletions(-) diff --git a/extra/lib/plausible/auth/sso/domain.ex b/extra/lib/plausible/auth/sso/domain.ex index 572026eba564..49b9820bc864 100644 --- a/extra/lib/plausible/auth/sso/domain.ex +++ b/extra/lib/plausible/auth/sso/domain.ex @@ -46,13 +46,15 @@ defmodule Plausible.Auth.SSO.Domain do timestamps() end - @spec create_changeset(SSO.Integration.t(), String.t() | nil) :: Ecto.Changeset.t() - def create_changeset(integration, domain) do + @spec create_changeset(SSO.Integration.t(), String.t() | nil, Keyword.t()) :: Ecto.Changeset.t() + def create_changeset(integration, domain, opts \\ []) do + skip_checks? = Keyword.get(opts, :skip_checks?, false) + %__MODULE__{} |> cast(%{domain: domain}, [:domain]) |> validate_required(:domain) |> normalize_domain(:domain) - |> validate_domain(:domain) + |> validate_domain(:domain, skip_checks?) |> unique_constraint(:domain, message: "is already in use") |> put_change(:identifier, Ecto.UUID.generate()) |> put_assoc(:sso_integration, integration) @@ -81,15 +83,14 @@ defmodule Plausible.Auth.SSO.Domain do |> put_change(:status, status) end - @spec valid_domain?(String.t()) :: boolean() - def valid_domain?(domain) do - # This is not a surefire way to ensure the domain is correct, - # but it should give a bit more confidence that it's at least - # resolvable. + @spec valid_domain?(String.t(), Keyword.t()) :: boolean() + def valid_domain?(domain, opts \\ []) do + skip_checks? = Keyword.get(opts, :skip_checks?, false) + case URI.new("https://" <> domain) do {:ok, %{host: host, port: port, path: nil, query: nil, fragment: nil, userinfo: nil}} when is_binary(host) and port in [80, 443] -> - true + skip_checks? or match?({:ok, _ips}, Plausible.SSRF.resolve_host(host)) _ -> false @@ -119,9 +120,9 @@ defmodule Plausible.Auth.SSO.Domain do end end - defp validate_domain(changeset, field) do + defp validate_domain(changeset, field, skip_checks?) do if domain = get_change(changeset, field) do - if valid_domain?(domain) do + if valid_domain?(domain, skip_checks?: skip_checks?) do changeset else add_error(changeset, field, "invalid domain", validation: :domain) diff --git a/extra/lib/plausible/auth/sso/domain/verification.ex b/extra/lib/plausible/auth/sso/domain/verification.ex index 329fec425d9e..ffa055d8052e 100644 --- a/extra/lib/plausible/auth/sso/domain/verification.ex +++ b/extra/lib/plausible/auth/sso/domain/verification.ex @@ -40,7 +40,12 @@ defmodule Plausible.Auth.SSO.Domain.Verification do @spec url(String.t(), String.t(), Keyword.t()) :: boolean() def url(sso_domain, domain_identifier, opts \\ []) do url_override = Keyword.get(opts, :url_override) - resp = run_request(url_override || "https://" <> Path.join(sso_domain, @prefix)) + + resp = + run_request( + url_override || "https://" <> Path.join(sso_domain, @prefix), + skip_checks?: not is_nil(url_override) + ) case resp do %Req.Response{body: body} @@ -57,7 +62,9 @@ defmodule Plausible.Auth.SSO.Domain.Verification do url_override = Keyword.get(opts, :url_override) with %Req.Response{body: body} = response when is_binary(body) <- - run_request(url_override || "https://#{sso_domain}"), + run_request(url_override || "https://#{sso_domain}", + skip_checks?: not is_nil(url_override) + ), true <- html?(response), html <- LazyHTML.from_document(body), [_ | _] <- @@ -103,22 +110,24 @@ defmodule Plausible.Auth.SSO.Domain.Verification do |> String.contains?("text/html") end - defp run_request(base_url) do - fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] - - opts = - Keyword.merge( - [ - base_url: base_url, - max_redirects: 4, - max_retries: 3, - retry_log_level: :warning - ], - fetch_body_opts - ) + # skip_checks?: true is only ever supplied by url_override escape hatch + # used by tests (e.g. to point at a local Bypass server) + defp run_request(url, opts) do + skip_checks? = Keyword.get(opts, :skip_checks?, false) + base_opts = [max_redirects: 4, max_retries: 3, retry_log_level: :warning] + + result = + if skip_checks? do + Req.request(Keyword.merge(base_opts, url: url, method: :get)) + else + fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] + Plausible.SSRF.get(url, Keyword.merge(base_opts, fetch_body_opts)) + end - {_req, resp} = opts |> Req.new() |> Req.Request.run_request() - resp + case result do + {:ok, resp} -> resp + {:error, _reason} -> nil + end end @after_compile __MODULE__ diff --git a/extra/lib/plausible/auth/sso/domains.ex b/extra/lib/plausible/auth/sso/domains.ex index dee39c5c9336..3013d9df3dce 100644 --- a/extra/lib/plausible/auth/sso/domains.ex +++ b/extra/lib/plausible/auth/sso/domains.ex @@ -12,10 +12,10 @@ defmodule Plausible.Auth.SSO.Domains do use Plausible.Auth.SSO.Domain.Status - @spec add(SSO.Integration.t(), String.t()) :: + @spec add(SSO.Integration.t(), String.t(), Keyword.t()) :: {:ok, SSO.Domain.t()} | {:error, Ecto.Changeset.t()} - def add(integration, domain) do - changeset = SSO.Domain.create_changeset(integration, domain) + def add(integration, domain, opts \\ []) do + changeset = SSO.Domain.create_changeset(integration, domain, opts) Repo.insert_with_audit(changeset, "sso_domain_added", %{team_id: integration.team_id}) end diff --git a/test/plausible/auth/sso/domain/verification_test.exs b/test/plausible/auth/sso/domain/verification_test.exs index db52aebc6f7a..0ed2b39fcd9f 100644 --- a/test/plausible/auth/sso/domain/verification_test.exs +++ b/test/plausible/auth/sso/domain/verification_test.exs @@ -4,6 +4,8 @@ defmodule Plausible.Auth.SSO.Domain.VerificationTest do @moduletag :ee_only on_ee do + use Plausible.Test.Support.DNS + alias Plasusible.Test.Support.DNSServer alias Plausible.Auth.SSO.Domain.Verification alias Plug.Conn @@ -40,6 +42,12 @@ defmodule Plausible.Auth.SSO.Domain.VerificationTest do ) end + test "url without url_override is protected against SSRF when DNS resolves to a private IP" do + expect_dns_lookup("example.com", [{192, 168, 1, 1}]) + + refute Verification.url("example.com", "ex4mpl3") + end + test "meta_tag", %{bypass: bypass} do Bypass.expect(bypass, "GET", "/test", fn conn -> conn diff --git a/test/plausible/auth/sso/domains_test.exs b/test/plausible/auth/sso/domains_test.exs index 27f853e8b906..84192dead44b 100644 --- a/test/plausible/auth/sso/domains_test.exs +++ b/test/plausible/auth/sso/domains_test.exs @@ -6,6 +6,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do on_ee do use Plausible.Auth.SSO.Domain.Status use Oban.Testing, repo: Plausible.Repo + use Plausible.Test.Support.DNS alias Plausible.Auth alias Plausible.Auth.SSO @@ -24,6 +25,14 @@ defmodule Plausible.Auth.SSO.DomainsTest do end describe "add/2" do + setup do + # These tests exercise real add-time domain validation (including + # resolvability), so - unlike the rest of this module, which passes + # skip_checks?: true - they need DNS to actually resolve. + stub_dns() + :ok + end + test "adds a new domain", %{integration: integration} do domain = generate_domain() @@ -113,7 +122,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do integration: integration } do domain = generate_domain() - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) verified_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) @@ -132,7 +141,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do integration: integration } do domain = generate_domain() - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) unverified_domain = SSO.Domains.verify(sso_domain, verification_opts: [methods: []]) @@ -150,7 +159,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do test "sets domain status to in progress", %{integration: integration} do domain = generate_domain() - {:ok, _} = SSO.Domains.add(integration, domain) + {:ok, _} = SSO.Domains.add(integration, domain, skip_checks?: true) assert {:ok, sso_domain} = SSO.Domains.start_verification(domain) assert sso_domain.status == Status.in_progress() @@ -162,7 +171,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do test "enqueues background work", %{integration: integration} do domain = generate_domain() - {:ok, _} = SSO.Domains.add(integration, domain) + {:ok, _} = SSO.Domains.add(integration, domain, skip_checks?: true) assert {:ok, _} = SSO.Domains.start_verification(domain) assert_enqueued( @@ -179,7 +188,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do test "sets domain status to unverified", %{integration: integration} do domain = generate_domain() - {:ok, _} = SSO.Domains.add(integration, domain) + {:ok, _} = SSO.Domains.add(integration, domain, skip_checks?: true) assert {:ok, sso_domain} = SSO.Domains.start_verification(domain) assert :ok = SSO.Domains.cancel_verification(domain) assert Repo.reload!(sso_domain).status == Status.unverified() @@ -194,7 +203,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do describe "lookup/1" do test "looks up domain by email", %{integration: integration} do domain = generate_domain() - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) email = "mary.jane@" <> domain @@ -208,7 +217,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do test "looks up domain by plain domain", %{integration: integration} do domain = generate_domain() - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) assert {:ok, found_sso_domain} = SSO.Domains.lookup(domain) @@ -217,7 +226,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do test "normalizes input removing whitespace and capitalizations", %{integration: integration} do domain = generate_domain() - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) email = " maRy.jAnE@" <> String.upcase(domain) <> " " @@ -228,7 +237,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do test "returns error if matching domain is not verified", %{integration: integration} do domain = generate_domain() - {:ok, _sso_domain} = SSO.Domains.add(integration, domain) + {:ok, _sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) assert {:error, :not_found} = SSO.Domains.lookup(domain) end @@ -243,7 +252,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do describe "check_can_remove/1" do setup %{integration: integration} do domain = generate_domain() - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) {:ok, domain: domain, sso_domain: sso_domain} end @@ -254,7 +263,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do sso_domain: sso_domain } do other_domain = generate_domain() - {:ok, other_sso_domain} = SSO.Domains.add(integration, other_domain) + {:ok, other_sso_domain} = SSO.Domains.add(integration, other_domain, skip_checks?: true) _other_sso_domain = SSO.Domains.verify(other_sso_domain, skip_checks?: true) other_identity = new_identity("Mary Goodwill", "mary@" <> other_domain, integration) {:ok, _, _, _other_sso_user} = SSO.provision_user(other_identity) @@ -296,7 +305,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do describe "remove/1,2" do setup %{integration: integration} do domain = generate_domain() - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) {:ok, domain: domain, sso_domain: sso_domain} end diff --git a/test/plausible/auth/sso_test.exs b/test/plausible/auth/sso_test.exs index 6665f88f60bb..b8d90ab2e81f 100644 --- a/test/plausible/auth/sso_test.exs +++ b/test/plausible/auth/sso_test.exs @@ -275,7 +275,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, team: team, integration: integration, domain: domain, sso_domain: sso_domain} @@ -312,7 +312,10 @@ defmodule Plausible.Auth.SSOTest do other_team = new_site().team other_integration = SSO.initiate_saml_integration(other_team) other_domain = "other-example-#{Enum.random(1..10_000)}.com" - {:ok, other_sso_domain} = SSO.Domains.add(other_integration, other_domain) + + {:ok, other_sso_domain} = + SSO.Domains.add(other_integration, other_domain, skip_checks?: true) + _other_sso_domain = SSO.Domains.verify(other_sso_domain, skip_checks?: true) identity = new_identity("Jane Sculley", "jane@" <> domain, other_integration) @@ -551,7 +554,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity("Clarence Fortridge", "clarence@" <> domain, integration) @@ -678,7 +681,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -703,7 +706,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -725,7 +728,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) assert {:error, :no_sso_user} = SSO.check_force_sso(team, :all_but_owners) @@ -743,7 +746,7 @@ defmodule Plausible.Auth.SSOTest do domain = "example-#{Enum.random(1..10_000)}.com" # Unverified domain - {:ok, _sso_domain} = SSO.Domains.add(integration, domain) + {:ok, _sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) assert {:error, :no_verified_domain} = SSO.check_force_sso(team, :all_but_owners) end @@ -789,7 +792,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -825,7 +828,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -865,7 +868,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -893,7 +896,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -918,7 +921,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -946,7 +949,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -970,7 +973,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -1003,7 +1006,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -1028,7 +1031,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) # Provisioned SSO identity @@ -1057,8 +1060,8 @@ defmodule Plausible.Auth.SSOTest do domain1 = "example-#{Enum.random(1..10_000)}.com" domain2 = "test-#{Enum.random(1..10_000)}.com" - {:ok, d1} = SSO.Domains.add(integration, domain1) - {:ok, d2} = SSO.Domains.add(integration, domain2) + {:ok, d1} = SSO.Domains.add(integration, domain1, skip_checks?: true) + {:ok, d2} = SSO.Domains.add(integration, domain2, skip_checks?: true) {:ok, _} = SSO.Domains.start_verification(domain1) {:ok, _} = SSO.Domains.start_verification(domain2) @@ -1094,7 +1097,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity("Test User", "test@" <> domain, integration) @@ -1130,7 +1133,7 @@ defmodule Plausible.Auth.SSOTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, diff --git a/test/plausible_web/controllers/sso_controller_sync_test.exs b/test/plausible_web/controllers/sso_controller_sync_test.exs index e9e3306a15f5..fcada7e44366 100644 --- a/test/plausible_web/controllers/sso_controller_sync_test.exs +++ b/test/plausible_web/controllers/sso_controller_sync_test.exs @@ -78,7 +78,7 @@ defmodule PlausibleWeb.SSOControllerSyncTest do domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, team: team, integration: integration, domain: domain, sso_domain: sso_domain} @@ -167,7 +167,7 @@ defmodule PlausibleWeb.SSOControllerSyncTest do domain = "plausible.test" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, root_node} = @assertion |> SimpleXml.parse() diff --git a/test/plausible_web/controllers/sso_controller_test.exs b/test/plausible_web/controllers/sso_controller_test.exs index 487c4199fb71..874dc7a8945e 100644 --- a/test/plausible_web/controllers/sso_controller_test.exs +++ b/test/plausible_web/controllers/sso_controller_test.exs @@ -14,7 +14,7 @@ defmodule PlausibleWeb.SSOControllerTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, diff --git a/test/plausible_web/live/sso_management_test.exs b/test/plausible_web/live/sso_management_test.exs index f803984dbee2..aeb8f64b4c3f 100644 --- a/test/plausible_web/live/sso_management_test.exs +++ b/test/plausible_web/live/sso_management_test.exs @@ -6,6 +6,7 @@ defmodule PlausibleWeb.Live.SSOMangementTest do on_ee do use Bamboo.Test, shared: true + use Plausible.Test.Support.DNS import Phoenix.LiveViewTest @@ -64,6 +65,14 @@ defmodule PlausibleWeb.Live.SSOMangementTest do describe "live" do setup [:create_user, :log_in, :create_team, :add_plan, :setup_team] + setup do + # Some tests below add a domain through the real LiveView form (not + # a direct SSO.Domains.add/3 call we could pass skip_checks?: true + # to), which goes through actual DNS-backed domain validation. + stub_dns() + :ok + end + test "init setup - basic walk through", %{conn: conn} do {lv, _html} = get_lv(conn) lv |> element("form#sso-init") |> render_submit() @@ -288,7 +297,7 @@ defmodule PlausibleWeb.Live.SSOMangementTest do defp setup_integration(team, domain) do integration = SSO.initiate_saml_integration(team) - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, integration} = diff --git a/test/workers/sso_domain_verification_worker_test.exs b/test/workers/sso_domain_verification_worker_test.exs index d75a013cf69b..4191946749ac 100644 --- a/test/workers/sso_domain_verification_worker_test.exs +++ b/test/workers/sso_domain_verification_worker_test.exs @@ -38,7 +38,7 @@ defmodule Plausible.Auth.SSO.Domain.Verification.WorkerTest do team = new_site(owner: owner).team integration = SSO.initiate_saml_integration(team) domain = "#{Enum.random(1..10_000)}.example.com" - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) {:ok, owner: owner, From 31b23305b1b4fb0e5b899d9292af348a8befa7f9 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 08:22:57 +0200 Subject: [PATCH 12/15] Update remaining tests to skip checks provisioning SSO --- .../controllers/auth_controller_test.exs | 6 +++--- .../live/customer_support/teams_test.exs | 8 ++++---- test/plausible_web/live/customer_support_test.exs | 4 ++-- test/plausible_web/user_auth_test.exs | 12 ++++++------ test/support/test_utils.ex | 4 +++- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs index 78ae1fc00979..26208da92c0e 100644 --- a/test/plausible_web/controllers/auth_controller_test.exs +++ b/test/plausible_web/controllers/auth_controller_test.exs @@ -635,7 +635,7 @@ defmodule PlausibleWeb.AuthControllerTest do # Setup SSO integration = Auth.SSO.initiate_saml_integration(team) - {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") + {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com", skip_checks?: true) _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(owner.name, owner.email, integration) @@ -659,7 +659,7 @@ defmodule PlausibleWeb.AuthControllerTest do # Setup SSO integration = Auth.SSO.initiate_saml_integration(team) - {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") + {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com", skip_checks?: true) _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(member.name, member.email, integration) @@ -683,7 +683,7 @@ defmodule PlausibleWeb.AuthControllerTest do # Setup SSO integration = Auth.SSO.initiate_saml_integration(team) - {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") + {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com", skip_checks?: true) _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(member.name, member.email, integration) diff --git a/test/plausible_web/live/customer_support/teams_test.exs b/test/plausible_web/live/customer_support/teams_test.exs index 2ffe3743f702..214be799ff04 100644 --- a/test/plausible_web/live/customer_support/teams_test.exs +++ b/test/plausible_web/live/customer_support/teams_test.exs @@ -911,8 +911,8 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do integration = SSO.initiate_saml_integration(team) - SSO.Domains.add(integration, "sso1.example.com") - SSO.Domains.add(integration, "sso2.example.com") + SSO.Domains.add(integration, "sso1.example.com", skip_checks?: true) + SSO.Domains.add(integration, "sso2.example.com", skip_checks?: true) {:ok, lv, _html} = live(conn, open_team(team.id, tab: :sso)) @@ -927,7 +927,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do test "delete domain", %{conn: conn, user: user} do team = team_of(user) integration = SSO.initiate_saml_integration(team) - {:ok, domain} = SSO.Domains.add(integration, "sso1.example.com") + {:ok, domain} = SSO.Domains.add(integration, "sso1.example.com", skip_checks?: true) {:ok, lv, _html} = live(conn, open_team(team.id, tab: :sso)) lv |> element("button#remove-sso-domain-#{domain.identifier}") |> render_click() @@ -937,7 +937,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do test "deprovisioning users", %{conn: conn, user: user} do team = team_of(user) |> Plausible.Teams.complete_setup() integration = SSO.initiate_saml_integration(team) - {:ok, sso_domain} = SSO.Domains.add(integration, "example.com") + {:ok, sso_domain} = SSO.Domains.add(integration, "example.com", skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) diff --git a/test/plausible_web/live/customer_support_test.exs b/test/plausible_web/live/customer_support_test.exs index 1e17697ad559..ff65980336ef 100644 --- a/test/plausible_web/live/customer_support_test.exs +++ b/test/plausible_web/live/customer_support_test.exs @@ -128,7 +128,7 @@ defmodule PlausibleWeb.Live.CustomerSupportTest do team3 = new_user(team: [name: "Team Three"]) |> team_of() integration = SSO.initiate_saml_integration(team2) - SSO.Domains.add(integration, "some-sso.example.com") + SSO.Domains.add(integration, "some-sso.example.com", skip_checks?: true) SSO.initiate_saml_integration(team3) {:ok, lv, _html} = live(conn, @cs_index) @@ -146,7 +146,7 @@ defmodule PlausibleWeb.Live.CustomerSupportTest do team3 = new_user(team: [name: "Team Three"]) |> team_of() integration = SSO.initiate_saml_integration(team2) - SSO.Domains.add(integration, "some-sso.example.com") + SSO.Domains.add(integration, "some-sso.example.com", skip_checks?: true) {:ok, lv, _html} = live(conn, @cs_index) diff --git a/test/plausible_web/user_auth_test.exs b/test/plausible_web/user_auth_test.exs index 657c23edab5b..67c27883ac14 100644 --- a/test/plausible_web/user_auth_test.exs +++ b/test/plausible_web/user_auth_test.exs @@ -49,7 +49,7 @@ defmodule PlausibleWeb.UserAuthTest do user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!() add_member(team, user: user, role: :editor) - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(user.name, user.email, integration) @@ -78,7 +78,7 @@ defmodule PlausibleWeb.UserAuthTest do integration = SSO.initiate_saml_integration(team) domain = "example-#{Enum.random(1..10_000)}.com" user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!() - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(user.name, user.email, integration) @@ -108,7 +108,7 @@ defmodule PlausibleWeb.UserAuthTest do user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!() add_member(team, user: user, role: :editor) - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(user.name, user.email, integration) @@ -165,7 +165,7 @@ defmodule PlausibleWeb.UserAuthTest do add_member(team, role: :viewer) add_member(team, role: :viewer) - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity("Jane Doe", "jane@" <> domain, integration) @@ -200,7 +200,7 @@ defmodule PlausibleWeb.UserAuthTest do another_team = new_site().team |> Plausible.Teams.complete_setup() add_member(another_team, user: user, role: :viewer) - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(user.name, user.email, integration) @@ -229,7 +229,7 @@ defmodule PlausibleWeb.UserAuthTest do # personal team with site created new_site(owner: user) - {:ok, sso_domain} = SSO.Domains.add(integration, domain) + {:ok, sso_domain} = SSO.Domains.add(integration, domain, skip_checks?: true) _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(user.name, user.email, integration) diff --git a/test/support/test_utils.ex b/test/support/test_utils.ex index b4a7266b9f2e..f48c6bfbf05e 100644 --- a/test/support/test_utils.ex +++ b/test/support/test_utils.ex @@ -117,7 +117,9 @@ defmodule Plausible.TestUtils do team = Plausible.Teams.complete_setup(team) integration = SSO.initiate_saml_integration(team) - {:ok, sso_domain} = SSO.Domains.add(integration, ctx[:domain] || "example.com") + {:ok, sso_domain} = + SSO.Domains.add(integration, ctx[:domain] || "example.com", skip_checks?: true) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, team: team, sso_integration: integration, sso_domain: sso_domain} From 329b86e9609b8adc1d693f8ee35838c702178e4e Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 08:43:17 +0200 Subject: [PATCH 13/15] SSRF-aware plausible installation support --- .../installation_support/checks/url.ex | 44 +++----- .../installation_support/checks/url_test.exs | 101 ++++++++++++------ .../detection/checks_observability_test.exs | 16 +-- .../detection/checks_test.exs | 2 +- .../checks_observability_test.exs | 4 +- .../verification/checks_test.exs | 6 +- .../plausible_web/live/change_domain_test.exs | 8 +- test/plausible_web/live/installation_test.exs | 54 +++++----- test/plausible_web/live/verification_test.exs | 12 +-- 9 files changed, 132 insertions(+), 115 deletions(-) diff --git a/extra/lib/plausible/installation_support/checks/url.ex b/extra/lib/plausible/installation_support/checks/url.ex index 95dbd56fea1c..ea983c83e9d4 100644 --- a/extra/lib/plausible/installation_support/checks/url.ex +++ b/extra/lib/plausible/installation_support/checks/url.ex @@ -1,8 +1,9 @@ defmodule Plausible.InstallationSupport.Checks.Url do @moduledoc """ - Checks if site domain has an A record. - If not, checks if prepending `www.` helps, - because we have specifically requested customers to register the domain with `www.` prefix. + Checks if site domain resolves (either A or AAAA record) to a public address. + If not, checks if prepending `www.` helps, because we have specifically + requested customers to register the domain with `www.` prefix. + If not, skips all further checks. """ @@ -15,11 +16,11 @@ defmodule Plausible.InstallationSupport.Checks.Url do @spec perform(State.t(), Keyword.t()) :: State.t() def perform(%State{url: url} = state, _opts) when is_binary(url) do with {:ok, %URI{scheme: scheme} = uri} when scheme in ["http", "https"] <- URI.new(url), - :ok <- check_domain(uri.host) do + :ok <- dns_lookup(uri.host) do stripped_url = URI.to_string(%URI{uri | query: nil, fragment: nil}) %State{state | url: stripped_url} else - {:error, :no_a_record} -> + {:error, :resolve_host_error} -> put_diagnostics(%State{state | skip_further_checks?: true}, service_error: %{code: :domain_not_found} ) @@ -54,40 +55,19 @@ defmodule Plausible.InstallationSupport.Checks.Url do "www.#{domain_without_path}" ] |> Enum.reduce_while({:error, :domain_not_found}, fn d, _acc -> + # For local testing, run the server with ALLOW_RESERVED_IPS=true case dns_lookup(d) do :ok -> {:halt, {:ok, "https://" <> unsplit_domain(d, rest)}} - {:error, :no_a_record} -> {:cont, {:error, :domain_not_found}} + {:error, :resolve_host_error} -> {:cont, {:error, :domain_not_found}} end end) end - @spec dns_lookup(String.t()) :: :ok | {:error, :no_a_record} + @spec dns_lookup(String.t()) :: :ok | {:error, :resolve_host_error} defp dns_lookup(domain) do - lookup_timeout = 1_000 - resolve_timeout = 1_000 - - case Plausible.DnsLookup.impl().lookup( - to_charlist(domain), - :in, - :a, - [timeout: resolve_timeout], - lookup_timeout - ) do - [{a, b, c, d} | _] - when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) -> - :ok - - # this may mean timeout or no DNS record - [] -> - {:error, :no_a_record} - end - end - - defp check_domain(domain) do - if Application.get_env(:plausible, :environment) == "dev" and domain == "localhost" do - :ok - else - dns_lookup(domain) + case Plausible.SSRF.resolve_host(domain) do + {:ok, _ips} -> :ok + {:error, _reason} -> {:error, :resolve_host_error} end end diff --git a/test/plausible/installation_support/checks/url_test.exs b/test/plausible/installation_support/checks/url_test.exs index 28ac54b9986e..07f0cd05109c 100644 --- a/test/plausible/installation_support/checks/url_test.exs +++ b/test/plausible/installation_support/checks/url_test.exs @@ -7,9 +7,9 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do use Plausible.DataCase, async: true on_ee do - import Mox + use Plausible.Test.Support.DNS - alias Plausible.InstallationSupport.{State, Checks, Verification} + alias Plausible.InstallationSupport.{Checks, State, Verification} @check Checks.Url @@ -20,14 +20,7 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do {"plausible.io/sites", ~c"plausible.io"} ] do test "guesses 'https://#{site_domain}' if A-record is found for '#{site_domain}'" do - Plausible.DnsLookup.Mock - |> expect(:lookup, fn unquote(expected_lookup_domain), - _type, - _record, - _opts, - _timeout -> - [{192, 168, 1, 1}] - end) + expect_dns_lookup(unquote(expected_lookup_domain), [{93, 184, 216, 34}]) state = @check.perform( @@ -48,13 +41,8 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do test "guesses 'www.{domain}' if A record is not found for 'domain'" do site_domain = "example.com/any/deeper/path" - Plausible.DnsLookup.Mock - |> expect(:lookup, fn ~c"example.com", _type, _record, _opts, _timeout -> - [] - end) - |> expect(:lookup, fn ~c"www.example.com", _type, _record, _opts, _timeout -> - [{192, 168, 1, 2}] - end) + expect_dns_lookup("example.com", []) + expect_dns_lookup("www.example.com", [{93, 184, 216, 34}]) state = @check.perform( @@ -72,15 +60,53 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do end test "fails if no A-record is found for 'domain' or 'www.{domain}'" do - expected_lookups = 2 + domain = "any.example.com" + + expect_dns_lookup("any.example.com", []) + expect_dns_lookup("www.any.example.com", []) + + state = + @check.perform( + %State{ + data_domain: domain, + url: nil, + diagnostics: %Verification.Diagnostics{} + }, + [] + ) + + assert state.url == nil + assert state.diagnostics.service_error == %{code: :domain_not_found} + assert state.skip_further_checks? + end + + test "fails if 'domain' only resolves to a private/reserved address" do + domain = "any.example.com" + + expect_dns_lookup("any.example.com", [{192, 168, 1, 1}]) + expect_dns_lookup("www.any.example.com", []) + + state = + @check.perform( + %State{ + data_domain: domain, + url: nil, + diagnostics: %Verification.Diagnostics{} + }, + [] + ) - Plausible.DnsLookup.Mock - |> expect(:lookup, expected_lookups, fn _domain, _type, _record, _opts, _timeout -> - [] - end) + assert state.url == nil + assert state.diagnostics.service_error == %{code: :domain_not_found} + assert state.skip_further_checks? + end + test "fails if 'domain' only resolves to a private/reserved AAAA address" do domain = "any.example.com" + expect_dns_lookup("any.example.com", [], [{0xFC00, 0, 0, 0, 0, 0, 0, 1}]) + expect_dns_lookup("www.any.example.com", []) + state = @check.perform( %State{ @@ -102,10 +128,7 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do site_domain = "example-com-rollup" url = "https://blog.example.com/recipes?foo=bar#baz" - Plausible.DnsLookup.Mock - |> expect(:lookup, fn ~c"blog.example.com", _type, _record, _opts, _timeout -> - [{192, 168, 1, 1}] - end) + expect_dns_lookup("blog.example.com", [{93, 184, 216, 34}]) state = @check.perform( @@ -142,10 +165,28 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do site_domain = "example-com-rollup" url = "https://example.com/archives/news?p=any#fragment" - Plausible.DnsLookup.Mock - |> expect(:lookup, fn ~c"example.com", _type, _record, _opts, _timeout -> - [] - end) + expect_dns_lookup("example.com", []) + + state = + @check.perform( + %State{ + data_domain: site_domain, + url: url, + diagnostics: %Verification.Diagnostics{} + }, + [] + ) + + assert state.url == url + assert state.diagnostics.service_error == %{code: :domain_not_found} + assert state.skip_further_checks? + end + + test "rejects urls whose host only resolves to a private/reserved address" do + site_domain = "example-com-rollup" + url = "https://example.com/archives/news?p=any#fragment" + + expect_dns_lookup("example.com", [{127, 0, 0, 1}]) state = @check.perform( diff --git a/test/plausible/installation_support/detection/checks_observability_test.exs b/test/plausible/installation_support/detection/checks_observability_test.exs index 6da95d9bf4e2..901df7c9fc95 100644 --- a/test/plausible/installation_support/detection/checks_observability_test.exs +++ b/test/plausible/installation_support/detection/checks_observability_test.exs @@ -34,7 +34,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksObservabilityTest do end test "successful detection -> no logs, no sentry, telemetry :success" do - stub_lookup_a_records(@expected_domain) + stub_dns() detection_stub = json_response_detection_stub(%{ @@ -59,7 +59,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksObservabilityTest do end test "domain not found -> customer website issue" do - stub_lookup_a_records(@expected_domain, []) + expect_dns_unresolvable(@expected_domain) detection_counter = Req.Test.stub(Plausible.InstallationSupport.Checks.Detection, fn _conn -> @@ -88,7 +88,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksObservabilityTest do for msg <- ["Execution context destroyed", "net::ERR_CONNECTION_REFUSED"] do test "failure due to a known :browserless_client_error (#{msg}) -> customer website issue" do - stub_lookup_a_records(@expected_domain) + stub_dns() detection_stub = json_response_detection_stub(%{ @@ -112,7 +112,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksObservabilityTest do end test "failure due to an unknown :browserless_client_error -> unknown failure" do - stub_lookup_a_records(@expected_domain) + stub_dns() detection_stub = json_response_detection_stub(%{ @@ -136,7 +136,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksObservabilityTest do end test "failure hitting the catch-all interpret clause -> unknown failure" do - stub_lookup_a_records(@expected_domain) + stub_dns() detection_stub = fn conn -> Req.Test.transport_error(conn, :econnrefused) @@ -158,7 +158,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksObservabilityTest do end test "failure due to a flaky browserless issue -> browserless issue" do - stub_lookup_a_records(@expected_domain) + stub_dns() detection_stub = fn conn -> conn @@ -182,7 +182,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksObservabilityTest do end test "failure due to a browserless timeout -> browserless issue" do - stub_lookup_a_records(@expected_domain) + stub_dns() detection_stub = fn conn -> Req.Test.transport_error(conn, :timeout) @@ -203,7 +203,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksObservabilityTest do end test "failure due to internal_check_timeout -> browserless issue" do - stub_lookup_a_records(@expected_domain) + stub_dns() detection_stub = fn _conn -> # times out diff --git a/test/plausible/installation_support/detection/checks_test.exs b/test/plausible/installation_support/detection/checks_test.exs index 8f362a6425af..70a3064a47bd 100644 --- a/test/plausible/installation_support/detection/checks_test.exs +++ b/test/plausible/installation_support/detection/checks_test.exs @@ -17,7 +17,7 @@ defmodule Plausible.InstallationSupport.Detection.ChecksTest do test "handles wordpress detection, retrying on 429" do url_to_verify = nil test = self() - stub_lookup_a_records(@expected_domain) + stub_dns() get_context_from_body = fn conn -> {:ok, body, _conn} = Plug.Conn.read_body(conn) diff --git a/test/plausible/installation_support/verification/checks_observability_test.exs b/test/plausible/installation_support/verification/checks_observability_test.exs index 0e7bbd5a951c..5464c08c019a 100644 --- a/test/plausible/installation_support/verification/checks_observability_test.exs +++ b/test/plausible/installation_support/verification/checks_observability_test.exs @@ -128,7 +128,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksObservabilityTest do Process.sleep(1000) end - stub_lookup_a_records(@expected_domain) + stub_dns() stub_verification_result(verification_stub) state = @@ -161,7 +161,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksObservabilityTest do end defp run_checks(verification_stub) do - stub_lookup_a_records(@expected_domain) + stub_dns() stub_verification_result(verification_stub) Checks.run(@url_to_verify, @expected_domain, "manual", diff --git a/test/plausible/installation_support/verification/checks_test.exs b/test/plausible/installation_support/verification/checks_test.exs index a0b7ef39a8dd..01e01e4d7910 100644 --- a/test/plausible/installation_support/verification/checks_test.exs +++ b/test/plausible/installation_support/verification/checks_test.exs @@ -16,7 +16,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do describe "URL check" do test "returns error when DNS check fails with domain not found error, offers custom URL input" do - stub_lookup_a_records(@expected_domain, []) + expect_dns_unresolvable(@expected_domain) assert_matches %Result{ ok?: false, @@ -43,7 +43,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do test "returns error when DNS check fails with invalid URL error, offers custom URL input" do url_to_verify = "file://#{@expected_domain}" - stub_lookup_a_records(@expected_domain, []) + expect_dns_unresolvable(@expected_domain) assert_matches %Result{ ok?: false, @@ -539,7 +539,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do installation_type = Keyword.get(opts, :installation_type, "manual") expected_req_count = Keyword.get(opts, :expected_req_count) - stub_lookup_a_records(@expected_domain) + stub_dns() if is_integer(expected_req_count) do counter = :atomics.new(1, []) diff --git a/test/plausible_web/live/change_domain_test.exs b/test/plausible_web/live/change_domain_test.exs index 80c5a430c687..84f902d3b4c8 100644 --- a/test/plausible_web/live/change_domain_test.exs +++ b/test/plausible_web/live/change_domain_test.exs @@ -4,7 +4,7 @@ defmodule PlausibleWeb.Live.ChangeDomainTest do import Phoenix.LiveViewTest on_ee do - import Mox + use Plausible.Test.Support.DNS end alias Plausible.Repo @@ -14,11 +14,7 @@ defmodule PlausibleWeb.Live.ChangeDomainTest do on_ee do setup do - # mock all domains resolve - Plausible.DnsLookup.Mock - |> stub(:lookup, fn _domain, _type, _record, _opts, _timeout -> - [{192, 168, 1, 2}] - end) + stub_dns() # Stub detection by default to prevent async task race conditions # Tests that need specific detection results can override this stub diff --git a/test/plausible_web/live/installation_test.exs b/test/plausible_web/live/installation_test.exs index a3ac3aae5f06..84e379b321fa 100644 --- a/test/plausible_web/live/installation_test.exs +++ b/test/plausible_web/live/installation_test.exs @@ -37,7 +37,7 @@ defmodule PlausibleWeb.Live.InstallationTest do describe "LiveView" do @tag :ee_only test "detects installation type when mounted", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_wordpress() {lv, _} = get_lv(conn, site) @@ -51,7 +51,7 @@ defmodule PlausibleWeb.Live.InstallationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() {lv, _} = get_lv(conn, site, "?type=wordpress") @@ -65,7 +65,7 @@ defmodule PlausibleWeb.Live.InstallationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_wordpress() {lv, _} = get_lv(conn, site, "?type=gtm") @@ -79,7 +79,7 @@ defmodule PlausibleWeb.Live.InstallationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_wordpress() {lv, _} = get_lv(conn, site, "?type=npm") @@ -93,7 +93,7 @@ defmodule PlausibleWeb.Live.InstallationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_wordpress() {lv, _} = get_lv(conn, site, "?type=manual") @@ -104,7 +104,7 @@ defmodule PlausibleWeb.Live.InstallationTest do @tag :ee_only test "allows switching between installation tabs (EE)", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() {lv, _html} = get_lv(conn, site, "?type=manual") @@ -151,7 +151,7 @@ defmodule PlausibleWeb.Live.InstallationTest do test "manual installations has script snippet with expected ID", %{conn: conn, site: site} do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -171,7 +171,7 @@ defmodule PlausibleWeb.Live.InstallationTest do test "manual installation shows optional measurements", %{conn: conn, site: site} do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -187,7 +187,7 @@ defmodule PlausibleWeb.Live.InstallationTest do test "manual installation shows advanced options in disclosure", %{conn: conn, site: site} do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -208,7 +208,7 @@ defmodule PlausibleWeb.Live.InstallationTest do site: site } do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -250,7 +250,7 @@ defmodule PlausibleWeb.Live.InstallationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() {lv, _html} = get_lv(conn, site, "?type=#{unquote(type)}") @@ -305,7 +305,7 @@ defmodule PlausibleWeb.Live.InstallationTest do test "404 goal gets created regardless of user options", %{conn: conn, site: site} do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -336,7 +336,7 @@ defmodule PlausibleWeb.Live.InstallationTest do site: site } do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -367,7 +367,7 @@ defmodule PlausibleWeb.Live.InstallationTest do @tag :ee_only test "detected WordPress installation shows special message", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_wordpress() {lv, _} = get_lv(conn, site) @@ -379,7 +379,7 @@ defmodule PlausibleWeb.Live.InstallationTest do @tag :ee_only test "if ratelimit for detection is exceeded, does not make detection request and falls back to recommending manual installation", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() # exceed the rate limit for site detection Plausible.RateLimit.check_rate( @@ -403,7 +403,7 @@ defmodule PlausibleWeb.Live.InstallationTest do @tag :ee_only test "detected GTM installation shows special message", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_gtm() {lv, _} = get_lv(conn, site) @@ -416,7 +416,7 @@ defmodule PlausibleWeb.Live.InstallationTest do @tag :ee_only test "detected NPM installation shows npm tab", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_result(%{ "v1Detected" => false, @@ -437,7 +437,7 @@ defmodule PlausibleWeb.Live.InstallationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual_with_v1() {lv, _} = get_lv(conn, site, "?type=manual") @@ -461,7 +461,7 @@ defmodule PlausibleWeb.Live.InstallationTest do site: site } do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_wordpress_with_v1() end @@ -477,7 +477,7 @@ defmodule PlausibleWeb.Live.InstallationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain, []) + expect_dns_unresolvable(site.domain) ExUnit.CaptureLog.capture_log(fn -> {lv, _} = get_lv(conn, site) @@ -495,7 +495,7 @@ defmodule PlausibleWeb.Live.InstallationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_error() ExUnit.CaptureLog.capture_log(fn -> @@ -523,7 +523,7 @@ defmodule PlausibleWeb.Live.InstallationTest do add_guest(site, user: user, role: :viewer) on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -538,7 +538,7 @@ defmodule PlausibleWeb.Live.InstallationTest do add_guest(site, user: user, role: :editor) on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -556,7 +556,7 @@ defmodule PlausibleWeb.Live.InstallationTest do site: site } do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -571,7 +571,7 @@ defmodule PlausibleWeb.Live.InstallationTest do site: site } do on_ee do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_manual() end @@ -586,7 +586,7 @@ defmodule PlausibleWeb.Live.InstallationTest do @describetag :ee_only test "When GTM + Wordpress detected, GTM takes precedence", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_result(%{ "v1Detected" => false, @@ -616,7 +616,7 @@ defmodule PlausibleWeb.Live.InstallationTest do form_submissions: true }) - stub_lookup_a_records(site.domain) + stub_dns() stub_detection_wordpress() {lv, _} = get_lv(conn, site, "?flow=review") diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 4580e932e96b..1e2928306459 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -42,7 +42,7 @@ defmodule PlausibleWeb.Live.VerificationTest do describe "LiveView" do @tag :ee_only test "LiveView mounts", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_verification_result(%{ "completed" => false, @@ -65,7 +65,7 @@ defmodule PlausibleWeb.Live.VerificationTest do @tag :ee_only test "from custom URL input form to verification", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_verification_result(%{ "completed" => false, @@ -94,7 +94,7 @@ defmodule PlausibleWeb.Live.VerificationTest do @tag :ee_only test "eventually verifies installation", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_verification_result(%{ "completed" => true, @@ -132,7 +132,7 @@ defmodule PlausibleWeb.Live.VerificationTest do build(:pageview) ]) - stub_lookup_a_records(site.domain) + stub_dns() stub_verification_result(%{ "completed" => true, @@ -165,7 +165,7 @@ defmodule PlausibleWeb.Live.VerificationTest do end test "will redirect when first pageview arrives", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_verification_result(%{ "completed" => true, @@ -242,7 +242,7 @@ defmodule PlausibleWeb.Live.VerificationTest do conn: conn, site: site } do - stub_lookup_a_records(site.domain) + stub_dns() stub_verification_result(%{ "completed" => true, From 0b5e22b5062fc16c209eb84f4bd4ff648ea8c87d Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 22:23:03 +0200 Subject: [PATCH 14/15] Update lib/plausible/ssrf.ex Co-authored-by: Adrian Gruntkowski --- lib/plausible/ssrf.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible/ssrf.ex b/lib/plausible/ssrf.ex index cb11f144cf8d..a0585879bf05 100644 --- a/lib/plausible/ssrf.ex +++ b/lib/plausible/ssrf.ex @@ -24,7 +24,7 @@ defmodule Plausible.SSRF do @doc """ Resolves bare hostname or literal IP and returns its address(es), - only if every all are allowed + only if all are allowed """ @spec resolve_host(String.t()) :: {:ok, [:inet.ip_address()]} | {:error, error_reason()} def resolve_host(host) when is_binary(host) do From ba47ef25981e31858dc45b9c88946973aae59a65 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 22 Jul 2026 07:02:14 +0200 Subject: [PATCH 15/15] case -> if --- lib/ip/tools.ex | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/ip/tools.ex b/lib/ip/tools.ex index e550802e2f58..e55a0709bfcc 100644 --- a/lib/ip/tools.ex +++ b/lib/ip/tools.ex @@ -52,18 +52,14 @@ defmodule Plausible.IP.Tools do end def allowed?(ip) when is_tuple(ip) and tuple_size(ip) in [4, 8] do - case allow_reserved_ips?() do - true -> - if reserved?(ip) do - Logger.warning( - "IP #{inspect(ip)} forcibly allowed due to :allow_reserved_ips? env set. " - ) - end + if allow_reserved_ips?() do + if reserved?(ip) do + Logger.warning("IP #{inspect(ip)} forcibly allowed due to :allow_reserved_ips? env set. ") + end - true - - false -> - not reserved?(ip) + true + else + not reserved?(ip) end end