From 329b86e9609b8adc1d693f8ee35838c702178e4e Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jul 2026 08:43:17 +0200 Subject: [PATCH] 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,