Packages
nous
0.15.5
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/tools/url_guard.ex
defmodule Nous.Tools.UrlGuard do
@moduledoc """
SSRF protection for outbound HTTP from tools and providers.
Prevents prompt-injected agents from reaching cloud-metadata
(`169.254.169.254`), internal services on private networks, loopback,
and link-local ranges. By default only `http://` and `https://` schemes
are accepted; other schemes (`file://`, `gopher://`, `ftp://`, etc.)
are rejected.
## Usage
case Nous.Tools.UrlGuard.validate("https://example.com/foo") do
{:ok, uri} -> proceed_with(uri)
{:error, reason} -> {:error, reason} # human-readable
end
## Opt-in: allowing private hosts
For local dev / Docker dev-loop you can pass `allow_private_hosts: true`:
Nous.Tools.UrlGuard.validate(url, allow_private_hosts: true)
Do NOT enable this in production. It re-opens the SSRF channel.
"""
import Bitwise
@blocked_schemes ~w(file gopher ftp ldap dict ssh)
# IPv4 ranges that must never be reachable from agent-controlled URLs.
# CIDR-style; checked via :inet integer math.
@blocked_v4_ranges [
# 127.0.0.0/8 - loopback
{{127, 0, 0, 0}, 8},
# 10.0.0.0/8 - RFC1918
{{10, 0, 0, 0}, 8},
# 172.16.0.0/12 - RFC1918
{{172, 16, 0, 0}, 12},
# 192.168.0.0/16 - RFC1918
{{192, 168, 0, 0}, 16},
# 169.254.0.0/16 - link-local (cloud metadata!)
{{169, 254, 0, 0}, 16},
# 100.64.0.0/10 - CGNAT
{{100, 64, 0, 0}, 10},
# 0.0.0.0/8 - "this network"
{{0, 0, 0, 0}, 8},
# 224.0.0.0/4 - multicast
{{224, 0, 0, 0}, 4},
# 240.0.0.0/4 - reserved
{{240, 0, 0, 0}, 4}
]
@doc """
Validate a URL string. Returns `{:ok, %URI{}}` or `{:error, reason}`.
## Options
- `:allow_private_hosts` — when true, skips the private/loopback
blocklist. Defaults to false.
"""
@spec validate(String.t(), keyword()) :: {:ok, URI.t()} | {:error, String.t()}
def validate(url, opts \\ [])
def validate(url, _opts) when not is_binary(url) do
{:error, "URL must be a string"}
end
def validate(url, opts) do
allow_private = Keyword.get(opts, :allow_private_hosts, false)
with {:ok, uri} <- parse(url),
:ok <- check_scheme(uri),
:ok <- check_host(uri, allow_private) do
{:ok, uri}
end
end
defp parse(url) do
case URI.new(url) do
{:ok, %URI{host: host} = uri} when is_binary(host) and host != "" -> {:ok, uri}
{:ok, _} -> {:error, "URL has no host"}
{:error, _} -> {:error, "URL is malformed"}
end
end
defp check_scheme(%URI{scheme: scheme}) do
cond do
is_nil(scheme) ->
{:error, "URL must include a scheme (http:// or https://)"}
scheme in @blocked_schemes ->
{:error, "URL scheme #{inspect(scheme)} is blocked"}
scheme in ["http", "https"] ->
:ok
true ->
{:error, "URL scheme #{inspect(scheme)} is not allowed (use http or https)"}
end
end
defp check_host(_uri, true), do: :ok
defp check_host(%URI{host: host}, false) do
case resolve_host(host) do
{:ok, addrs} ->
if Enum.any?(addrs, &address_blocked?/1) do
{:error,
"URL resolves to a private/loopback/link-local address; refusing to fetch (#{host})"}
else
:ok
end
{:error, reason} ->
{:error, "Could not resolve #{host}: #{inspect(reason)}"}
end
end
# Resolve a host name to its IP addresses. If the input is already an IP
# literal, just parse it.
defp resolve_host(host) do
case :inet.parse_address(String.to_charlist(host)) do
{:ok, addr} ->
{:ok, [addr]}
{:error, _} ->
# Not a literal; do a DNS lookup
case :inet.getaddrs(String.to_charlist(host), :inet) do
{:ok, addrs} -> {:ok, addrs}
{:error, _} = err -> err
end
end
end
# IPv4 address blocklist check (CIDR-style).
defp address_blocked?({a, b, c, d} = _addr) do
Enum.any?(@blocked_v4_ranges, fn {prefix, prefix_len} ->
addr_int = ip_to_int({a, b, c, d})
prefix_int = ip_to_int(prefix)
mask = bsl(0xFFFFFFFF, 32 - prefix_len) |> band(0xFFFFFFFF)
band(addr_int, mask) == band(prefix_int, mask)
end)
end
# IPv6 - block loopback (::1) and unique local (fc00::/7) at minimum.
defp address_blocked?({0, 0, 0, 0, 0, 0, 0, 1}), do: true
defp address_blocked?({a, _, _, _, _, _, _, _})
when band(a, 0xFE00) == 0xFC00,
do: true
defp address_blocked?(_), do: false
defp ip_to_int({a, b, c, d}), do: bsl(a, 24) + bsl(b, 16) + bsl(c, 8) + d
end