feat: security.AssertSafeURL ports the SSRFGuard

https only, the golem15.golem.ssrf.allowed_hosts allowlist (overridden by
GOLEM15_SSRF_ALLOWED_HOSTS) with dotted-suffix entries, and a resolve-time
check that every address is public, judged by fetchguard.IsPrivateAddr.
Failures carry PHP's RuntimeException messages.
This commit is contained in:
Jakub Zych
2026-10-03 23:09:02 +02:00
parent cd3f0f6c1c
commit da9fcde725
2 changed files with 308 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
// Package security ports the WinterCMS plugin's SSRFGuard: the check an
// untrusted AI base URL (a user's or an organisation's) and an
// AI-returned image URL pass before anything is sent to them.
package security
import (
"context"
"net"
"net/netip"
"net/url"
"os"
"strings"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
// ConfigAllowedHosts is the config key of the host allowlist.
const ConfigAllowedHosts = "golem15.golem.ssrf.allowed_hosts"
// EnvAllowedHosts is the environment variable that replaces the
// allowlist: a comma-separated list, used when set and not empty.
const EnvAllowedHosts = "GOLEM15_SSRF_ALLOWED_HOSTS"
// DefaultAllowedHosts is the allowlist when neither the environment nor
// the config names one: OpenAI's DALL-E image host and every OpenAI host.
var DefaultAllowedHosts = []string{"oaidalleapiprodscus.blob.core.windows.net", ".openai.com"}
// UnsafeURLError is a URL the guard refused. Message is PHP's
// RuntimeException text.
type UnsafeURLError struct {
Message string
}
func (e *UnsafeURLError) Error() string { return e.Message }
// Resolver looks a host up (net.DefaultResolver satisfies it).
type Resolver interface {
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
}
// DefaultResolver is the resolver AssertSafeURL uses.
var DefaultResolver Resolver = net.DefaultResolver
// AllowedHosts is the allowlist: GOLEM15_SSRF_ALLOWED_HOSTS split on
// commas (entries trimmed, empty ones dropped) when it is set and not
// empty, else golem15.golem.ssrf.allowed_hosts, else DefaultAllowedHosts.
func AllowedHosts(cfg *compass.Config) []string {
if env := os.Getenv(EnvAllowedHosts); env != "" {
var out []string
for _, h := range strings.Split(env, ",") {
if h = strings.TrimSpace(h); h != "" {
out = append(out, h)
}
}
return out
}
if cfg != nil {
if v, ok := cfg.Lookup(ConfigAllowedHosts); ok {
var out []string
switch list := v.(type) {
case []any:
for _, h := range list {
if s, ok := h.(string); ok {
out = append(out, s)
}
}
case []string:
out = append(out, list...)
}
return out
}
}
return append([]string(nil), DefaultAllowedHosts...)
}
// AssertSafeURL is SSRFGuard::assertSafeUrl: rawURL needs a scheme and a
// host ("Invalid URL"), the https scheme ("Only https:// scheme
// allowed"), a host on the allowlist, where an entry with a leading dot
// matches any subdomain and any other entry matches exactly, both
// case-insensitively ("Host not in allowlist: <host>"), and a host that
// resolves ("Cannot resolve host: <host>") to public addresses only
// ("Resolves to private/loopback IP: <ip>", judged by
// fetchguard.IsPrivateAddr). A failure is *UnsafeURLError.
//
// The address check runs before the request, as in PHP; the guarded
// client's dial-time check runs again when it connects.
func AssertSafeURL(ctx context.Context, rawURL string, allowed []string) error {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return &UnsafeURLError{Message: "Invalid URL"}
}
if !strings.EqualFold(u.Scheme, "https") {
return &UnsafeURLError{Message: "Only https:// scheme allowed"}
}
host := u.Hostname()
if !hostAllowed(host, allowed) {
return &UnsafeURLError{Message: "Host not in allowlist: " + host}
}
addrs, err := resolve(ctx, host)
if err != nil || len(addrs) == 0 {
return &UnsafeURLError{Message: "Cannot resolve host: " + host}
}
for _, a := range addrs {
if fetchguard.IsPrivateAddr(a) {
return &UnsafeURLError{Message: "Resolves to private/loopback IP: " + a.String()}
}
}
return nil
}
func hostAllowed(host string, allowed []string) bool {
h := strings.ToLower(host)
for _, a := range allowed {
a = strings.ToLower(a)
if strings.HasPrefix(a, ".") {
if strings.HasSuffix(h, a) {
return true
}
} else if h == a {
return true
}
}
return false
}
func resolve(ctx context.Context, host string) ([]netip.Addr, error) {
if a, err := netip.ParseAddr(host); err == nil {
return []netip.Addr{a}, nil
}
ips, err := DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
out := make([]netip.Addr, 0, len(ips))
for _, ip := range ips {
if a, ok := netip.AddrFromSlice(ip.IP); ok {
out = append(out, a.Unmap())
}
}
return out, nil
}

View File

@@ -0,0 +1,166 @@
package security
import (
"context"
"errors"
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
// fakeResolver answers from a table; a missing host does not resolve.
type fakeResolver map[string][]string
func (f fakeResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) {
ips, ok := f[strings.ToLower(host)]
if !ok {
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
}
out := make([]net.IPAddr, len(ips))
for i, s := range ips {
out[i] = net.IPAddr{IP: net.ParseIP(s)}
}
return out, nil
}
func withResolver(t *testing.T, r Resolver) {
t.Helper()
prev := DefaultResolver
DefaultResolver = r
t.Cleanup(func() { DefaultResolver = prev })
}
func message(err error) string {
var u *UnsafeURLError
if !errors.As(err, &u) {
return "<not UnsafeURLError: " + strings.TrimSpace(func() string {
if err == nil {
return "nil"
}
return err.Error()
}()) + ">"
}
return u.Message
}
// TestSSRFGuard ports SSRFGuardTest.php and adds the allowlist sources.
func TestSSRFGuard(t *testing.T) {
ctx := context.Background()
withResolver(t, fakeResolver{
"oaidalleapiprodscus.blob.core.windows.net": {"20.60.40.1"},
"api.openai.com": {"162.159.140.245", "2606:4700::6812:1"},
"rebind.openai.com": {"162.159.140.245", "10.0.0.5"},
"metadata.example.com": {"169.254.169.254"},
})
def := DefaultAllowedHosts
cases := []struct {
name string
url string
allowed []string
want string
}{
{"private ipv4 127", "https://127.0.0.1/x", []string{"127.0.0.1"}, "Resolves to private/loopback IP: 127.0.0.1"},
{"metadata 169.254", "https://169.254.169.254/latest/meta-data/", []string{"169.254.169.254"}, "Resolves to private/loopback IP: 169.254.169.254"},
{"ipv6 loopback", "https://[::1]/x", []string{"::1"}, "Resolves to private/loopback IP: ::1"},
{"non-https scheme", "ftp://oaidalleapiprodscus.blob.core.windows.net/x", def, "Only https:// scheme allowed"},
{"http scheme", "http://api.openai.com/v1", def, "Only https:// scheme allowed"},
{"non-allowlisted host", "https://evil.example.com/x", def, "Host not in allowlist: evil.example.com"},
{"groq is not allowlisted", "https://api.groq.com/openai/v1", def, "Host not in allowlist: api.groq.com"},
{"anthropic is not allowlisted by default", "https://api.anthropic.com/v1", def, "Host not in allowlist: api.anthropic.com"},
{"suffix entry needs the dot", "https://evilopenai.com/v1", def, "Host not in allowlist: evilopenai.com"},
{"no scheme", "api.openai.com/v1", def, "Invalid URL"},
{"no host", "https:///v1", def, "Invalid URL"},
{"unresolvable", "https://nxdomain.openai.com/v1", def, "Cannot resolve host: nxdomain.openai.com"},
{"one private answer is enough", "https://rebind.openai.com/v1", def, "Resolves to private/loopback IP: 10.0.0.5"},
{"resolves to metadata", "https://metadata.example.com/", []string{"metadata.example.com"}, "Resolves to private/loopback IP: 169.254.169.254"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := message(AssertSafeURL(ctx, c.url, c.allowed)); got != c.want {
t.Fatalf("AssertSafeURL(%s) = %q, want %q", c.url, got, c.want)
}
})
}
t.Run("accepts allowlisted public hosts", func(t *testing.T) {
for _, u := range []string{
"https://oaidalleapiprodscus.blob.core.windows.net/file.png",
"https://api.openai.com/v1",
"HTTPS://API.OPENAI.COM/v1",
} {
if err := AssertSafeURL(ctx, u, def); err != nil {
t.Fatalf("%s: %v", u, err)
}
}
if err := AssertSafeURL(ctx, "https://API.openai.com/v1", []string{".OPENAI.COM"}); err != nil {
t.Fatalf("case-insensitive suffix: %v", err)
}
})
t.Run("documented private ranges", func(t *testing.T) {
for _, ip := range []string{"127.0.0.0", "127.0.0.1", "127.255.255.255", "10.0.0.0", "10.255.255.255", "172.16.0.0", "172.31.255.255", "192.168.0.0", "192.168.255.255", "169.254.0.0", "169.254.169.254", "169.254.255.255", "0.0.0.0", "::1", "fe80::1", "fe80::abcd:1234", "fc00::1", "fd12:3456:789a::1", "240.0.0.1"} {
if err := AssertSafeURL(ctx, "https://"+bracket(ip)+"/", []string{ip}); message(err) != "Resolves to private/loopback IP: "+ip {
t.Errorf("%s: %v", ip, err)
}
}
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "172.32.0.1", "2001:4860:4860::8888"} {
if err := AssertSafeURL(ctx, "https://"+bracket(ip)+"/", []string{ip}); err != nil {
t.Errorf("public %s refused: %v", ip, err)
}
}
})
t.Run("allowlist sources", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: ssrf\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
t.Setenv(EnvAllowedHosts, "")
if got := AllowedHosts(nil); strings.Join(got, ",") != "oaidalleapiprodscus.blob.core.windows.net,.openai.com" {
t.Fatalf("default = %v", got)
}
if err := cfg.Set(ConfigAllowedHosts, []any{".anthropic.com"}); err != nil {
t.Fatal(err)
}
if got := AllowedHosts(cfg); strings.Join(got, ",") != ".anthropic.com" {
t.Fatalf("config = %v", got)
}
t.Setenv(EnvAllowedHosts, " api.groq.com, ,.anthropic.com ")
if got := AllowedHosts(cfg); strings.Join(got, ",") != "api.groq.com,.anthropic.com" {
t.Fatalf("env override = %v", got)
}
withResolver(t, fakeResolver{"api.groq.com": {"104.18.2.161"}})
if err := AssertSafeURL(ctx, "https://api.groq.com/openai/v1", AllowedHosts(cfg)); err != nil {
t.Fatalf("env-allowlisted host refused: %v", err)
}
})
t.Run("same classification as the dial guard", func(t *testing.T) {
if !fetchguard.IsPrivateAddr(mustAddr("100.64.0.1")) {
t.Fatal("carrier-grade NAT is private")
}
if message(AssertSafeURL(ctx, "https://100.64.0.1/", []string{"100.64.0.1"})) != "Resolves to private/loopback IP: 100.64.0.1" {
t.Fatal("the guard must refuse what the dial guard refuses")
}
})
}
func bracket(ip string) string {
if strings.Contains(ip, ":") {
return "[" + ip + "]"
}
return ip
}
func mustAddr(s string) netip.Addr { return netip.MustParseAddr(s) }