From da9fcde7258ab345bb4615b0af6be8ff81fecabc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 3 Oct 2026 23:09:02 +0200 Subject: [PATCH] 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. --- classes/security/ssrf_guard.go | 142 ++++++++++++++++++++++++ classes/security/ssrf_guard_test.go | 166 ++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 classes/security/ssrf_guard.go create mode 100644 classes/security/ssrf_guard_test.go diff --git a/classes/security/ssrf_guard.go b/classes/security/ssrf_guard.go new file mode 100644 index 0000000..29762e1 --- /dev/null +++ b/classes/security/ssrf_guard.go @@ -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: "), and a host that +// resolves ("Cannot resolve host: ") to public addresses only +// ("Resolves to private/loopback 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 +} diff --git a/classes/security/ssrf_guard_test.go b/classes/security/ssrf_guard_test.go new file mode 100644 index 0000000..9ec895d --- /dev/null +++ b/classes/security/ssrf_guard_test.go @@ -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 "" + } + 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) }