From ddfa081e39207e066b9d63d0fce2ccef72653951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Mon, 15 Jun 2026 23:09:06 +0100 Subject: [PATCH] User-controlled hostnames (SSO domains, site data_domains, custom verification URLs) get dereferenced by an outbound HTTP client. A public hostname can resolve to a private/loopback/link-local address (e.g. cloud metadata at 169.254.169.254) and turn a verification fetch into an internal request. Add Plausible.SSRFProtection to classify resolved IPs and guard the request path in three layers: - syntactic check at add-time rejects literal IPs and dot-less hosts - resolve-time check rejects hosts whose A or AAAA records are internal - a Req response step refuses redirects whose Location is internal IPv6 classification re-checks IPv4 embedded in mapped, IPv4-compatible, NAT64, and 6to4 addresses. --- extra/lib/plausible/auth/sso/domain.ex | 17 ++- .../plausible/auth/sso/domain/verification.ex | 95 +++++++++++-- .../installation_support/checks/url.ex | 31 +++-- lib/plausible/ssrf_protection.ex | 98 +++++++++++++ .../auth/sso/domain/verification_test.exs | 56 ++++++++ test/plausible/auth/sso/domains_test.exs | 25 ++++ .../installation_support/checks/url_test.exs | 129 ++++++++++++++++-- test/plausible/ssrf_protection_test.exs | 97 +++++++++++++ .../plausible_web/live/change_domain_test.exs | 5 +- test/support/dns.ex | 11 +- 10 files changed, 527 insertions(+), 37 deletions(-) create mode 100644 lib/plausible/ssrf_protection.ex create mode 100644 test/plausible/ssrf_protection_test.exs diff --git a/extra/lib/plausible/auth/sso/domain.ex b/extra/lib/plausible/auth/sso/domain.ex index 572026eba564..d3b5c7a76ab2 100644 --- a/extra/lib/plausible/auth/sso/domain.ex +++ b/extra/lib/plausible/auth/sso/domain.ex @@ -89,13 +89,28 @@ defmodule Plausible.Auth.SSO.Domain do 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 + public_hostname?(host) _ -> false end end + # SSO ownership is proven against a domain name, never a bare IP or an + # internal single-label host. Rejecting literal IPs (e.g. `127.0.0.1`, + # `169.254.169.254`, `10.0.0.5`) and dot-less hostnames (`localhost`, + # `consul`, `redis`) keeps obviously-internal targets out of the + # verification request path. Public hostnames pointing at private IPs are + # caught later, at request time, by `Verification`. + defp public_hostname?(host) do + case :inet.parse_address(to_charlist(host)) do + {:ok, _ip} -> false + # A trailing root dot doesn't make a single-label host multi-label, so + # strip it before requiring a label separator (rejects `localhost.`). + {:error, _} -> host |> String.trim_trailing(".") |> String.contains?(".") + end + end + defp normalize_domain(changeset, field) do if domain = get_change(changeset, field) do # We try to clear the usual copy-paste prefixes. diff --git a/extra/lib/plausible/auth/sso/domain/verification.ex b/extra/lib/plausible/auth/sso/domain/verification.ex index 77b64102d6fa..95cad3187ba4 100644 --- a/extra/lib/plausible/auth/sso/domain/verification.ex +++ b/extra/lib/plausible/auth/sso/domain/verification.ex @@ -41,13 +41,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)) - - case resp do - %Req.Response{body: body} - when is_binary(body) -> - String.trim(body) == domain_identifier + with :ok <- safe_to_request(sso_domain, url_override), + %Req.Response{body: body} when is_binary(body) <- + run_request(url_override || "https://" <> Path.join(sso_domain, @prefix)) do + String.trim(body) == domain_identifier + else _ -> false end @@ -57,7 +56,8 @@ defmodule Plausible.Auth.SSO.Domain.Verification do def meta_tag(sso_domain, domain_identifier, opts \\ []) do url_override = Keyword.get(opts, :url_override) - with %Req.Response{body: body} = response when is_binary(body) <- + with :ok <- safe_to_request(sso_domain, url_override), + %Req.Response{body: body} = response when is_binary(body) <- run_request(url_override || "https://#{sso_domain}"), true <- html?(response), html <- LazyHTML.from_document(body), @@ -104,6 +104,50 @@ defmodule Plausible.Auth.SSO.Domain.Verification do |> String.contains?("text/html") end + @resolve_timeout 1_000 + + # When an explicit `url_override` is provided (test seam), the request + # targets a known local endpoint, so the SSRF guard is skipped. In + # production the host is always the user-controlled `sso_domain`: resolve it + # and refuse to issue the request if it points at an internal address. This + # closes the public-hostname -> private-IP vector that `valid_domain?/1` + # cannot catch with a purely syntactic check. + @spec safe_to_request(String.t(), String.t() | nil) :: :ok | :error + defp safe_to_request(_sso_domain, url_override) when is_binary(url_override), do: :ok + + defp safe_to_request(sso_domain, nil) do + if host_safe?(sso_domain), do: :ok, else: :error + end + + # Resolves both A and AAAA records and rejects the host if any resolved + # address is internal (or if it is a literal internal IP). AAAA is resolved + # as well as A because the HTTP client will happily connect over IPv6: a host + # with a public A record but an internal AAAA record would otherwise slip + # through. An unresolvable host is treated as unsafe. + defp host_safe?(host) when is_binary(host) and host != "" do + case :inet.parse_address(to_charlist(host)) do + {:ok, ip} -> not Plausible.SSRFProtection.internal_ip?(ip) + {:error, _} -> not Plausible.SSRFProtection.any_internal?(resolve(host)) + end + end + + defp host_safe?(_), do: false + + defp resolve(host) do + charlist = to_charlist(host) + lookup(charlist, :a) ++ lookup(charlist, :aaaa) + end + + defp lookup(charlist, type) do + Plausible.DnsLookup.impl().lookup( + charlist, + :in, + type, + [timeout: @resolve_timeout], + @resolve_timeout + ) + end + defp run_request(base_url) do fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] @@ -118,10 +162,45 @@ defmodule Plausible.Auth.SSO.Domain.Verification do fetch_body_opts ) - {_req, resp} = opts |> Req.new() |> Req.Request.run_request() + {_req, resp} = + opts + |> Req.new() + |> Req.Request.prepend_response_steps(plausible_ssrf_guard: &block_internal_redirect/1) + |> Req.Request.run_request() + resp end + # Req re-resolves DNS for every redirect hop, so the `safe_to_request/2` + # preflight only covers the first request. This response step runs before + # Req's built-in `:redirect` step and refuses to follow a 3xx whose + # `Location` resolves to an internal address, closing the + # public-domain -> redirect -> internal-host pivot. Redirects are still + # followed for legitimate targets (e.g. apex -> www, http -> https), which + # customers commonly rely on. This does not close DNS rebinding: the actual + # connection is made by Finch, which re-resolves the host independently. + defp block_internal_redirect({request, %Req.Response{status: status} = response}) + when status in [301, 302, 303, 307, 308] do + case Req.Response.get_header(response, "location") do + [location | _] -> + target_host = + request.url + |> URI.merge(URI.parse(location)) + |> Map.get(:host) + + if host_safe?(target_host) do + {request, response} + else + Req.Request.halt(request, %{response | body: ""}) + end + + [] -> + {request, response} + end + end + + defp block_internal_redirect(request_response), do: request_response + @after_compile __MODULE__ def __after_compile__(_env, _bytecode) do available_methods = Domain.verification_methods() diff --git a/extra/lib/plausible/installation_support/checks/url.ex b/extra/lib/plausible/installation_support/checks/url.ex index 95dbd56fea1c..002943968e4d 100644 --- a/extra/lib/plausible/installation_support/checks/url.ex +++ b/extra/lib/plausible/installation_support/checks/url.ex @@ -63,26 +63,33 @@ defmodule Plausible.InstallationSupport.Checks.Url do @spec dns_lookup(String.t()) :: :ok | {:error, :no_a_record} defp dns_lookup(domain) do - lookup_timeout = 1_000 - resolve_timeout = 1_000 + charlist = to_charlist(domain) - case Plausible.DnsLookup.impl().lookup( - to_charlist(domain), - :in, - :a, - [timeout: resolve_timeout], - lookup_timeout - ) do - [{a, b, c, d} | _] + case lookup(charlist, :a) do + [{a, b, c, d} | _] = a_records when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) -> - :ok + # Reject hosts resolving to private/internal addresses to prevent SSRF. + # AAAA is resolved as well: a host with a public A record but an + # internal AAAA record would otherwise be reachable over IPv6. Reported + # as `:no_a_record` so the host is treated as not publicly reachable + # without disclosing that an internal IP was detected. + if Plausible.SSRFProtection.any_internal?(a_records) or + Enum.any?(lookup(charlist, :aaaa), &Plausible.SSRFProtection.internal_ip?/1) do + {:error, :no_a_record} + else + :ok + end # this may mean timeout or no DNS record - [] -> + _ -> {:error, :no_a_record} end end + defp lookup(charlist, type) do + Plausible.DnsLookup.impl().lookup(charlist, :in, type, [timeout: 1_000], 1_000) + end + defp check_domain(domain) do if Application.get_env(:plausible, :environment) == "dev" and domain == "localhost" do :ok diff --git a/lib/plausible/ssrf_protection.ex b/lib/plausible/ssrf_protection.ex new file mode 100644 index 000000000000..485c841d916b --- /dev/null +++ b/lib/plausible/ssrf_protection.ex @@ -0,0 +1,98 @@ +defmodule Plausible.SSRFProtection do + @moduledoc """ + Helpers for protecting against Server-Side Request Forgery (SSRF). + + User-controlled hostnames (custom verification URLs, site `data_domain`s, + SSO domains) eventually get dereferenced by an outbound HTTP client or by + Browserless. Before that happens the resolved IP addresses must be checked: + a publicly resolvable hostname can point at a private, loopback, link-local + (cloud metadata at `169.254.169.254`), CGNAT, multicast, or otherwise + non-routable address and turn an innocent fetch into an internal request. + + This module only classifies addresses; DNS resolution stays with the caller + (`Plausible.DnsLookup`) so the logic remains pure and trivially testable. + + Note: classifying the resolved IP does not by itself close the DNS-rebinding + gap — a downstream consumer that re-resolves the host (e.g. Browserless) can + still be handed a different answer. Pinning the resolved IP or running the + consumer behind an egress filter is a deployment-level concern. + """ + + import Bitwise + + @type ip :: :inet.ip4_address() | :inet.ip6_address() + + @doc """ + Returns `true` when `ip` is NOT a publicly routable unicast address and must + therefore never be the target of an outbound request. + """ + @spec internal_ip?(ip()) :: boolean() + def internal_ip?({a, b, c, d}) + when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do + internal_ipv4?(a, b, c, d) + end + + def internal_ip?({a, b, c, d, e, f, g, h}) + when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) and + is_integer(e) and is_integer(f) and is_integer(g) and is_integer(h) do + internal_ipv6?({a, b, c, d, e, f, g, h}) + end + + @doc """ + Returns `true` when `ips` is empty or any address in it is internal. + + A host must be rejected if *any* of its resolved addresses is internal, + because the HTTP client may connect to any of them. An empty list (no + record / lookup failure) is treated as unsafe. + """ + @spec any_internal?([ip()]) :: boolean() + def any_internal?([]), do: true + def any_internal?(ips) when is_list(ips), do: Enum.any?(ips, &internal_ip?/1) + + # IPv4 ------------------------------------------------------------------- + + defp internal_ipv4?(0, _, _, _), do: true + defp internal_ipv4?(10, _, _, _), do: true + defp internal_ipv4?(127, _, _, _), do: true + defp internal_ipv4?(100, b, _, _) when b in 64..127, do: true + defp internal_ipv4?(169, 254, _, _), do: true + defp internal_ipv4?(172, b, _, _) when b in 16..31, do: true + defp internal_ipv4?(192, 0, 0, _), do: true + defp internal_ipv4?(192, 168, _, _), do: true + defp internal_ipv4?(198, b, _, _) when b in 18..19, do: true + # 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, 255.255.255.255 broadcast + defp internal_ipv4?(a, _, _, _) when a >= 224, do: true + defp internal_ipv4?(_, _, _, _), do: false + + # IPv6 ------------------------------------------------------------------- + + defp internal_ipv6?({0, 0, 0, 0, 0, 0, 0, 0}), do: true + defp internal_ipv6?({0, 0, 0, 0, 0, 0, 0, 1}), do: true + + # IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded IPv4 address. + defp internal_ipv6?({0, 0, 0, 0, 0, 0xFFFF, g, h}), do: embedded_ipv4_internal?(g, h) + + # Deprecated IPv4-compatible (::a.b.c.d). `::` and `::1` are matched above; + # everything else in `::/96` carries an embedded IPv4, so re-check it. + defp internal_ipv6?({0, 0, 0, 0, 0, 0, g, h}), do: embedded_ipv4_internal?(g, h) + + # NAT64 well-known prefix (64:ff9b::a.b.c.d) — translated to the embedded + # IPv4 on the wire, so it can reach a private target on a NAT64 deployment. + defp internal_ipv6?({0x0064, 0xFF9B, 0, 0, 0, 0, g, h}), do: embedded_ipv4_internal?(g, h) + + # 6to4 (2002:a.b.c.d::/16) — embedded IPv4 sits in the 2nd and 3rd groups. + defp internal_ipv6?({0x2002, b, c, _, _, _, _, _}), do: embedded_ipv4_internal?(b, c) + + defp internal_ipv6?({a, _, _, _, _, _, _, _}) do + cond do + (a &&& 0xFE00) == 0xFC00 -> true + (a &&& 0xFFC0) == 0xFE80 -> true + (a &&& 0xFF00) == 0xFF00 -> true + true -> false + end + end + + defp embedded_ipv4_internal?(g, h) do + internal_ipv4?(g >>> 8, g &&& 0xFF, h >>> 8, h &&& 0xFF) + end +end diff --git a/test/plausible/auth/sso/domain/verification_test.exs b/test/plausible/auth/sso/domain/verification_test.exs index db52aebc6f7a..a9a0a1c8d31e 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 @@ -88,6 +90,60 @@ defmodule Plausible.Auth.SSO.Domain.VerificationTest do ) end + test "url refuses to request a domain resolving to an internal IP (SSRF)" do + stub_lookup_a_records("internal.attacker.example", [{169, 254, 169, 254}]) + + refute Verification.url("internal.attacker.example", "ex4mpl3") + end + + test "meta_tag refuses to request a domain resolving to an internal IP (SSRF)" do + stub_lookup_a_records("internal.attacker.example", [{10, 0, 0, 1}]) + + refute Verification.meta_tag("internal.attacker.example", "ex4mpl3") + end + + test "url refuses a domain whose AAAA record is internal (SSRF)" do + stub_lookup_a_records( + "dualstack.attacker.example", + [{93, 184, 216, 34}], + [{0, 0, 0, 0, 0, 0, 0, 1}] + ) + + refute Verification.url("dualstack.attacker.example", "ex4mpl3") + end + + test "url refuses to follow a redirect to an internal host (SSRF)", %{bypass: bypass} do + Bypass.expect(bypass, "GET", "/test", fn conn -> + conn + |> Conn.put_resp_header("location", "http://10.0.0.1/") + |> Conn.resp(302, "") + end) + + refute Verification.url("example.com", "ex4mpl3", + url_override: "http://localhost:#{bypass.port}/test" + ) + end + + test "url still follows a redirect to a public host", %{bypass: bypass} do + # The SSRF guard resolves redirect targets; stub the host (still hit by + # Finch's own resolution to reach Bypass) to a public address. + stub_lookup_a_records("localhost", [{93, 184, 216, 34}]) + + Bypass.expect(bypass, "GET", "/redirect", fn conn -> + conn + |> Conn.put_resp_header("location", "/verified") + |> Conn.resp(302, "") + end) + + Bypass.expect(bypass, "GET", "/verified", fn conn -> + Conn.resp(conn, 200, "ex4mpl3") + end) + + assert Verification.url("example.com", "ex4mpl3", + url_override: "http://localhost:#{bypass.port}/redirect" + ) + end + test "meta_tag succeeds in case of multiple matches", %{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..d95bf25cc352 100644 --- a/test/plausible/auth/sso/domains_test.exs +++ b/test/plausible/auth/sso/domains_test.exs @@ -80,6 +80,31 @@ defmodule Plausible.Auth.SSO.DomainsTest do assert {:error, _changeset} = SSO.Domains.add(integration, "invalid domain.com") end + test "rejects literal IPs and internal single-label hosts (SSRF)", %{ + integration: integration + } do + for input <- [ + "127.0.0.1", + "169.254.169.254", + "10.0.0.5", + "192.168.1.1", + "::1", + "localhost", + "localhost.", + "consul", + "consul.", + "redis" + ] do + assert {:error, changeset} = SSO.Domains.add(integration, input), + "expected #{input} to be rejected" + + assert %{domain: [:domain]} = + Ecto.Changeset.traverse_errors(changeset, fn {_msg, opts} -> + opts[:validation] + end) + end + end + test "rejects already added domain", %{integration: integration} do domain = generate_domain() {:ok, _} = SSO.Domains.add(integration, domain) diff --git a/test/plausible/installation_support/checks/url_test.exs b/test/plausible/installation_support/checks/url_test.exs index 28ac54b9986e..d05b8296febd 100644 --- a/test/plausible/installation_support/checks/url_test.exs +++ b/test/plausible/installation_support/checks/url_test.exs @@ -21,12 +21,12 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do ] 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}] + |> stub(:lookup, fn + unquote(expected_lookup_domain), _class, :aaaa, _opts, _timeout -> + [] + + unquote(expected_lookup_domain), _class, _type, _opts, _timeout -> + [{93, 184, 216, 34}] end) state = @@ -49,11 +49,12 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do site_domain = "example.com/any/deeper/path" Plausible.DnsLookup.Mock - |> expect(:lookup, fn ~c"example.com", _type, _record, _opts, _timeout -> + |> expect(:lookup, fn ~c"example.com", _class, :a, _opts, _timeout -> [] end) - |> expect(:lookup, fn ~c"www.example.com", _type, _record, _opts, _timeout -> - [{192, 168, 1, 2}] + |> stub(:lookup, fn + ~c"www.example.com", _class, :aaaa, _opts, _timeout -> [] + ~c"www.example.com", _class, _type, _opts, _timeout -> [{93, 184, 216, 35}] end) state = @@ -103,8 +104,9 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do 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}] + |> stub(:lookup, fn + ~c"blog.example.com", _class, :aaaa, _opts, _timeout -> [] + ~c"blog.example.com", _class, _type, _opts, _timeout -> [{93, 184, 216, 34}] end) state = @@ -163,6 +165,111 @@ defmodule Plausible.InstallationSupport.Checks.UrlTest do end end + describe "SSRF protection" do + for {label, a_record} <- [ + {"loopback", {127, 0, 0, 1}}, + {"private 10/8", {10, 0, 0, 5}}, + {"private 192.168/16", {192, 168, 1, 1}}, + {"private 172.16/12", {172, 16, 0, 1}}, + {"link-local metadata", {169, 254, 169, 254}}, + {"CGNAT 100.64/10", {100, 64, 0, 1}} + ] do + test "rejects url whose host resolves to #{label}" do + url = "https://internal.attacker.example/_search" + + Plausible.DnsLookup.Mock + |> expect(:lookup, fn ~c"internal.attacker.example", _type, _record, _opts, _timeout -> + [unquote(Macro.escape(a_record))] + end) + + state = + @check.perform( + %State{ + data_domain: "example-com-rollup", + 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 data_domain whose host resolves to #{label}" do + Plausible.DnsLookup.Mock + |> expect(:lookup, 2, fn _domain, _type, _record, _opts, _timeout -> + [unquote(Macro.escape(a_record))] + end) + + state = + @check.perform( + %State{ + data_domain: "internal.attacker.example", + url: nil, + diagnostics: %Verification.Diagnostics{} + }, + [] + ) + + assert state.url == nil + assert state.diagnostics.service_error == %{code: :domain_not_found} + assert state.skip_further_checks? + end + end + + test "rejects host with a public A record but an internal AAAA record" do + url = "https://dualstack.attacker.example/" + + Plausible.DnsLookup.Mock + |> stub(:lookup, fn + ~c"dualstack.attacker.example", _class, :aaaa, _opts, _timeout -> + [{0, 0, 0, 0, 0, 0, 0, 1}] + + ~c"dualstack.attacker.example", _class, _type, _opts, _timeout -> + [{93, 184, 216, 34}] + end) + + state = + @check.perform( + %State{ + data_domain: "example-com-rollup", + 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 host that resolves to both public and private addresses" do + url = "https://rebind.attacker.example/" + + Plausible.DnsLookup.Mock + |> expect(:lookup, fn ~c"rebind.attacker.example", _type, _record, _opts, _timeout -> + [{93, 184, 216, 34}, {10, 0, 0, 1}] + end) + + state = + @check.perform( + %State{ + data_domain: "example-com-rollup", + url: url, + diagnostics: %Verification.Diagnostics{} + }, + [] + ) + + assert state.url == url + assert state.diagnostics.service_error == %{code: :domain_not_found} + assert state.skip_further_checks? + end + end + test "reports progress correctly" do assert @check.report_progress_as() == "We're trying to reach your website" diff --git a/test/plausible/ssrf_protection_test.exs b/test/plausible/ssrf_protection_test.exs new file mode 100644 index 000000000000..8964fc75d833 --- /dev/null +++ b/test/plausible/ssrf_protection_test.exs @@ -0,0 +1,97 @@ +defmodule Plausible.SSRFProtectionTest do + use ExUnit.Case, async: true + + alias Plausible.SSRFProtection + + describe "internal_ip?/1 with IPv4" do + for ip <- [ + {0, 0, 0, 0}, + {10, 0, 0, 1}, + {127, 0, 0, 1}, + {100, 64, 0, 1}, + {100, 100, 50, 1}, + {169, 254, 169, 254}, + {172, 16, 0, 1}, + {172, 31, 255, 255}, + {192, 0, 0, 1}, + {192, 168, 1, 1}, + {198, 18, 0, 1}, + {224, 0, 0, 1}, + {239, 255, 255, 255}, + {255, 255, 255, 255} + ] do + test "rejects internal #{inspect(ip)}" do + assert SSRFProtection.internal_ip?(unquote(Macro.escape(ip))) + end + end + + for ip <- [ + {1, 1, 1, 1}, + {8, 8, 8, 8}, + {93, 184, 216, 34}, + {172, 15, 0, 1}, + {172, 32, 0, 1}, + {100, 63, 0, 1}, + {100, 128, 0, 1}, + {198, 17, 0, 1}, + {198, 20, 0, 1} + ] do + test "accepts public #{inspect(ip)}" do + refute SSRFProtection.internal_ip?(unquote(Macro.escape(ip))) + end + end + end + + describe "internal_ip?/1 with IPv6" do + for ip <- [ + {0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 1}, + {0xFC00, 0, 0, 0, 0, 0, 0, 1}, + {0xFD12, 0, 0, 0, 0, 0, 0, 1}, + {0xFE80, 0, 0, 0, 0, 0, 0, 1}, + {0xFF02, 0, 0, 0, 0, 0, 0, 1}, + # ::ffff:169.254.169.254 (IPv4-mapped metadata address) + {0, 0, 0, 0, 0, 0xFFFF, 0xA9FE, 0xA9FE}, + # ::ffff:10.0.0.1 + {0, 0, 0, 0, 0, 0xFFFF, 0x0A00, 0x0001}, + # ::127.0.0.1 (deprecated IPv4-compatible loopback) + {0, 0, 0, 0, 0, 0, 0x7F00, 0x0001}, + # 64:ff9b::169.254.169.254 (NAT64 metadata address) + {0x0064, 0xFF9B, 0, 0, 0, 0, 0xA9FE, 0xA9FE}, + # 2002:0a00:0001:: (6to4 wrapping 10.0.0.1) + {0x2002, 0x0A00, 0x0001, 0, 0, 0, 0, 0} + ] do + test "rejects internal #{inspect(ip)}" do + assert SSRFProtection.internal_ip?(unquote(Macro.escape(ip))) + end + end + + for ip <- [ + {0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111}, + # ::ffff:8.8.8.8 (IPv4-mapped public address) + {0, 0, 0, 0, 0, 0xFFFF, 0x0808, 0x0808}, + # 64:ff9b::8.8.8.8 (NAT64 wrapping a public address) + {0x0064, 0xFF9B, 0, 0, 0, 0, 0x0808, 0x0808}, + # 2002:0808:0808:: (6to4 wrapping 8.8.8.8) + {0x2002, 0x0808, 0x0808, 0, 0, 0, 0, 0} + ] do + test "accepts public #{inspect(ip)}" do + refute SSRFProtection.internal_ip?(unquote(Macro.escape(ip))) + end + end + end + + describe "any_internal?/1" do + test "treats empty list as unsafe" do + assert SSRFProtection.any_internal?([]) + end + + test "is true when any address is internal" do + assert SSRFProtection.any_internal?([{8, 8, 8, 8}, {10, 0, 0, 1}]) + end + + test "is false when all addresses are public" do + refute SSRFProtection.any_internal?([{8, 8, 8, 8}, {1, 1, 1, 1}]) + end + end +end diff --git a/test/plausible_web/live/change_domain_test.exs b/test/plausible_web/live/change_domain_test.exs index df5b7428c7e8..8faab99f751c 100644 --- a/test/plausible_web/live/change_domain_test.exs +++ b/test/plausible_web/live/change_domain_test.exs @@ -16,8 +16,9 @@ defmodule PlausibleWeb.Live.ChangeDomainTest do setup do # mock all domains resolve Plausible.DnsLookup.Mock - |> expect(:lookup, fn _domain, _type, _record, _opts, _timeout -> - [{192, 168, 1, 2}] + |> stub(:lookup, fn + _domain, _class, :aaaa, _opts, _timeout -> [] + _domain, _class, _type, _opts, _timeout -> [{93, 184, 216, 34}] end) # Stub detection by default to prevent async task race conditions diff --git a/test/support/dns.ex b/test/support/dns.ex index c3e3800f2d42..99c1eeffc418 100644 --- a/test/support/dns.ex +++ b/test/support/dns.ex @@ -4,12 +4,17 @@ defmodule Plausible.Test.Support.DNS do quote do import Mox - def stub_lookup_a_records(domain, a_records \\ [{192, 168, 1, 1}]) do + def stub_lookup_a_records( + domain, + a_records \\ [{93, 184, 216, 34}], + aaaa_records \\ [] + ) do lookup_domain = to_charlist(domain) Plausible.DnsLookup.Mock - |> expect(:lookup, fn ^lookup_domain, _type, _record, _opts, _timeout -> - a_records + |> stub(:lookup, fn + ^lookup_domain, _class, :aaaa, _opts, _timeout -> aaaa_records + ^lookup_domain, _class, _type, _opts, _timeout -> a_records end) end end