diff --git a/CHANGELOG.md b/CHANGELOG.md index c041f5a79..ae44ce1dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,25 @@ OpenSVC v3 is a major evolution, rebuilt in Go for performance, reliability, and * **Enhanced secret management**: New commands like `om key rename` for better key management in secret stores. +### Security + +* **SSRF protection for HTTP fetches**: + + All configuration, cfg, and secret + values fetched from HTTP(S) URLs are now validated against SSRF policies. + By default, only `https://raw.githubusercontent.com/opensvc/opensvc_templates/*` + URLs are allowed, while all other URLs and private/internal CIDR ranges + (including loopback, RFC 1918, link-local, TEST-NET, and IPv6 ULA) are blocked. + Redirects are disabled by default. Administrators can override these defaults + via environment variables in `/etc/default/opensvc` or `/etc/sysconfig/opensvc`: + ``` + OSVC_SSRF_ALLOWED_URL + OSVC_SSRF_BLOCKED_URL + OSVC_SSRF_ALLOWED_CIDR + OSVC_SSRF_BLOCKED_CIDR + OSVC_SSRF_ENABLE_REDIRECTS + ``` + ### Network & Storage * **Modern firewall management**: Configuration now uses nftables exclusively, with better support for large subnets including IPv6 via the new `mask_per_node` keyword. diff --git a/core/commoncmd/flags.go b/core/commoncmd/flags.go index cdf3c9fcc..3bfdb321b 100644 --- a/core/commoncmd/flags.go +++ b/core/commoncmd/flags.go @@ -106,7 +106,7 @@ func FlagCPUProfile(flags *pflag.FlagSet, p *string) { } func FlagCreateConfig(flags *pflag.FlagSet, p *string) { - flags.StringVar(p, "config", "", "the initial configuration source: -, /dev/stdin, file path, url, object path or template://") + flags.StringVar(p, "config", "", "the initial configuration source: -, /dev/stdin, file path, http[s] url, object path or template://") } func FlagCreateForce(flags *pflag.FlagSet, p *bool) { diff --git a/core/commoncmd/object_create.go b/core/commoncmd/object_create.go index 6f9eb104e..a29b1c8fd 100644 --- a/core/commoncmd/object_create.go +++ b/core/commoncmd/object_create.go @@ -11,7 +11,7 @@ import ( func DataFromConfigURI(u uri.T) ([]byte, error) { fpath, err := u.Fetch() if err != nil { - return nil, nil + return nil, err } defer os.Remove(fpath) return DataFromConfigFile(fpath) diff --git a/core/omcmd/cluster_join.go b/core/omcmd/cluster_join.go index fdecab7a0..b41bb3ae8 100644 --- a/core/omcmd/cluster_join.go +++ b/core/omcmd/cluster_join.go @@ -155,6 +155,10 @@ func (t *CmdClusterJoin) extractCaClaim() (ca []byte, err error) { token *jwt.Token ) + // Parse the token without verifying the signature (part of a secure + // bootstrap trust mechanism) to extract the CA certificate for a TLS trust + // establishment, not for authentication. + // Authentication happens server-side through JWT signature validation. token, _, err = parser.ParseUnverified(t.Token, &joinClaim{}) if err != nil { return diff --git a/core/omcmd/object_create.go b/core/omcmd/object_create.go index 40109c3f4..d29771b7f 100644 --- a/core/omcmd/object_create.go +++ b/core/omcmd/object_create.go @@ -188,7 +188,7 @@ func (t *CmdObjectCreate) do() error { } } -func (t CmdObjectCreate) fromPath(p naming.Path) error { +func (t *CmdObjectCreate) fromPath(p naming.Path) error { cmd := CmdObjectConfigShow{} b, err := cmd.extractPath(p, t.client) if err != nil { @@ -204,7 +204,7 @@ func (t CmdObjectCreate) fromPath(p naming.Path) error { return t.fromData(p, b) } -func (t CmdObjectCreate) fromTemplate(template string) error { +func (t *CmdObjectCreate) fromTemplate(template string) error { if b, err := commoncmd.DataFromTemplate(template); err != nil { return err } else { @@ -212,7 +212,7 @@ func (t CmdObjectCreate) fromTemplate(template string) error { } } -func (t CmdObjectCreate) fromConfig() error { +func (t *CmdObjectCreate) fromConfig() error { b, err := t.dataFromConfig() if err != nil { return err @@ -220,11 +220,11 @@ func (t CmdObjectCreate) fromConfig() error { return t.fromData(t.path, b) } -func (t CmdObjectCreate) fromScratch() error { +func (t *CmdObjectCreate) fromScratch() error { return t.fromData(t.path, nil) } -func (t CmdObjectCreate) fromStdin() error { +func (t *CmdObjectCreate) fromStdin() error { b, err := commoncmd.DataFromStdin() if err != nil { return err @@ -232,7 +232,7 @@ func (t CmdObjectCreate) fromStdin() error { return t.fromData(t.path, b) } -func (t CmdObjectCreate) dataFromConfig() ([]byte, error) { +func (t *CmdObjectCreate) dataFromConfig() ([]byte, error) { u := uri.New(t.Config) switch { case file.Exists(t.Config): @@ -244,7 +244,7 @@ func (t CmdObjectCreate) dataFromConfig() ([]byte, error) { } } -func (t CmdObjectCreate) fromData(p naming.Path, b []byte) error { +func (t *CmdObjectCreate) fromData(p naming.Path, b []byte) error { if !t.Force && !t.Restore && p.Exists() { return fmt.Errorf("%s already exists", p) } @@ -278,7 +278,7 @@ func (t CmdObjectCreate) fromData(p naming.Path, b []byte) error { return nil } -func (t CmdObjectCreate) localEmpty(p naming.Path) error { +func (t *CmdObjectCreate) localEmpty(p naming.Path) error { if !t.Force && p.Exists() { return fmt.Errorf("%s already exists", p) } diff --git a/core/oxcmd/object_create.go b/core/oxcmd/object_create.go index 047819d57..92a6d4caf 100644 --- a/core/oxcmd/object_create.go +++ b/core/oxcmd/object_create.go @@ -156,7 +156,7 @@ func (t *CmdObjectCreate) do() error { } } -func (t CmdObjectCreate) fromData(p naming.Path, b []byte) error { +func (t *CmdObjectCreate) fromData(p naming.Path, b []byte) error { if !t.Force && p.Exists() { return fmt.Errorf("%s already exists", p) } @@ -202,7 +202,7 @@ func (t CmdObjectCreate) fromData(p naming.Path, b []byte) error { return nil } -func (t CmdObjectCreate) fromPath(p naming.Path) error { +func (t *CmdObjectCreate) fromPath(p naming.Path) error { cmd := CmdObjectConfigShow{} b, err := cmd.extractFromDaemon(p, t.client) if err != nil { @@ -218,7 +218,7 @@ func (t CmdObjectCreate) fromPath(p naming.Path) error { return t.fromData(p, b) } -func (t CmdObjectCreate) fromTemplate(template string) error { +func (t *CmdObjectCreate) fromTemplate(template string) error { if b, err := commoncmd.DataFromTemplate(template); err != nil { return err } else { @@ -226,7 +226,7 @@ func (t CmdObjectCreate) fromTemplate(template string) error { } } -func (t CmdObjectCreate) fromConfig() error { +func (t *CmdObjectCreate) fromConfig() error { b, err := t.dataFromConfig() if err != nil { return err @@ -235,11 +235,11 @@ func (t CmdObjectCreate) fromConfig() error { } } -func (t CmdObjectCreate) fromScratch() error { +func (t *CmdObjectCreate) fromScratch() error { return t.fromData(t.path, nil) } -func (t CmdObjectCreate) fromStdin() error { +func (t *CmdObjectCreate) fromStdin() error { b, err := commoncmd.DataFromStdin() if err != nil { return err @@ -247,7 +247,7 @@ func (t CmdObjectCreate) fromStdin() error { return t.fromData(t.path, b) } -func (t CmdObjectCreate) dataFromConfig() ([]byte, error) { +func (t *CmdObjectCreate) dataFromConfig() ([]byte, error) { u := uri.New(t.Config) switch { case file.Exists(t.Config): diff --git a/core/rawconfig/node.go b/core/rawconfig/node.go index 16c4668f6..34114ff5a 100644 --- a/core/rawconfig/node.go +++ b/core/rawconfig/node.go @@ -28,6 +28,12 @@ var ( Colorize *palette.ColorPaletteFunc Color *palette.ColorPalette Paths AgentPaths + + SSRFAllowedURL []string + SSRFBlockedURL []string + SSRFAllowedCIDR []string + SSRFBlockedCIDR []string + SSRFEnableRedirects bool ) func init() { @@ -43,6 +49,10 @@ func Load(env map[string]string) { } } + if env != nil { + setSSRF(env) + } + var root string if s, ok := os.LookupEnv("OSVC_ROOT_PATH"); ok { root = s diff --git a/core/rawconfig/ssrf_policy.go b/core/rawconfig/ssrf_policy.go new file mode 100644 index 000000000..ec021754b --- /dev/null +++ b/core/rawconfig/ssrf_policy.go @@ -0,0 +1,62 @@ +package rawconfig + +import "strings" + +var ( + defaultSSRFAllowedURL = []string{ + "https://raw.githubusercontent.com/opensvc/opensvc_templates/*", + } + + defaultSSRFBlockedURL = []string{ + "*", + } + + defaultSSRFAllowedCIDR = []string{} + + defaultSSRFBlockedCIDR = []string{ + "127.0.0.0/8", // RFC 1122 loopback + "10.0.0.0/8", // RFC 1918 private + "172.16.0.0/12", // RFC 1918 private + "192.168.0.0/16", // RFC 1918 private + "169.254.0.0/16", // link local + "192.0.2.0/24", // RFC 5737 private TEST-NET-1 + "198.51.100.0/24", // RFC 5737 private TEST-NET-2 + "203.0.113.0/24", // RFC 5737 private TEST-NET-3 + + // IPV6 + "::1/128", // RFC 4291 loopback + "fe80::/10", // link-local + "fc00::/7", // RFC 4193 Unique Local IPv6 Unicast Addresses + "ff00::/8", // reserved + } +) + +// setSSRF configures SSRF protection settings, including allowed/blocked URLs, CIDR ranges, and redirect behavior. +// Default is: +// OSVC_SSRF_ALLOWED_URL = https://raw.githubusercontent.com/opensvc/opensvc_templates/* +// OSVC_SSRF_BLOCKED_URL = * +// OSVC_SSRF_BLOCKED_CIDR = 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 192.0.2.0/24 198.51.100.0/24 203.0.113.0/24 ::1/128 fe80::/10 fc00::/7 ff00::/8 +// OSVC_SSRF_ALLOWED_CIDR = +// OSVC_SSRF_ENABLE_REDIRECTS = false +func setSSRF(env map[string]string) { + + SSRFAllowedURL = getSSRFValue(env, "OSVC_SSRF_ALLOWED_URL", defaultSSRFAllowedURL) + SSRFBlockedURL = getSSRFValue(env, "OSVC_SSRF_BLOCKED_URL", defaultSSRFBlockedURL) + SSRFAllowedCIDR = getSSRFValue(env, "OSVC_SSRF_ALLOWED_CIDR", defaultSSRFAllowedCIDR) + SSRFBlockedCIDR = getSSRFValue(env, "OSVC_SSRF_BLOCKED_CIDR", defaultSSRFBlockedCIDR) + if enableRedirects, _ := env["OSVC_SSRF_ENABLE_REDIRECTS"]; enableRedirects == "true" { + SSRFEnableRedirects = true + } else { + SSRFEnableRedirects = false + } +} + +func getSSRFValue(env map[string]string, varName string, defaultValue []string) []string { + if v, ok := env[varName]; !ok { + return append([]string{}, defaultValue...) + } else if v == "" { + return []string{} + } else { + return append([]string{}, strings.Fields(v)...) + } +} diff --git a/util/httppolicy/main.go b/util/httppolicy/main.go new file mode 100644 index 000000000..b4100690e --- /dev/null +++ b/util/httppolicy/main.go @@ -0,0 +1,225 @@ +package httppolicy + +import ( + "fmt" + "net" + "net/url" + "path" + "strings" + + "github.com/danwakefield/fnmatch" +) + +type ( + T struct { + // AllowedUrl defines the list of URL patterns that are explicitly + // permitted by the policy regardless BlockedUrl setting. + AllowedUrl []string + + // BlockedURL define the URLs patterns that are explicitly disallowed by the policy. + // Empty value is treated the same as a []string{"*"} (all URLs are blocked + // unless explicitly allowed). + BlockedUrl []string + + // AllowedCIDR defines CIDR blocks explicitly allowed by the policy, + // overriding any entries in BlockedCIDR. + AllowedCIDR []string + + // BlockedCIDR defines a list of CIDR blocks that are explicitly disallowed + // by the policy for incoming requests. + BlockedCIDR []string + } +) + +// New creates and returns a new instance of T initialized with the provided whitelist URLs and blocked CIDRs. +func New(allowedURL, blockedURL, allowedCIDR, blockedCIDR []string) *T { + return &T{ + AllowedUrl: append([]string(nil), allowedURL...), + BlockedUrl: append([]string(nil), blockedURL...), + AllowedCIDR: append([]string(nil), allowedCIDR...), + BlockedCIDR: append([]string(nil), blockedCIDR...), + } +} + +// Check validates the given rawURL, resolves its IP, determines its port, and ensures it complies with set rules. +// The accepted ip, port is returned to prevent TOCTOU bugs. +func (t *T) Check(rawURL string) (ip net.IP, port string, err error) { + if t == nil { + return nil, "", fmt.Errorf("unexpected validator") + } + + parsedURL, err := parseRequestURL(rawURL) + if err != nil { + return nil, "", err + } + + host := parsedURL.Hostname() + if host == "" { + return nil, "", fmt.Errorf("empty host") + } + var ips []net.IP + ips, err = net.LookupIP(host) + if err != nil { + return nil, "", err + } + + if err := rejectURL(parsedURL, t.AllowedUrl, t.BlockedUrl); err != nil { + return nil, "", err + } + + allowedNets := parseBlockedCIDRs(t.AllowedCIDR) + blockedNets := parseBlockedCIDRs(t.BlockedCIDR) + if ip, err = rejectIPs(ips, allowedNets, blockedNets); err != nil { + return nil, "", fmt.Errorf("reject url with host=%s ip=%s: %w", host, ip, err) + } + port = parsedURL.Port() + if port == "" { + // set port based on the allowed URL scheme + switch parsedURL.Scheme { + case "http": + port = "80" + case "https": + port = "443" + default: + return nil, "", fmt.Errorf("can't detect port for unsupported scheme %s", parsedURL.Scheme) + } + } + return ip, port, nil +} + +func parseRequestURL(rawURL string) (*url.URL, error) { + unescapedURL, err := url.PathUnescape(rawURL) + if err != nil { + return nil, err + } + + parsedURL, err := url.ParseRequestURI(unescapedURL) + if err != nil { + return nil, err + } + if parsedURL == nil { + return nil, fmt.Errorf("nil url") + } + + return parsedURL, nil +} + +func normalizePattern(pattern string) string { + if !strings.Contains(pattern, "://") { + return pattern + } + + u, err := url.Parse(pattern) + if err != nil { + return pattern + } + + port := u.Port() + if port == "" { + switch u.Scheme { + case "http": + port = "80" + case "https": + port = "443" + default: + return pattern + } + } + + normalizedPath := path.Clean(u.Path) + if normalizedPath == "." { + normalizedPath = "/" + } else if !strings.HasPrefix(normalizedPath, "/") { + normalizedPath = "/" + normalizedPath + } + + return fmt.Sprintf("%s://%s:%s%s", u.Scheme, u.Hostname(), port, normalizedPath) +} + +func normalizeURL(u *url.URL) string { + scheme := u.Scheme + host := u.Hostname() + port := u.Port() + + if port == "" { + switch scheme { + case "http": + port = "80" + case "https": + port = "443" + default: + return "" + } + } + + normalizedPath := path.Clean(u.Path) + if normalizedPath == "." { + normalizedPath = "/" + } else if !strings.HasPrefix(normalizedPath, "/") { + normalizedPath = "/" + normalizedPath + } + + return fmt.Sprintf("%s://%s:%s%s", scheme, host, port, normalizedPath) +} + +func rejectURL(requestURL *url.URL, allowed, blocked []string) error { + normalizedURL := normalizeURL(requestURL) + if normalizedURL == "" { + return fmt.Errorf("invalid url scheme") + } + + // Verify exception from the allowed list + for _, pattern := range allowed { + if fnmatch.Match(normalizePattern(pattern), normalizedURL, 0) { + return nil + } + } + + // verify if match blocked url + if len(blocked) == 0 { + blocked = []string{"*"} + } + for _, pattern := range blocked { + if fnmatch.Match(normalizePattern(pattern), normalizedURL, 0) { + return fmt.Errorf("reject url %s: pattern %s is blocked (see SSRF blocked/allowed url lists)", normalizedURL, pattern) + } + } + + return nil +} + +func parseBlockedCIDRs(blockedCIDRs []string) []*net.IPNet { + blockedNets := make([]*net.IPNet, 0, len(blockedCIDRs)) + + for _, cidrString := range blockedCIDRs { + _, cidr, err := net.ParseCIDR(cidrString) + if err != nil || cidr == nil { + continue + } + + blockedNets = append(blockedNets, cidr) + } + + return blockedNets +} + +func rejectIPs(ips []net.IP, allowedNet, blockedNets []*net.IPNet) (ip net.IP, err error) { + if len(ips) == 0 { + return ip, fmt.Errorf("empty ip list provided") + } + for _, ip = range ips { + for _, blockedNet := range blockedNets { + if blockedNet.Contains(ip) { + // verify exceptions + for _, allowed := range allowedNet { + if allowed.Contains(ip) { + return ip, nil + } + } + return ip, fmt.Errorf("subnet %s is blocked (see SSRF blocked/allowed cidr lists)", blockedNet) + } + } + } + // ips are no blocked, return fist ip from ip list + return ips[0], nil +} diff --git a/util/httppolicy/main_test.go b/util/httppolicy/main_test.go new file mode 100644 index 000000000..c57d4d2bd --- /dev/null +++ b/util/httppolicy/main_test.go @@ -0,0 +1,271 @@ +package httppolicy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/opensvc/om3/v3/core/rawconfig" +) + +func TestCheck(t *testing.T) { + cases := []struct { + name string + inputURL string + expectedErr bool + }{ + { + name: "allows_https_github_relay_v3_config_path", + inputURL: "https://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf", + expectedErr: false, + }, + { + name: "allows_https_github_host_only", + inputURL: "https://www.github.com", + expectedErr: false, + }, + { + name: "allows_https_github_host_with_trailing_slash", + inputURL: "https://www.github.com/", + expectedErr: false, + }, + { + name: "rejects_http_scheme", + inputURL: "http://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf", + expectedErr: true, + }, + { + name: "rejects_file_scheme_with_empty_host", + inputURL: "file:///tmp/foo", + expectedErr: true, + }, + { + name: "rejects_disallowed_port_on_allowlisted_host_path", + inputURL: "https://github.com:666/opensvc/om3", + expectedErr: true, + }, + { + name: "allows_exception_url_with_port_and_path", + inputURL: "https://github.com:8888/opensvc/om3", + expectedErr: false, + }, + { + name: "allows_subpath_under_allowlisted_path", + inputURL: "https://github.com:8888/opensvc/om3/bar", + expectedErr: false, + }, + { + name: "allows_normalized_path_within_allowlisted_path", + inputURL: "https://github.com:8888/opensvc/om3/../om3/bar", + expectedErr: false, + }, + { + name: "rejects_normalized_path_outside_allowlisted_path", + inputURL: "https://github.com:8888/opensvc/om3/../oc3/bar", + expectedErr: true, + }, + { + name: "rejects_escaped_dotdot_path_escape", + inputURL: "https://github.com:8888/opensvc/om3/%2e%2e/foo", + expectedErr: true, + }, + { + name: "rejects_path_prefix_without_separator", + inputURL: "https://github.com:8888/opensvc/om3foo", + expectedErr: true, + }, + { + name: "rejects_path_outside_allowlisted_prefix", + inputURL: "https://github.com:8888/opensvc/foo/bar", + expectedErr: true, + }, + { + name: "rejects_file_scheme_with_remote_style_host", + inputURL: "file://raw.githubusercontent.com/tmp/foo", + expectedErr: true, + }, + { + name: "rejects_unknown_scheme", + inputURL: "foo://raw.githubusercontent.com", + expectedErr: true, + }, + { + name: "rejects_disallowed_port_on_allowlisted_raw_github_host", + inputURL: "https://raw.githubusercontent.com:8888", + expectedErr: true, + }, + { + name: "rejects_non_allowlisted_host", + inputURL: "https://google.com", + expectedErr: true, + }, + { + name: "rejects_ipv4_loopback_resolved_from_localhost", + inputURL: "https://localhost", + expectedErr: true, + }, + { + name: "block_cidr", + inputURL: "https://127.0.0.3/foo", + expectedErr: true, + }, + { + name: "block_cidr_v4_mapped_v6", + inputURL: "https://[::ffff:127.0.0.3]/foo", + expectedErr: true, + }, + { + name: "allow_cidr_exception_v4_mapped_v6", + inputURL: "https://[::ffff:127.0.0.2]/foo", + expectedErr: false, + }, + { + name: "allow_cidr_exception", + inputURL: "https://127.0.0.2/foo", + expectedErr: false, + }, + { + name: "rejects_ipv6_loopback", + inputURL: "https://[::1]", + expectedErr: true, + }, + { + name: "rejects_ula_ipv6_cidr", + inputURL: "https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b]", + expectedErr: true, + }, + { + name: "allows_ula_ipv6_cidr_exception", + inputURL: "https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]", + expectedErr: false, + }, + { + name: "rejects_private_ipv4_address", + inputURL: "https://10.0.0.1", + expectedErr: true, + }, + { + name: "rejects_unique_local_ipv6_address", + inputURL: "https://[fc00::1]", + expectedErr: true, + }, + { + name: "allows_public_ipv4_address", + inputURL: "https://8.8.8.8", + expectedErr: false, + }, + { + name: "rejects_malformed_url", + inputURL: ":/invalid-url", + expectedErr: true, + }, + { + name: "rejects_unresolvable_host", + inputURL: "https://invalid-host-lookup.opensvc.com/foo/bar", + expectedErr: true, + }, + { + name: "accepts_*.opensvc.com_with_/foo/bar", + inputURL: "https://www.opensvc.com/foo/bar", + expectedErr: false, + }, + { + name: "accepts_*.opensvc.com_with_/foo/bar/baz", + inputURL: "https://www.opensvc.com/foo/bar/baz", + expectedErr: false, + }, + { + name: "accepts_*.opensvc.com_with_/foo/bar/", + inputURL: "https://www.opensvc.com/foo/bar/baz/", + expectedErr: false, + }, + { + name: "rejects_*.opensvc.com_with_/foo", + inputURL: "https://www.opensvc.com/foo", + expectedErr: true, + }, + { + name: "rejects_*.opensvc.com_with_/foo1/bar", + inputURL: "https://www.opensvc.com/foo1/bar", + expectedErr: true, + }, + { + name: "rejects_https_url_with_empty_host", + inputURL: "https://", + expectedErr: true, + }, + { + name: "rejects_empty_url", + inputURL: "", + expectedErr: true, + }, + { + name: "rejects_relative_path", + inputURL: "foo/bar", + expectedErr: true, + }, + { + name: "rejects_dot_relative_path", + inputURL: "./bar/foo", + expectedErr: true, + }, + } + + t.Run("raw config checker", func(t *testing.T) { + v := New(rawconfig.SSRFAllowedURL, rawconfig.SSRFBlockedURL, rawconfig.SSRFAllowedCIDR, rawconfig.SSRFBlockedCIDR) + + v.AllowedUrl = append(v.AllowedUrl, "https://8.8.8.8", "https://github.com:8888/opensvc/om3", "https://github.com:8888/opensvc/om3/*", "https://www.github.com") + + // to verify loopback ranges + v.AllowedUrl = append(v.AllowedUrl, "https://localhost", "https://[::1]") + + // wildcard exception + v.AllowedUrl = append(v.AllowedUrl, "https://*.opensvc.com/foo/bar*") + + v.AllowedUrl = append(v.AllowedUrl, "https://127.0.0.2/foo", "https://127.0.0.3/foo") + v.AllowedUrl = append(v.AllowedUrl, "https://[::ffff:127.0.0.2]/foo", "https://[::ffff:127.0.0.3]/foo") + v.AllowedCIDR = append(v.AllowedCIDR, "127.0.0.10/32", "127.0.0.2/32") + + v.AllowedUrl = append(v.AllowedUrl, + "https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b]", // should be rejected by ula rule + "https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]", // should be accepted in AllowedCIDR + ) + v.AllowedCIDR = append(v.AllowedCIDR, "fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128") + + t.Logf("policy AllowedUrl: %s", v.AllowedUrl) + t.Logf("policy BlockedUrl: %s", v.BlockedUrl) + t.Logf("policy AllowedCIDR: %s", v.AllowedCIDR) + t.Logf("policy BlockedCIDR: %s", v.BlockedCIDR) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Logf("Check url: %s", tc.inputURL) + ip, port, err := v.Check(tc.inputURL) + if tc.expectedErr { + assert.Errorf(t, err, "expected error for %s", tc.inputURL) + if err != nil { + t.Logf("Check url error: %s", err) + } + } else { + t.Logf("url detected ip %s, port %s", ip, port) + assert.NoError(t, err) + } + }) + } + }) + + t.Run("zero checker must reject all urls", func(t *testing.T) { + v := New(nil, nil, nil, nil) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Logf("Check url: %s", tc.inputURL) + ip, port, err := v.Check(tc.inputURL) + assert.Errorf(t, err, "Check url for %s", tc.inputURL) + t.Logf("url detected ip %s, port %s", ip, port) + if err != nil { + t.Logf("got expected error: %s", err) + } + }) + } + }) +} diff --git a/util/uri/uri.go b/util/uri/uri.go index e43306c84..9f456dff5 100644 --- a/util/uri/uri.go +++ b/util/uri/uri.go @@ -2,9 +2,11 @@ package uri import ( "bufio" + "context" "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -12,6 +14,7 @@ import ( "github.com/opensvc/om3/v3/core/rawconfig" "github.com/opensvc/om3/v3/util/file" + "github.com/opensvc/om3/v3/util/httppolicy" "github.com/opensvc/om3/v3/util/random" ) @@ -33,7 +36,15 @@ func New(s string) T { } func (t T) Fetch() (string, error) { - resp, err := http.Get(t.uri) + var resp *http.Response + policy := httppolicy.New(rawconfig.SSRFAllowedURL, rawconfig.SSRFBlockedURL, rawconfig.SSRFAllowedCIDR, rawconfig.SSRFBlockedCIDR) + ip, port, err := policy.Check(t.uri) + if err != nil { + return "", err + } + + client := clientForIP(ip, port, rawconfig.SSRFEnableRedirects) + resp, err = client.Get(t.uri) if err != nil { return "", err } @@ -106,6 +117,35 @@ func ReadAllFrom(from string) (map[string][]byte, error) { } } +func clientForIP(ip net.IP, port string, enableRedirects bool) *http.Client { + dialer := &net.Dialer{} + + transport := &http.Transport{ + DialContext: func( + ctx context.Context, + network, _ string, + ) (net.Conn, error) { + return dialer.DialContext( + ctx, + network, + net.JoinHostPort(ip.String(), port), + ) + }, + } + + client := &http.Client{ + Transport: transport, + } + + if !enableRedirects { + // Never follow redirects. + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } + return client +} + func readAllFromStdin() (map[string][]byte, error) { m := make(map[string][]byte) stat, _ := os.Stdin.Stat()