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:
142
classes/security/ssrf_guard.go
Normal file
142
classes/security/ssrf_guard.go
Normal 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
|
||||
}
|
||||
166
classes/security/ssrf_guard_test.go
Normal file
166
classes/security/ssrf_guard_test.go
Normal 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) }
|
||||
Reference in New Issue
Block a user