Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
23 changes: 12 additions & 11 deletions extra/lib/plausible/auth/sso/domain.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 26 additions & 17 deletions extra/lib/plausible/auth/sso/domain/verification.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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),
[_ | _] <-
Expand Down Expand Up @@ -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__
Expand Down
6 changes: 3 additions & 3 deletions extra/lib/plausible/auth/sso/domains.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 12 additions & 32 deletions extra/lib/plausible/installation_support/checks/url.ex
Original file line number Diff line number Diff line change
@@ -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.
"""

Expand All @@ -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}
)
Expand Down Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion lib/ip/tools.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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
Expand All @@ -49,8 +52,20 @@ defmodule Plausible.IP.Tools do
end

def allowed?(ip) when is_tuple(ip) and tuple_size(ip) in [4, 8] do
not reserved?(ip)
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
else
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
130 changes: 130 additions & 0 deletions lib/plausible/ssrf.ex
Original file line number Diff line number Diff line change
@@ -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 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
Loading
Loading