Files
sm-golem-plugin/classes/security/ssrf_guard.go
Jakub Zych da9fcde725 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.
2026-10-03 23:09:02 +02:00

143 lines
4.3 KiB
Go

// 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
}