defmodule GitGud.OutboundUrl do
@moduledoc """
The one gate in front of every request the forge makes to a URL a
user (or a remote server) chose: repository imports and their
provider APIs, mirror / upstream sync, webhook delivery, and
ActivityPub actor fetches and inbox deliveries.
Without it any signed-in user could point the server at itself or its
network — `http://169.254.169.254/` (cloud metadata), the database,
an admin port on loopback — and read the answer back through an
import error, a webhook delivery log, or a mirrored repository.
A URL passes when
* its scheme is `http` or `https` (`git` too, for git transports),
* it has a host and carries no credentials-only trickery,
* and **every** address the host resolves to is public — not
loopback, link-local (incl. `169.254.169.254`), RFC 1918,
CGNAT (`100.64.0.0/10`), `0.0.0.0/8`, unique-local (`fc00::/7`),
multicast, reserved, or an IPv4-mapped / NAT64 form of one of
those.
## DNS rebinding
Checking a name and then letting the HTTP client resolve it again
leaves a window where the second answer differs. So callers don't
re-resolve: `attach/2` makes Req connect to the address we checked
(keeping the real name for `Host`, SNI and certificate checks), and
`git_config/2` hands git the same pin through `http.curloptResolve`.
Redirects are refused outright on both — a redirect would be a fresh,
unchecked URL. The `git://` transport cannot be pinned (curl isn't
involved), so for it the rebinding window remains; it is checked
like the others.
## Operator override
Self-hosters who import from, or deliver webhooks to, their own LAN
can lift the private-address rule per purpose:
* `GITGUD_IMPORT_ALLOW_PRIVATE=1` — imports, mirrors, upstream sync
* `GITGUD_WEBHOOK_ALLOW_PRIVATE=1` — webhook delivery
* `GITGUD_FEDERATION_ALLOW_PRIVATE=1` — ActivityPub fetch/delivery
(`config :git_gud, GitGud.OutboundUrl, allow_private: [:import, ...]`.)
The scheme rule always applies.
"""
import Bitwise
@type purpose :: :import | :webhook | :federation
@type reason ::
:invalid_url
| :unsupported_scheme
| :nxdomain
| {:blocked_address, String.t()}
@http_schemes ~w(http https)
@git_schemes ~w(http https git)
@doc """
Checks `url` for `purpose`. Returns `{:ok, target}` with the parsed
URI and the addresses it resolved to (all of them public, unless the
operator allowed private ones), or `{:error, reason}`.
Options: `:schemes` — the allowed schemes (default http/https).
"""
@spec check(String.t(), purpose, keyword) ::
{:ok, %{uri: URI.t(), host: String.t(), port: integer, addresses: [tuple]}}
| {:error, reason}
def check(url, purpose, opts \\ [])
def check(url, purpose, opts) when is_binary(url) do
schemes = Keyword.get(opts, :schemes, @http_schemes)
with {:ok, %URI{scheme: scheme} = uri} <- parse(url),
:ok <- check_scheme(scheme, schemes),
{:ok, host} <- host(uri),
{:ok, addresses} <- resolve(host),
:ok <- check_addresses(addresses, purpose) do
{:ok, %{uri: uri, host: host, port: uri.port || default_port(scheme), addresses: addresses}}
end
end
def check(_url, _purpose, _opts), do: {:error, :invalid_url}
@doc "`check/3` as a boolean."
def allowed?(url, purpose, opts \\ []), do: match?({:ok, _}, check(url, purpose, opts))
@doc "A sentence for an error flash or a delivery log."
def describe(:invalid_url), do: "That isn't a valid URL."
def describe(:unsupported_scheme), do: "Only http(s) URLs are allowed."
def describe(:nxdomain), do: "That host name doesn't resolve."
def describe({:blocked_address, ip}),
do: "That host resolves to a private or reserved address (#{ip}), which isn't allowed."
def describe(other), do: inspect(other)
# ── Req ──────────────────────────────────────────────────────────────
@doc """
Guards a `Req.Request` for `purpose`: redirects off, and a request
step that checks the URL just before it is sent and pins the
connection to the checked address. A refused URL never leaves the
node; the request returns `{:error, %GitGud.OutboundUrl.Blocked{}}`.
"""
def attach(%Req.Request{} = req, purpose) do
req
|> Req.merge(redirect: false)
|> Req.Request.append_request_steps(outbound_url_guard: &guard_step(&1, purpose))
end
@doc "`Req.new(opts) |> attach(purpose)`."
def req(opts, purpose), do: opts |> Req.new() |> attach(purpose)
defp guard_step(%Req.Request{url: %URI{} = uri} = request, purpose) do
case check(URI.to_string(uri), purpose) do
{:ok, target} ->
pin(request, target)
{:error, reason} ->
{request, GitGud.OutboundUrl.Blocked.exception(reason: reason)}
end
end
# Under a test plug there's no socket to pin. Otherwise connect to the
# address we checked; Mint's `:hostname` keeps Host/SNI/cert on the
# real name.
defp pin(%Req.Request{} = request, target) do
if Req.Request.get_option(request, :plug) do
request
else
[ip | _] = target.addresses
connect = Req.Request.get_option(request, :connect_options, [])
request
|> Map.put(:url, %{request.url | host: ip_string(ip)})
|> Req.Request.put_option(:connect_options, Keyword.put(connect, :hostname, target.host))
end
end
# ── git ──────────────────────────────────────────────────────────────
@doc """
Checks a git remote for `purpose` and returns the `-c` arguments that
go before the git subcommand: no redirects, only http(s)/git
transports, and (for http(s)) the connection pinned to the address we
checked.
A local path or `file://` URL is refused — it would let a user clone
another repository straight off this server's disk. (The test suite
turns on `allow_local_paths` to use on-disk fixtures as upstreams.)
"""
@spec git_config(String.t(), purpose) :: {:ok, [String.t()]} | {:error, reason}
def git_config(url, purpose) when is_binary(url) do
base = [
"-c",
"http.followRedirects=false",
"-c",
"protocol.allow=never",
"-c",
"protocol.http.allow=always",
"-c",
"protocol.https.allow=always",
"-c",
"protocol.git.allow=always"
]
if local_path?(url) and config(:allow_local_paths, false) do
{:ok, ["-c", "protocol.file.allow=always"]}
else
with {:ok, target} <- check(url, purpose, schemes: @git_schemes) do
{:ok, base ++ resolve_pin(target)}
end
end
end
def git_config(_url, _purpose), do: {:error, :invalid_url}
defp resolve_pin(%{uri: %URI{scheme: s}, host: host, port: port, addresses: [ip | _]})
when s in @http_schemes do
["-c", "http.curloptResolve=#{host}:#{port}:#{curl_ip(ip)}"]
end
defp resolve_pin(_), do: []
defp local_path?(url) do
String.starts_with?(url, "/") or String.starts_with?(url, "file:") or
not String.contains?(url, "://")
end
# ── checks ───────────────────────────────────────────────────────────
defp parse(url) do
case URI.new(String.trim(url)) do
{:ok, %URI{} = uri} -> {:ok, uri}
_ -> {:error, :invalid_url}
end
end
defp host(%URI{host: host}) when is_binary(host) and host != "", do: {:ok, host}
defp host(_), do: {:error, :invalid_url}
defp check_scheme(scheme, schemes) when is_binary(scheme) do
if String.downcase(scheme) in schemes, do: :ok, else: {:error, :unsupported_scheme}
end
defp check_scheme(_, _), do: {:error, :unsupported_scheme}
defp check_addresses(addresses, purpose) do
if purpose in config(:allow_private, []) do
:ok
else
case Enum.find(addresses, &blocked?/1) do
nil -> :ok
ip -> {:error, {:blocked_address, ip_string(ip)}}
end
end
end
@doc false
def resolve(host) do
host = host |> String.trim_leading("[") |> String.trim_trailing("]")
case :inet.parse_address(String.to_charlist(host)) do
{:ok, ip} ->
{:ok, [ip]}
{:error, _} ->
case resolver().(host) do
[] -> {:error, :nxdomain}
addresses -> {:ok, Enum.uniq(addresses)}
end
end
end
defp resolver do
case config(:resolver, nil) do
fun when is_function(fun, 1) -> fun
nil -> &system_resolve/1
end
end
defp system_resolve(host) do
name = String.to_charlist(host)
for family <- [:inet, :inet6],
{:ok, addrs} <- [:inet.getaddrs(name, family)],
addr <- addrs,
do: addr
end
@doc """
Whether `ip` (an `:inet` tuple) is somewhere a user-chosen URL must
not reach.
"""
def blocked?({a, b, c, _d} = ip) when tuple_size(ip) == 4, do: blocked_v4?(a, b, c)
# The v6 ranges, one per line, read best as a single table.
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def blocked?({a, b, c, d, e, f, g, h} = ip) when tuple_size(ip) == 8 do
cond do
# :: and ::1
{a, b, c, d, e, f, g} == {0, 0, 0, 0, 0, 0, 0} and h in [0, 1] -> true
# ::ffff:a.b.c.d (mapped) and ::a.b.c.d (compatible)
{a, b, c, d, e} == {0, 0, 0, 0, 0} and f in [0, 0xFFFF] -> blocked?(v4(g, h))
# 64:ff9b::/96 NAT64
{a, b, c, d, e, f} == {0x64, 0xFF9B, 0, 0, 0, 0} -> blocked?(v4(g, h))
# 2002::/16 6to4 embeds the v4 address in the next 32 bits
a == 0x2002 -> blocked?(v4(b, c))
# fc00::/7 unique local, fe80::/10 link-local, fec0::/10 site-local, ff00::/8 multicast
(a &&& 0xFE00) == 0xFC00 -> true
(a &&& 0xFFC0) in [0xFE80, 0xFEC0] -> true
(a &&& 0xFF00) == 0xFF00 -> true
# 2001:db8::/32 documentation
a == 0x2001 and b == 0x0DB8 -> true
true -> false
end
end
def blocked?(_), do: true
# By the first three octets.
defp blocked_v4?(a, _b, _c) when a in [0, 10, 127] or a >= 224, do: true
defp blocked_v4?(100, b, _c), do: (b &&& 0xC0) == 64
defp blocked_v4?(169, 254, _c), do: true
defp blocked_v4?(172, b, _c), do: (b &&& 0xF0) == 16
defp blocked_v4?(192, 168, _c), do: true
defp blocked_v4?(192, 0, c), do: c in [0, 2]
defp blocked_v4?(198, b, _c) when b in [18, 19], do: true
defp blocked_v4?(198, 51, 100), do: true
defp blocked_v4?(203, 0, 113), do: true
defp blocked_v4?(_a, _b, _c), do: false
defp v4(hi, lo), do: {hi >>> 8, hi &&& 0xFF, lo >>> 8, lo &&& 0xFF}
defp ip_string(ip), do: ip |> :inet.ntoa() |> to_string()
defp curl_ip(ip) when tuple_size(ip) == 8, do: "[" <> ip_string(ip) <> "]"
defp curl_ip(ip), do: ip_string(ip)
defp default_port("https"), do: 443
defp default_port("http"), do: 80
defp default_port("git"), do: 9418
defp default_port(_), do: nil
defp config(key, default) do
:git_gud |> Application.get_env(__MODULE__, []) |> Keyword.get(key, default)
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
11.0 KiB · text
History
f611475