Skip to content
Draft
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
15 changes: 13 additions & 2 deletions lib/logflare/backends/adaptor/http_based/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ defmodule Logflare.Backends.Adaptor.HttpBased.Client do
| {:formatter, Tesla.Client.middleware()}
| {:pool_name, atom()}
| {:http2, boolean()}
| {:ssrf, boolean()}

defguardp is_possible_pool(value)
when not is_nil(value) and not is_boolean(value) and is_atom(value)
Expand All @@ -57,6 +58,9 @@ defmodule Logflare.Backends.Adaptor.HttpBased.Client do
`content-type` should return `["content-type"]`.
* `:pool_name` - An override for the name of the Finch pool to use for requests.
* `:http2` - Whether to use HTTP/2. Defaults to `true`.
* `:ssrf` - Routes requests through an SSRF-safe Finch pool. Defaults to `true`.
When enabled, `:pool_name` and `:http2` do not override the safe pool. Set
this to `false` only for trusted destinations that must access private addresses.
"""
@spec new(opts()) :: t()
def new(opts \\ []) do
Expand All @@ -76,7 +80,11 @@ defmodule Logflare.Backends.Adaptor.HttpBased.Client do
EgressTracer
]
|> Enum.filter(& &1),
adapter_config(Keyword.get(opts, :http2, true), opts[:pool_name])
adapter_config(
Keyword.get(opts, :http2, true),
opts[:pool_name],
Keyword.get(opts, :ssrf, true)
)
)
end

Expand Down Expand Up @@ -117,8 +125,11 @@ defmodule Logflare.Backends.Adaptor.HttpBased.Client do

def headers_middleware(headers), do: {Tesla.Middleware.Headers, headers}

defp adapter_config(http2?, pool_name) do
defp adapter_config(http2?, pool_name, ssrf?) do
cond do
ssrf? ->
{Tesla.Adapter.Finch, name: Logflare.FinchSSRF, receive_timeout: 5_000}

is_possible_pool(pool_name) ->
{Tesla.Adapter.Finch, name: pool_name, receive_timeout: 5_000}

Expand Down
30 changes: 0 additions & 30 deletions lib/logflare/backends/adaptor/http_based/ssrf_protection.ex

This file was deleted.

30 changes: 5 additions & 25 deletions lib/logflare/backends/adaptor/webhook_adaptor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@

A number of other adaptors (_DataDog, Elastic, Loki, etc_) leverage this to handle the final HTTP transaction.

### Finch Pool Selection

By default the pool will be selected automatically based on the `:http` configuration option.

If you want to manually select a specific Finch pool, you can use the `:pool_name` option and provide the module name.
Outbound requests use a dedicated Finch pool that rejects private and reserved
destination addresses at connection time.


### Dynamic URL handling with URL Override
Expand Down Expand Up @@ -277,29 +274,13 @@
# HTTP Client
defmodule Client do
@moduledoc false
alias Logflare.Backends.Adaptor.HttpBased.EgressTracer
alias Logflare.Backends.Adaptor.HttpBased.Headers
alias Logflare.Backends.Adaptor.HttpBased.SSRFProtection
use Tesla, docs: false

defguardp is_possible_pool(value)
when not is_nil(value) and not is_boolean(value) and is_atom(value)
alias Logflare.Backends.Adaptor.HttpBased.EgressTracer
alias Logflare.Backends.Adaptor.HttpBased.Headers

def send(opts) do
http_opt = Keyword.get(opts, :http)
pool_name = Keyword.get(opts, :pool_name)

adaptor =
cond do
is_possible_pool(pool_name) ->
{Tesla.Adapter.Finch, name: pool_name, receive_timeout: 5_000}

http_opt == "http2" ->
{Tesla.Adapter.Finch, name: Logflare.FinchDefault, receive_timeout: 5_000}

true ->
{Tesla.Adapter.Finch, name: Logflare.FinchDefaultHttp1, receive_timeout: 5_000}
end
adaptor = {Tesla.Adapter.Finch, name: Logflare.FinchSSRF, receive_timeout: 5_000}

reserved = reserved_header_names(opts)

Expand All @@ -313,7 +294,6 @@
Tesla.Middleware.Telemetry,
Tesla.Middleware.JSON,
if(opts[:gzip], do: {Tesla.Middleware.CompressRequest, format: "gzip"}),
SSRFProtection,
EgressTracer
]
|> Enum.filter(& &1),
Expand Down Expand Up @@ -507,7 +487,7 @@
end

def ack(_ack_ref, _successful, _failed) do
# TODO: re-queue failed

Check warning on line 490 in lib/logflare/backends/adaptor/webhook_adaptor.ex

View workflow job for this annotation

GitHub Actions / Checks (Code Quality - Linting, mix lint.all)

Found a TODO tag in a comment: # TODO: re-queue failed
end
end
end
15 changes: 15 additions & 0 deletions lib/logflare/networking.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Logflare.Networking do
alias Logflare.Backends.Adaptor.BigQueryAdaptor.GoogleApiClient
alias Logflare.Backends.Adaptor.DatadogAdaptor
alias Logflare.SingleTenant
alias Logflare.Utils.SSRF.TCP

def pools do
if SingleTenant.postgres_backend?() do
Expand Down Expand Up @@ -76,9 +77,23 @@ defmodule Logflare.Networking do
end

defp base_finch_pools do
:ok = TCP.ensure_supported_backend!()

base = System.schedulers_online()

[
{Finch,
name: Logflare.FinchSSRF,
pools: %{
default: [
protocols: [:http1],
size: 50,
start_pool_metrics?: true,
conn_opts: [
transport_opts: [inet6: false, tcp_module: TCP]
]
]
}},
{Finch,
name: Logflare.FinchClickHouseIngest,
pools: %{
Expand Down
105 changes: 75 additions & 30 deletions lib/logflare/utils/ssrf.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ defmodule Logflare.Utils.SSRF do

import Logflare.Utils.Guards, only: [is_non_empty_binary: 1]

@private_ranges Enum.map(
alias Logflare.Utils.SSRF.TCP

@blocked_ranges Enum.map(
[
# IPv4
# all-zeros
Expand All @@ -20,21 +22,62 @@ defmodule Logflare.Utils.SSRF do
"169.254.0.0/16",
# RFC 1918 private
"172.16.0.0/12",
# IETF protocol assignments
"192.0.0.0/24",
# documentation
"192.0.2.0/24",
# deprecated 6to4 relay anycast
"192.88.99.0/24",
# RFC 1918 private
"192.168.0.0/16",
# benchmarking
"198.18.0.0/15",
# documentation
"198.51.100.0/24",
# documentation
"203.0.113.0/24",
# multicast
"224.0.0.0/4",
# reserved
"240.0.0.0/4",
# broadcast
"255.255.255.255/32",
# IPv6
# outside the currently allocated global-unicast envelope
"::/3",
# all-zeros
"::/128",
# loopback
"::1/128",
# IPv4-mapped IPv6
"::ffff:0:0/96",
# NAT64 well-known prefix
"64:ff9b::/96",
# NAT64 local-use prefix
"64:ff9b:1::/48",
# discard-only
"100::/64",
# IETF protocol assignments, including Teredo, benchmarking, and ORCHID
"2001::/23",
# documentation
"2001:db8::/32",
# 6to4
"2002::/16",
# documentation
"3fff::/20",
# segment routing SIDs
"5f00::/16",
# unique-local (covers fd00::/8 = AWS IMDS fd00:ec2::254)
"fc00::/7",
# deprecated site-local
"fec0::/10",
# link-local
"fe80::/10"
"fe80::/10",
# multicast
"ff00::/8",
# outside the currently allocated global-unicast envelope
"4000::/2",
"8000::/1"
],
&InetCidr.parse_cidr!/1
)
Expand All @@ -44,53 +87,55 @@ defmodule Logflare.Utils.SSRF do
@doc "Returns true if the address is loopback, link-local, RFC1918, CGNAT, broadcast, or reserved."
@spec private_ip?(:inet.ip_address()) :: boolean()
def private_ip?(addr) when is_tuple(addr) do
Enum.any?(@private_ranges, &InetCidr.contains?(&1, addr))
Enum.any?(@blocked_ranges, &InetCidr.contains?(&1, addr))
end

@doc "Returns only valid public IP addresses, preserving their order."
@spec public_addresses([term()]) :: [:inet.ip_address()]
def public_addresses(addresses) when is_list(addresses) do
addresses
|> Enum.filter(&public_ip?/1)
|> Enum.uniq()
end

@doc """
Resolves `host` and returns the first safe IP address, or an error if the
host resolves to any private/reserved address or cannot be resolved at all.

Used to obtain an IP to connect to directly, eliminating DNS re-resolution.
host has no public address or cannot be resolved at all. Mixed DNS answers
are filtered so a public address can still be used safely by the pinned TCP
transport.
"""
@spec safe_resolve(String.t() | nil) :: {:ok, :inet.ip_address()} | {:error, String.t()}
def safe_resolve(host) when is_non_empty_binary(host) do
def safe_resolve(host) do
with {:ok, [addr | _]} <- safe_resolve_all(host), do: {:ok, addr}
end

@doc """
Resolves `host` and returns every public IPv4 and IPv6 address, or an error
if no public address is available.
"""
@spec safe_resolve_all(String.t() | nil) ::
{:ok, [:inet.ip_address(), ...]} | {:error, String.t()}
def safe_resolve_all(host) when is_non_empty_binary(host) do
charlist = String.to_charlist(host)

with {:ok, addr} <- :inet.parse_address(charlist),
{:private, false} <- {:private, private_ip?(addr)} do
{:ok, addr}
{:ok, [addr]}
else
{:error, _} -> resolve_hostname(charlist)
{:private, true} -> {:error, @private_ip_error}
end
end

def safe_resolve(_), do: {:error, "invalid host"}

@doc "Formats an IP address tuple as a URL host component (IPv6 wrapped in brackets)."
@spec url_host(:inet.ip_address()) :: String.t()
def url_host(addr) when tuple_size(addr) == 8,
do: "[#{addr |> :inet.ntoa() |> List.to_string()}]"

def url_host(addr), do: addr |> :inet.ntoa() |> List.to_string()
def safe_resolve_all(_), do: {:error, "invalid host"}

defp resolve_hostname(charlist) do
with :unresolved <- resolve_hostname(charlist, :inet),
:unresolved <- resolve_hostname(charlist, :inet6) do
{:error, "could not resolve webhook destination host"}
else
other -> other
case TCP.getaddrs(charlist) do
{:ok, [_ | _] = addresses} -> {:ok, addresses}
{:error, :eacces} -> {:error, @private_ip_error}
{:error, _reason} -> {:error, "could not resolve webhook destination host"}
end
end

defp resolve_hostname(charlist, family) do
with {:ok, addrs} <- :inet.getaddrs(charlist, family),
{:private, false} <- {:private, Enum.any?(addrs, &private_ip?/1)} do
{:ok, List.first(addrs)}
else
{:private, true} -> {:error, @private_ip_error}
{:error, _} -> :unresolved
end
end
defp public_ip?(addr), do: :inet.is_ip_address(addr) and not private_ip?(addr)
end
Loading
Loading