Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion extra/lib/plausible/auth/sso/domain.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 87 additions & 8 deletions extra/lib/plausible/auth/sso/domain/verification.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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] || []

Expand All @@ -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()
Expand Down
31 changes: 19 additions & 12 deletions extra/lib/plausible/installation_support/checks/url.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions lib/plausible/ssrf_protection.ex
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions test/plausible/auth/sso/domain/verification_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading