From 7ff01ff0ff581972037b9bdcaba5c7cdea7b2817 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Wed, 12 Aug 2026 09:53:20 +0200 Subject: [PATCH 01/16] [oxcmd,omcmd] Use pointer receiver for `CmdObjectCreate` methods Fix: Struct CmdObjectCreate has methods on both value and pointer receivers --- core/omcmd/object_create.go | 16 ++++++++-------- core/oxcmd/object_create.go | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) 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): From 8e15dd04b956d2eb18bb10380c9c2ec8c41f1ca1 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Thu, 13 Aug 2026 12:33:53 +0200 Subject: [PATCH 02/16] [core/rawconfig] Add SSRF whitelist and blocked CIDR configuration handling It defines defaults SSRFWhiteListUrl and SSRFBlockedCIDR from sysconfig like files for opensvc. With following defaults when OSVC_SSRF_WHITELIST_URL and OSVC_SSRF_BLOCKED_CIDR are not defined. SSRFWhiteListUrl = []string{ "https://raw.githubusercontent.com", "https://gitlab.com", } SSRFBlockedCIDR = []string{ // RFC 1918 private address ranges "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // RFC 4193 Unique Local IPv6 Unicast Addresses "fc00::/7", // Loopback ranges "127.0.0.0/8", // RFC 1122 "::1/128", // RFC 4291 } --- core/rawconfig/node.go | 9 +++++++++ core/rawconfig/ssrf_policy.go | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 core/rawconfig/ssrf_policy.go diff --git a/core/rawconfig/node.go b/core/rawconfig/node.go index 16c4668f6..e0f92b337 100644 --- a/core/rawconfig/node.go +++ b/core/rawconfig/node.go @@ -28,6 +28,9 @@ var ( Colorize *palette.ColorPaletteFunc Color *palette.ColorPalette Paths AgentPaths + + SSRFWhiteListUrl []string + SSRFBlockedCIDR []string ) func init() { @@ -43,6 +46,12 @@ func Load(env map[string]string) { } } + if env != nil { + allowed, _ := env["OSVC_SSRF_WHITELIST_URL"] + blocked, _ := env["OSVC_SSRF_BLOCKED_CIDR"] + setSSRF(allowed, blocked) + } + 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..07047e9a9 --- /dev/null +++ b/core/rawconfig/ssrf_policy.go @@ -0,0 +1,37 @@ +package rawconfig + +import "strings" + +var ( + defaultWhitelistURLs = []string{ + "https://raw.githubusercontent.com", + "https://gitlab.com", + } + + defaultBlockedCIDRs = []string{ + // RFC 1918 private address ranges + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + + // RFC 4193 Unique Local IPv6 Unicast Addresses + "fc00::/7", + + // Loopback ranges + "127.0.0.0/8", // RFC 1122 + "::1/128", // RFC 4291 + } +) + +func setSSRF(whiteListUrl, blockedCIDR string) { + if whiteListUrl == "" { + SSRFWhiteListUrl = defaultWhitelistURLs + } else { + SSRFWhiteListUrl = strings.Fields(whiteListUrl) + } + if blockedCIDR == "" { + SSRFBlockedCIDR = defaultBlockedCIDRs + } else { + SSRFBlockedCIDR = strings.Fields(blockedCIDR) + } +} From c6066c6189c2181991cc3ab0e62aa371e596082e Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Thu, 13 Aug 2026 12:35:45 +0200 Subject: [PATCH 03/16] [commoncmd] Fix error return in `DataFromConfigURI` when `u.Fetch()` fails --- core/commoncmd/object_create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From f1a0125ff70d9986b0e0c07ede7691e5235ffb62 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Thu, 13 Aug 2026 12:36:31 +0200 Subject: [PATCH 04/16] [util/httppolicy] Add HTTP policy validation library and test suite Provide URL validation utility with whitelisting, CIDR blocking, and raw request handling. Includes comprehensive test coverage. --- util/httppolicy/main.go | 140 ++++++++++++++++++++++++ util/httppolicy/main_test.go | 206 +++++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 util/httppolicy/main.go create mode 100644 util/httppolicy/main_test.go diff --git a/util/httppolicy/main.go b/util/httppolicy/main.go new file mode 100644 index 000000000..2c2fc4c2f --- /dev/null +++ b/util/httppolicy/main.go @@ -0,0 +1,140 @@ +package httppolicy + +import ( + "fmt" + "net" + "net/url" + "path" + "strings" +) + +type ( + T struct { + WhiteListUrls []string + BlockedCIDRs []string + } +) + +// New creates and returns a new instance of T initialized with the provided whitelist URLs and blocked CIDRs. +func New(whiteListUrls, blockedCIDRs []string) *T { + return &T{ + WhiteListUrls: append([]string(nil), whiteListUrls...), + BlockedCIDRs: append([]string(nil), blockedCIDRs...), + } +} + +// Check checks if the provided raw URL is valid, matches whitelist criteria, +// and is not blocked by CIDR rules. +func (t *T) Check(rawURL string) error { + if t == nil { + return fmt.Errorf("unexpected validator") + } + + parsedURL, err := parseRequestURL(rawURL) + if err != nil { + return err + } + + host := parsedURL.Hostname() + if host == "" { + return fmt.Errorf("empty host") + } + + if !isURLWhitelisted(parsedURL, t.WhiteListUrls) { + return fmt.Errorf("url %s not in white list url %s", parsedURL, t.WhiteListUrls) + } + + blockedNets := parseBlockedCIDRs(t.BlockedCIDRs) + return rejectBlockedResolvedIPs(host, blockedNets) +} + +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 isURLWhitelisted(requestURL *url.URL, whitelistURLs []string) bool { + host := requestURL.Hostname() + scheme := requestURL.Scheme + port := requestURL.Port() + normalizedPath := path.Clean(requestURL.Path) + + for _, whitelistURL := range whitelistURLs { + allowedURL, err := url.Parse(whitelistURL) + if err != nil { + continue + } + + if isAllowedURLMatch(scheme, host, port, normalizedPath, allowedURL) { + return true + } + } + + return false +} + +func isAllowedURLMatch(scheme, host, port, normalizedPath string, allowedURL *url.URL) bool { + if allowedURL.Scheme != "http" && allowedURL.Scheme != "https" { + return false + } + + allowedHost := allowedURL.Hostname() + if allowedHost == "" { + return false + } + + if scheme != allowedURL.Scheme || host != allowedHost || port != allowedURL.Port() { + return false + } + + if allowedURL.Path == "" { + return true + } + + allowedPath := path.Clean(allowedURL.Path) + return normalizedPath == allowedPath || strings.HasPrefix(normalizedPath, allowedPath+"/") +} + +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 rejectBlockedResolvedIPs(host string, blockedNets []*net.IPNet) error { + ips, err := net.LookupIP(host) + if err != nil { + return err + } + + for _, ip := range ips { + for _, blockedNet := range blockedNets { + if blockedNet.Contains(ip) { + return fmt.Errorf("ip %s from %s is blocked by CIDR %s", ip, host, blockedNet) + } + } + } + + return nil +} diff --git a/util/httppolicy/main_test.go b/util/httppolicy/main_test.go new file mode 100644 index 000000000..a5c950714 --- /dev/null +++ b/util/httppolicy/main_test.go @@ -0,0 +1,206 @@ +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://raw.githubusercontent.com", + expectedErr: false, + }, + { + name: "allows_https_github_host_with_trailing_slash", + inputURL: "https://raw.githubusercontent.com/", + expectedErr: false, + }, + { + name: "allows_https_gitlab_host_only", + inputURL: "https://gitlab.com", + expectedErr: false, + }, + { + name: "rejects_http_scheme", + inputURL: "http://raw.githubusercontent.com", + 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_allowlisted_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: "rejects_ipv6_loopback", + inputURL: "https://[::1]", + expectedErr: true, + }, + { + 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", + 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.SSRFWhiteListUrl, rawconfig.SSRFBlockedCIDR) + + v.WhiteListUrls = append(v.WhiteListUrls, "https://8.8.8.8", "https://github.com:8888/opensvc/om3") + + // to verify loopback ranges + v.WhiteListUrls = append(v.WhiteListUrls, "https://localhost", "https://[::1]") + + // to verify invalid host lookup + v.WhiteListUrls = append(v.WhiteListUrls, "https://invalid-host-lookup.opensvc.com") + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Logf("Check url: %s", tc.inputURL) + err := v.Check(tc.inputURL) + if tc.expectedErr { + assert.Errorf(t, err, "expected error for %s", tc.inputURL) + if err != nil { + t.Logf("got expected error: %s", err) + } + } else { + assert.NoError(t, err) + } + }) + } + }) + + t.Run("zero checker must reject all urls", func(t *testing.T) { + v := New(nil, nil) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Logf("Check url: %s", tc.inputURL) + err := v.Check(tc.inputURL) + assert.Errorf(t, err, "expected error for %s", tc.inputURL) + if err != nil { + t.Logf("got expected error: %s", err) + } + }) + } + }) + +} From e3e92a075611a4a683215562b1798e606629344f Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Thu, 13 Aug 2026 12:37:22 +0200 Subject: [PATCH 05/16] [util/uri] Add HTTP policy check to validate URLs against SSRF rules --- core/commoncmd/flags.go | 2 +- util/uri/uri.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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/util/uri/uri.go b/util/uri/uri.go index e43306c84..3fbf9f0fd 100644 --- a/util/uri/uri.go +++ b/util/uri/uri.go @@ -12,6 +12,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,6 +34,10 @@ func New(s string) T { } func (t T) Fetch() (string, error) { + policy := httppolicy.New(rawconfig.SSRFWhiteListUrl, rawconfig.SSRFBlockedCIDR) + if err := policy.Check(t.uri); err != nil { + return "", err + } resp, err := http.Get(t.uri) if err != nil { return "", err From 7a7ae80b4f1bb93b51ac50feb589724444968920 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 14 Aug 2026 11:27:55 +0200 Subject: [PATCH 06/16] [core/rawconfig] Define more flexible SSRF URL and CIDR configurations Introduce SSRFAllowedURL, SSRFBlockedURL, SSRFAllowedCIDR, and SSRFBlockedCIDR to replace the existing whitelist and blocked CIDR configurations, improving configuration granularity and consistency with new defaults. --- core/rawconfig/node.go | 14 +++++++++----- core/rawconfig/ssrf_policy.go | 33 ++++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/core/rawconfig/node.go b/core/rawconfig/node.go index e0f92b337..8f2401bc3 100644 --- a/core/rawconfig/node.go +++ b/core/rawconfig/node.go @@ -29,8 +29,10 @@ var ( Color *palette.ColorPalette Paths AgentPaths - SSRFWhiteListUrl []string - SSRFBlockedCIDR []string + SSRFAllowedURL []string + SSRFBlockedURL []string + SSRFAllowedCIDR []string + SSRFBlockedCIDR []string ) func init() { @@ -47,9 +49,11 @@ func Load(env map[string]string) { } if env != nil { - allowed, _ := env["OSVC_SSRF_WHITELIST_URL"] - blocked, _ := env["OSVC_SSRF_BLOCKED_CIDR"] - setSSRF(allowed, blocked) + blockedURL, _ := env["OSVC_SSRF_BLOCKED_URL"] + allowedURL, _ := env["OSVC_SSRF_ALLOWED_URL"] + blockedCIDR, _ := env["OSVC_SSRF_BLOCKED_CIDR"] + allowedCIDR, _ := env["OSVC_SSRF_ALLOWED_CIDR"] + setSSRF(allowedURL, blockedURL, allowedCIDR, blockedCIDR) } var root string diff --git a/core/rawconfig/ssrf_policy.go b/core/rawconfig/ssrf_policy.go index 07047e9a9..077ac1d75 100644 --- a/core/rawconfig/ssrf_policy.go +++ b/core/rawconfig/ssrf_policy.go @@ -3,12 +3,17 @@ package rawconfig import "strings" var ( - defaultWhitelistURLs = []string{ - "https://raw.githubusercontent.com", - "https://gitlab.com", + defaultSSRFAllowedURL = []string{ + "https://raw.githubusercontent.com/opensvc/opensvc_templates", } - defaultBlockedCIDRs = []string{ + defaultSSRFBlockedURL = []string{ + "*", + } + + defaultSSRFAllowedCIDR = []string{} + + defaultSSRFBlockedCIDR = []string{ // RFC 1918 private address ranges "10.0.0.0/8", "172.16.0.0/12", @@ -23,14 +28,24 @@ var ( } ) -func setSSRF(whiteListUrl, blockedCIDR string) { - if whiteListUrl == "" { - SSRFWhiteListUrl = defaultWhitelistURLs +func setSSRF(allowedURL, blockedURL, AllowedCIDR, blockedCIDR string) { + if allowedURL == "" { + SSRFAllowedURL = defaultSSRFAllowedURL + } else { + SSRFAllowedURL = strings.Fields(allowedURL) + } + if blockedURL == "" { + SSRFBlockedURL = defaultSSRFBlockedURL + } else { + SSRFBlockedURL = strings.Fields(blockedURL) + } + if AllowedCIDR == "" { + SSRFAllowedCIDR = defaultSSRFAllowedCIDR } else { - SSRFWhiteListUrl = strings.Fields(whiteListUrl) + SSRFAllowedCIDR = strings.Fields(AllowedCIDR) } if blockedCIDR == "" { - SSRFBlockedCIDR = defaultBlockedCIDRs + SSRFBlockedCIDR = defaultSSRFBlockedCIDR } else { SSRFBlockedCIDR = strings.Fields(blockedCIDR) } From beaa5065b7a94e0615c1cf7b5a1157c5de1bead9 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 14 Aug 2026 11:30:24 +0200 Subject: [PATCH 07/16] [core/rawconfig] Add SSRF redirect configuration support Introduce `SSRFEnableRedirects` to allow configuration of redirect handling in SSRF protections. Update `setSSRF` to include redirect behavior based on `OSVC_SSRF_ENABLE_REDIRECTS` environment variable. Default SSRFEnableRedirects value is false --- core/rawconfig/node.go | 12 +++++++----- core/rawconfig/ssrf_policy.go | 9 ++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/core/rawconfig/node.go b/core/rawconfig/node.go index 8f2401bc3..81d982843 100644 --- a/core/rawconfig/node.go +++ b/core/rawconfig/node.go @@ -29,10 +29,11 @@ var ( Color *palette.ColorPalette Paths AgentPaths - SSRFAllowedURL []string - SSRFBlockedURL []string - SSRFAllowedCIDR []string - SSRFBlockedCIDR []string + SSRFAllowedURL []string + SSRFBlockedURL []string + SSRFAllowedCIDR []string + SSRFBlockedCIDR []string + SSRFEnableRedirects bool ) func init() { @@ -53,7 +54,8 @@ func Load(env map[string]string) { allowedURL, _ := env["OSVC_SSRF_ALLOWED_URL"] blockedCIDR, _ := env["OSVC_SSRF_BLOCKED_CIDR"] allowedCIDR, _ := env["OSVC_SSRF_ALLOWED_CIDR"] - setSSRF(allowedURL, blockedURL, allowedCIDR, blockedCIDR) + enableRedirects, _ := env["OSVC_SSRF_ENABLE_REDIRECTS"] + setSSRF(allowedURL, blockedURL, allowedCIDR, blockedCIDR, enableRedirects) } var root string diff --git a/core/rawconfig/ssrf_policy.go b/core/rawconfig/ssrf_policy.go index 077ac1d75..34038a2eb 100644 --- a/core/rawconfig/ssrf_policy.go +++ b/core/rawconfig/ssrf_policy.go @@ -28,7 +28,8 @@ var ( } ) -func setSSRF(allowedURL, blockedURL, AllowedCIDR, blockedCIDR string) { +// setSSRF configures SSRF protection settings, including allowed/blocked URLs, CIDR ranges, and redirect behavior. +func setSSRF(allowedURL, blockedURL, AllowedCIDR, blockedCIDR, enableRedirects string) { if allowedURL == "" { SSRFAllowedURL = defaultSSRFAllowedURL } else { @@ -49,4 +50,10 @@ func setSSRF(allowedURL, blockedURL, AllowedCIDR, blockedCIDR string) { } else { SSRFBlockedCIDR = strings.Fields(blockedCIDR) } + + // SSRFEnableRedirects is always false by default unless enableRedirects == "true" + SSRFEnableRedirects = false + if enableRedirects == "true" { + SSRFEnableRedirects = true + } } From aef2cde515b9b95c67361cb7d14fb1b145132b17 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 14 Aug 2026 12:20:28 +0200 Subject: [PATCH 08/16] [util/httppolicy] Improves Check This commit improves the Check function with more granular rules: - AllowedUrl: list of allowed urls - BlockedUrl: list of blocking url patterns - BlockedCIDR: list of blocked cidr - AllowedCIDR: list of allowed cidr exceptions of the BlockedCIDR list - Prevent TOCTOU bugs: the returned ip is the resolved ip that satisfies CIDR rules. Updated tests: === RUN TestCheck === RUN TestCheck/raw_config_checker main_test.go:210: policy AllowedCIDR: [127.0.0.10/32 127.0.0.2/32 fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128] main_test.go:211: policy BlockedUrl: [*] main_test.go:212: policy BlockedCIDR: [10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 127.0.0.0/8 ::1/128] main_test.go:213: policy AllowedCIDR: [127.0.0.10/32 127.0.0.2/32 fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128] === RUN TestCheck/raw_config_checker/allows_https_github_relay_v3_config_path main_test.go:216: Check url: https://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf main_test.go:224: url detected ip 2606:50c0:8000::154, port 443 --- PASS: TestCheck/raw_config_checker/allows_https_github_relay_v3_config_path (0.01s) === RUN TestCheck/raw_config_checker/allows_https_github_host_only main_test.go:216: Check url: https://www.github.com main_test.go:224: url detected ip 140.82.121.4, port 443 --- PASS: TestCheck/raw_config_checker/allows_https_github_host_only (0.00s) === RUN TestCheck/raw_config_checker/allows_https_github_host_with_trailing_slash main_test.go:216: Check url: https://www.github.com/ main_test.go:224: url detected ip 140.82.121.4, port 443 --- PASS: TestCheck/raw_config_checker/allows_https_github_host_with_trailing_slash (0.00s) === RUN TestCheck/raw_config_checker/rejects_http_scheme main_test.go:216: Check url: http://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf main_test.go:221: got expected error: reject url http://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf: url http://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_http_scheme (0.00s) === RUN TestCheck/raw_config_checker/rejects_file_scheme_with_empty_host main_test.go:216: Check url: file:///tmp/foo main_test.go:221: got expected error: empty host --- PASS: TestCheck/raw_config_checker/rejects_file_scheme_with_empty_host (0.00s) === RUN TestCheck/raw_config_checker/rejects_disallowed_port_on_allowlisted_host_path main_test.go:216: Check url: https://github.com:666/opensvc/om3 main_test.go:221: got expected error: reject url https://github.com:666/opensvc/om3: url https://github.com:666/opensvc/om3 is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_disallowed_port_on_allowlisted_host_path (0.00s) === RUN TestCheck/raw_config_checker/allows_exception_url_with_port_and_path main_test.go:216: Check url: https://github.com:8888/opensvc/om3 main_test.go:224: url detected ip 140.82.121.4, port 8888 --- PASS: TestCheck/raw_config_checker/allows_exception_url_with_port_and_path (0.00s) === RUN TestCheck/raw_config_checker/allows_subpath_under_allowlisted_path main_test.go:216: Check url: https://github.com:8888/opensvc/om3/bar main_test.go:224: url detected ip 140.82.121.4, port 8888 --- PASS: TestCheck/raw_config_checker/allows_subpath_under_allowlisted_path (0.00s) === RUN TestCheck/raw_config_checker/allows_normalized_path_within_allowlisted_path main_test.go:216: Check url: https://github.com:8888/opensvc/om3/../om3/bar main_test.go:224: url detected ip 140.82.121.4, port 8888 --- PASS: TestCheck/raw_config_checker/allows_normalized_path_within_allowlisted_path (0.00s) === RUN TestCheck/raw_config_checker/rejects_normalized_path_outside_allowlisted_path main_test.go:216: Check url: https://github.com:8888/opensvc/om3/../oc3/bar main_test.go:221: got expected error: reject url https://github.com:8888/opensvc/om3/../oc3/bar: url https://github.com:8888/opensvc/oc3/bar is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_normalized_path_outside_allowlisted_path (0.00s) === RUN TestCheck/raw_config_checker/rejects_escaped_dotdot_path_escape main_test.go:216: Check url: https://github.com:8888/opensvc/om3/%2e%2e/foo main_test.go:221: got expected error: reject url https://github.com:8888/opensvc/om3/../foo: url https://github.com:8888/opensvc/foo is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_escaped_dotdot_path_escape (0.00s) === RUN TestCheck/raw_config_checker/rejects_path_prefix_without_separator main_test.go:216: Check url: https://github.com:8888/opensvc/om3foo main_test.go:221: got expected error: reject url https://github.com:8888/opensvc/om3foo: url https://github.com:8888/opensvc/om3foo is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_path_prefix_without_separator (0.00s) === RUN TestCheck/raw_config_checker/rejects_path_outside_allowlisted_prefix main_test.go:216: Check url: https://github.com:8888/opensvc/foo/bar main_test.go:221: got expected error: reject url https://github.com:8888/opensvc/foo/bar: url https://github.com:8888/opensvc/foo/bar is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_path_outside_allowlisted_prefix (0.00s) === RUN TestCheck/raw_config_checker/rejects_file_scheme_with_remote_style_host main_test.go:216: Check url: file://raw.githubusercontent.com/tmp/foo main_test.go:221: got expected error: reject url file://raw.githubusercontent.com/tmp/foo: url file://raw.githubusercontent.com/tmp/foo is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_file_scheme_with_remote_style_host (0.00s) === RUN TestCheck/raw_config_checker/rejects_unknown_scheme main_test.go:216: Check url: foo://raw.githubusercontent.com main_test.go:221: got expected error: reject url foo://raw.githubusercontent.com: url foo://raw.githubusercontent.com/. is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_unknown_scheme (0.00s) === RUN TestCheck/raw_config_checker/rejects_disallowed_port_on_allowlisted_raw_github_host main_test.go:216: Check url: https://raw.githubusercontent.com:8888 main_test.go:221: got expected error: reject url https://raw.githubusercontent.com:8888: url https://raw.githubusercontent.com:8888/. is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_disallowed_port_on_allowlisted_raw_github_host (0.00s) === RUN TestCheck/raw_config_checker/rejects_non_allowlisted_host main_test.go:216: Check url: https://google.com main_test.go:221: got expected error: reject url https://google.com: url https://google.com/. is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_non_allowlisted_host (0.00s) === RUN TestCheck/raw_config_checker/rejects_ipv4_loopback_resolved_from_localhost main_test.go:216: Check url: https://localhost main_test.go:221: got expected error: reject host localhost ip 127.0.0.1: blocked net 127.0.0.0/8 and not in exception net list [127.0.0.10/32 127.0.0.2/32 fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128] --- PASS: TestCheck/raw_config_checker/rejects_ipv4_loopback_resolved_from_localhost (0.00s) === RUN TestCheck/raw_config_checker/block_cidr main_test.go:216: Check url: https://127.0.0.3/foo main_test.go:221: got expected error: reject host 127.0.0.3 ip 127.0.0.3: blocked net 127.0.0.0/8 and not in exception net list [127.0.0.10/32 127.0.0.2/32 fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128] --- PASS: TestCheck/raw_config_checker/block_cidr (0.00s) === RUN TestCheck/raw_config_checker/block_cidr_v4_mapped_v6 main_test.go:216: Check url: https://[::ffff:127.0.0.3]/foo main_test.go:221: got expected error: reject host ::ffff:127.0.0.3 ip 127.0.0.3: blocked net 127.0.0.0/8 and not in exception net list [127.0.0.10/32 127.0.0.2/32 fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128] --- PASS: TestCheck/raw_config_checker/block_cidr_v4_mapped_v6 (0.00s) === RUN TestCheck/raw_config_checker/allow_cidr_exception_v4_mapped_v6 main_test.go:216: Check url: https://[::ffff:127.0.0.2]/foo main_test.go:224: url detected ip 127.0.0.2, port 443 --- PASS: TestCheck/raw_config_checker/allow_cidr_exception_v4_mapped_v6 (0.00s) === RUN TestCheck/raw_config_checker/allow_cidr_exception main_test.go:216: Check url: https://127.0.0.2/foo main_test.go:224: url detected ip 127.0.0.2, port 443 --- PASS: TestCheck/raw_config_checker/allow_cidr_exception (0.00s) === RUN TestCheck/raw_config_checker/rejects_ipv6_loopback main_test.go:216: Check url: https://[::1] main_test.go:221: got expected error: reject host ::1 ip ::1: blocked net ::1/128 and not in exception net list [127.0.0.10/32 127.0.0.2/32 fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128] --- PASS: TestCheck/raw_config_checker/rejects_ipv6_loopback (0.00s) === RUN TestCheck/raw_config_checker/reject_ula_ipv6_cidr main_test.go:216: Check url: https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] main_test.go:221: got expected error: reject host fd7a:115c:a1e0:ab12:4843:cd96:626b:626b ip fd7a:115c:a1e0:ab12:4843:cd96:626b:626b: blocked net fc00::/7 and not in exception net list [127.0.0.10/32 127.0.0.2/32 fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128] --- PASS: TestCheck/raw_config_checker/reject_ula_ipv6_cidr (0.00s) === RUN TestCheck/raw_config_checker/allow_ula_ipv6_cidr_exception main_test.go:216: Check url: https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b] main_test.go:224: url detected ip fd7a:115c:a1e0:ab12:4843:cd96:626b:430b, port 443 --- PASS: TestCheck/raw_config_checker/allow_ula_ipv6_cidr_exception (0.00s) === RUN TestCheck/raw_config_checker/rejects_private_ipv4_address main_test.go:216: Check url: https://10.0.0.1 main_test.go:221: got expected error: reject url https://10.0.0.1: url https://10.0.0.1/. is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_private_ipv4_address (0.00s) === RUN TestCheck/raw_config_checker/rejects_unique_local_ipv6_address main_test.go:216: Check url: https://[fc00::1] main_test.go:221: got expected error: reject url https://[fc00::1]: url https://[fc00::1]/. is blocked by pattern * and not in allowed list [https://raw.githubusercontent.com/opensvc/opensvc_templates https://8.8.8.8 https://github.com:8888/opensvc/om3 https://www.github.com https://localhost https://[::1] https://invalid-host-lookup.opensvc.com https://127.0.0.2/foo https://127.0.0.3/foo https://[::ffff:127.0.0.2]/foo https://[::ffff:127.0.0.3]/foo https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]] --- PASS: TestCheck/raw_config_checker/rejects_unique_local_ipv6_address (0.00s) === RUN TestCheck/raw_config_checker/allows_public_ipv4_address main_test.go:216: Check url: https://8.8.8.8 main_test.go:224: url detected ip 8.8.8.8, port 443 --- PASS: TestCheck/raw_config_checker/allows_public_ipv4_address (0.00s) === RUN TestCheck/raw_config_checker/rejects_malformed_url main_test.go:216: Check url: :/invalid-url main_test.go:221: got expected error: parse ":/invalid-url": missing protocol scheme --- PASS: TestCheck/raw_config_checker/rejects_malformed_url (0.00s) === RUN TestCheck/raw_config_checker/rejects_unresolvable_host main_test.go:216: Check url: https://invalid-host-lookup.opensvc.com main_test.go:221: got expected error: lookup invalid-host-lookup.opensvc.com: no such host --- PASS: TestCheck/raw_config_checker/rejects_unresolvable_host (0.00s) === RUN TestCheck/raw_config_checker/rejects_https_url_with_empty_host main_test.go:216: Check url: https:// main_test.go:221: got expected error: empty host --- PASS: TestCheck/raw_config_checker/rejects_https_url_with_empty_host (0.00s) === RUN TestCheck/raw_config_checker/rejects_empty_url main_test.go:216: Check url: main_test.go:221: got expected error: parse "": empty url --- PASS: TestCheck/raw_config_checker/rejects_empty_url (0.00s) === RUN TestCheck/raw_config_checker/rejects_relative_path main_test.go:216: Check url: foo/bar main_test.go:221: got expected error: parse "foo/bar": invalid URI for request --- PASS: TestCheck/raw_config_checker/rejects_relative_path (0.00s) === RUN TestCheck/raw_config_checker/rejects_dot_relative_path main_test.go:216: Check url: ./bar/foo main_test.go:221: got expected error: parse "./bar/foo": invalid URI for request --- PASS: TestCheck/raw_config_checker/rejects_dot_relative_path (0.00s) --- PASS: TestCheck/raw_config_checker (0.03s) === RUN TestCheck/zero_checker_must_reject_all_urls === RUN TestCheck/zero_checker_must_reject_all_urls/allows_https_github_relay_v3_config_path main_test.go:236: Check url: https://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf: url https://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allows_https_github_relay_v3_config_path (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allows_https_github_host_only main_test.go:236: Check url: https://www.github.com main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://www.github.com: url https://www.github.com/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allows_https_github_host_only (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allows_https_github_host_with_trailing_slash main_test.go:236: Check url: https://www.github.com/ main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://www.github.com/: url https://www.github.com/ is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allows_https_github_host_with_trailing_slash (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_http_scheme main_test.go:236: Check url: http://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url http://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf: url http://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_http_scheme (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_file_scheme_with_empty_host main_test.go:236: Check url: file:///tmp/foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: empty host --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_file_scheme_with_empty_host (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_disallowed_port_on_allowlisted_host_path main_test.go:236: Check url: https://github.com:666/opensvc/om3 main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://github.com:666/opensvc/om3: url https://github.com:666/opensvc/om3 is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_disallowed_port_on_allowlisted_host_path (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allows_exception_url_with_port_and_path main_test.go:236: Check url: https://github.com:8888/opensvc/om3 main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://github.com:8888/opensvc/om3: url https://github.com:8888/opensvc/om3 is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allows_exception_url_with_port_and_path (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allows_subpath_under_allowlisted_path main_test.go:236: Check url: https://github.com:8888/opensvc/om3/bar main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://github.com:8888/opensvc/om3/bar: url https://github.com:8888/opensvc/om3/bar is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allows_subpath_under_allowlisted_path (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allows_normalized_path_within_allowlisted_path main_test.go:236: Check url: https://github.com:8888/opensvc/om3/../om3/bar main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://github.com:8888/opensvc/om3/../om3/bar: url https://github.com:8888/opensvc/om3/bar is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allows_normalized_path_within_allowlisted_path (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_normalized_path_outside_allowlisted_path main_test.go:236: Check url: https://github.com:8888/opensvc/om3/../oc3/bar main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://github.com:8888/opensvc/om3/../oc3/bar: url https://github.com:8888/opensvc/oc3/bar is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_normalized_path_outside_allowlisted_path (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_escaped_dotdot_path_escape main_test.go:236: Check url: https://github.com:8888/opensvc/om3/%2e%2e/foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://github.com:8888/opensvc/om3/../foo: url https://github.com:8888/opensvc/foo is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_escaped_dotdot_path_escape (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_path_prefix_without_separator main_test.go:236: Check url: https://github.com:8888/opensvc/om3foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://github.com:8888/opensvc/om3foo: url https://github.com:8888/opensvc/om3foo is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_path_prefix_without_separator (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_path_outside_allowlisted_prefix main_test.go:236: Check url: https://github.com:8888/opensvc/foo/bar main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://github.com:8888/opensvc/foo/bar: url https://github.com:8888/opensvc/foo/bar is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_path_outside_allowlisted_prefix (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_file_scheme_with_remote_style_host main_test.go:236: Check url: file://raw.githubusercontent.com/tmp/foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url file://raw.githubusercontent.com/tmp/foo: url file://raw.githubusercontent.com/tmp/foo is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_file_scheme_with_remote_style_host (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_unknown_scheme main_test.go:236: Check url: foo://raw.githubusercontent.com main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url foo://raw.githubusercontent.com: url foo://raw.githubusercontent.com/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_unknown_scheme (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_disallowed_port_on_allowlisted_raw_github_host main_test.go:236: Check url: https://raw.githubusercontent.com:8888 main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://raw.githubusercontent.com:8888: url https://raw.githubusercontent.com:8888/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_disallowed_port_on_allowlisted_raw_github_host (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_non_allowlisted_host main_test.go:236: Check url: https://google.com main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://google.com: url https://google.com/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_non_allowlisted_host (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_ipv4_loopback_resolved_from_localhost main_test.go:236: Check url: https://localhost main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://localhost: url https://localhost/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_ipv4_loopback_resolved_from_localhost (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/block_cidr main_test.go:236: Check url: https://127.0.0.3/foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://127.0.0.3/foo: url https://127.0.0.3/foo is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/block_cidr (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/block_cidr_v4_mapped_v6 main_test.go:236: Check url: https://[::ffff:127.0.0.3]/foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://[::ffff:127.0.0.3]/foo: url https://[::ffff:127.0.0.3]/foo is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/block_cidr_v4_mapped_v6 (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allow_cidr_exception_v4_mapped_v6 main_test.go:236: Check url: https://[::ffff:127.0.0.2]/foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://[::ffff:127.0.0.2]/foo: url https://[::ffff:127.0.0.2]/foo is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allow_cidr_exception_v4_mapped_v6 (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allow_cidr_exception main_test.go:236: Check url: https://127.0.0.2/foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://127.0.0.2/foo: url https://127.0.0.2/foo is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allow_cidr_exception (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_ipv6_loopback main_test.go:236: Check url: https://[::1] main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://[::1]: url https://[::1]/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_ipv6_loopback (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/reject_ula_ipv6_cidr main_test.go:236: Check url: https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b] main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b]: url https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b]/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/reject_ula_ipv6_cidr (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allow_ula_ipv6_cidr_exception main_test.go:236: Check url: https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b] main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]: url https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allow_ula_ipv6_cidr_exception (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_private_ipv4_address main_test.go:236: Check url: https://10.0.0.1 main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://10.0.0.1: url https://10.0.0.1/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_private_ipv4_address (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_unique_local_ipv6_address main_test.go:236: Check url: https://[fc00::1] main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://[fc00::1]: url https://[fc00::1]/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_unique_local_ipv6_address (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/allows_public_ipv4_address main_test.go:236: Check url: https://8.8.8.8 main_test.go:239: url detected ip , port main_test.go:241: got expected error: reject url https://8.8.8.8: url https://8.8.8.8/. is blocked by pattern * and not in allowed list [] --- PASS: TestCheck/zero_checker_must_reject_all_urls/allows_public_ipv4_address (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_malformed_url main_test.go:236: Check url: :/invalid-url main_test.go:239: url detected ip , port main_test.go:241: got expected error: parse ":/invalid-url": missing protocol scheme --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_malformed_url (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_unresolvable_host main_test.go:236: Check url: https://invalid-host-lookup.opensvc.com main_test.go:239: url detected ip , port main_test.go:241: got expected error: lookup invalid-host-lookup.opensvc.com: no such host --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_unresolvable_host (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_https_url_with_empty_host main_test.go:236: Check url: https:// main_test.go:239: url detected ip , port main_test.go:241: got expected error: empty host --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_https_url_with_empty_host (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_empty_url main_test.go:236: Check url: main_test.go:239: url detected ip , port main_test.go:241: got expected error: parse "": empty url --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_empty_url (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_relative_path main_test.go:236: Check url: foo/bar main_test.go:239: url detected ip , port main_test.go:241: got expected error: parse "foo/bar": invalid URI for request --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_relative_path (0.00s) === RUN TestCheck/zero_checker_must_reject_all_urls/rejects_dot_relative_path main_test.go:236: Check url: ./bar/foo main_test.go:239: url detected ip , port main_test.go:241: got expected error: parse "./bar/foo": invalid URI for request --- PASS: TestCheck/zero_checker_must_reject_all_urls/rejects_dot_relative_path (0.00s) --- PASS: TestCheck/zero_checker_must_reject_all_urls (0.02s) --- PASS: TestCheck (0.05s) PASS --- util/httppolicy/main.go | 119 ++++++++++++++++++++++++++--------- util/httppolicy/main_test.go | 73 ++++++++++++++++----- 2 files changed, 147 insertions(+), 45 deletions(-) diff --git a/util/httppolicy/main.go b/util/httppolicy/main.go index 2c2fc4c2f..efc78f369 100644 --- a/util/httppolicy/main.go +++ b/util/httppolicy/main.go @@ -6,46 +6,85 @@ import ( "net/url" "path" "strings" + + "github.com/danwakefield/fnmatch" ) type ( T struct { - WhiteListUrls []string - BlockedCIDRs []string + // 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(whiteListUrls, blockedCIDRs []string) *T { +func New(allowedURL, blockedURL, allowedCIDR, blockedCIDR []string) *T { return &T{ - WhiteListUrls: append([]string(nil), whiteListUrls...), - BlockedCIDRs: append([]string(nil), blockedCIDRs...), + AllowedUrl: append([]string(nil), allowedURL...), + BlockedUrl: append([]string(nil), blockedURL...), + AllowedCIDR: append([]string(nil), allowedCIDR...), + BlockedCIDR: append([]string(nil), blockedCIDR...), } } -// Check checks if the provided raw URL is valid, matches whitelist criteria, -// and is not blocked by CIDR rules. -func (t *T) Check(rawURL string) error { +// 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 fmt.Errorf("unexpected validator") + return nil, "", fmt.Errorf("unexpected validator") } parsedURL, err := parseRequestURL(rawURL) if err != nil { - return err + return nil, "", err } host := parsedURL.Hostname() if host == "" { - return fmt.Errorf("empty host") + return nil, "", fmt.Errorf("empty host") + } + var ips []net.IP + ips, err = net.LookupIP(host) + if err != nil { + return nil, "", err } - if !isURLWhitelisted(parsedURL, t.WhiteListUrls) { - return fmt.Errorf("url %s not in white list url %s", parsedURL, t.WhiteListUrls) + if err := rejectURL(parsedURL, t.AllowedUrl, t.BlockedUrl); err != nil { + return nil, "", fmt.Errorf("reject url %s: %w", parsedURL, err) } - blockedNets := parseBlockedCIDRs(t.BlockedCIDRs) - return rejectBlockedResolvedIPs(host, blockedNets) + allowedNets := parseBlockedCIDRs(t.AllowedCIDR) + blockedNets := parseBlockedCIDRs(t.BlockedCIDR) + if ip, err = rejectIPs(ips, allowedNets, blockedNets); err != nil { + return nil, "", fmt.Errorf("reject 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) { @@ -65,24 +104,42 @@ func parseRequestURL(rawURL string) (*url.URL, error) { return parsedURL, nil } -func isURLWhitelisted(requestURL *url.URL, whitelistURLs []string) bool { +func rejectURL(requestURL *url.URL, allowed, blocked []string) error { host := requestURL.Hostname() scheme := requestURL.Scheme port := requestURL.Port() normalizedPath := path.Clean(requestURL.Path) - for _, whitelistURL := range whitelistURLs { - allowedURL, err := url.Parse(whitelistURL) + // Verify exception from the allowed list + for _, u := range allowed { + allowedURL, err := url.Parse(u) if err != nil { continue } if isAllowedURLMatch(scheme, host, port, normalizedPath, allowedURL) { - return true + return nil + } + } + + // verify if match blocked url + requestURL = &url.URL{ + Scheme: requestURL.Scheme, + Host: requestURL.Host, + Path: path.Clean(requestURL.Path), + } + + if len(blocked) == 0 { + blocked = []string{"*"} + } + requestURLString := requestURL.String() + for _, u := range blocked { + if fnmatch.Match(u, requestURLString, 0) { + return fmt.Errorf("url %s is blocked by pattern %s and not in allowed list %s", requestURLString, u, allowed) } } - return false + return nil } func isAllowedURLMatch(scheme, host, port, normalizedPath string, allowedURL *url.URL) bool { @@ -122,19 +179,23 @@ func parseBlockedCIDRs(blockedCIDRs []string) []*net.IPNet { return blockedNets } -func rejectBlockedResolvedIPs(host string, blockedNets []*net.IPNet) error { - ips, err := net.LookupIP(host) - if err != nil { - return err +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 _, ip = range ips { for _, blockedNet := range blockedNets { if blockedNet.Contains(ip) { - return fmt.Errorf("ip %s from %s is blocked by CIDR %s", ip, host, blockedNet) + // verify exceptions + for _, allowed := range allowedNet { + if allowed.Contains(ip) { + return ip, nil + } + } + return ip, fmt.Errorf("blocked net %s and not in exception net list %s", blockedNet, allowedNet) } } } - - return nil + // 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 index a5c950714..29dec7602 100644 --- a/util/httppolicy/main_test.go +++ b/util/httppolicy/main_test.go @@ -21,22 +21,17 @@ func TestCheck(t *testing.T) { }, { name: "allows_https_github_host_only", - inputURL: "https://raw.githubusercontent.com", + inputURL: "https://www.github.com", expectedErr: false, }, { name: "allows_https_github_host_with_trailing_slash", - inputURL: "https://raw.githubusercontent.com/", - expectedErr: false, - }, - { - name: "allows_https_gitlab_host_only", - inputURL: "https://gitlab.com", + inputURL: "https://www.github.com/", expectedErr: false, }, { name: "rejects_http_scheme", - inputURL: "http://raw.githubusercontent.com", + inputURL: "http://raw.githubusercontent.com/opensvc/opensvc_templates/refs/heads/main/relay-v3/relay-v3.conf", expectedErr: true, }, { @@ -50,7 +45,7 @@ func TestCheck(t *testing.T) { expectedErr: true, }, { - name: "allows_allowlisted_port_and_path", + name: "allows_exception_url_with_port_and_path", inputURL: "https://github.com:8888/opensvc/om3", expectedErr: false, }, @@ -109,11 +104,41 @@ func TestCheck(t *testing.T) { 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: "reject_ula_ipv6_cidr", + inputURL: "https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b]", + expectedErr: true, + }, + { + name: "allow_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", @@ -162,26 +187,41 @@ func TestCheck(t *testing.T) { } t.Run("raw config checker", func(t *testing.T) { - v := New(rawconfig.SSRFWhiteListUrl, rawconfig.SSRFBlockedCIDR) + v := New(rawconfig.SSRFAllowedURL, rawconfig.SSRFBlockedURL, rawconfig.SSRFAllowedCIDR, rawconfig.SSRFBlockedCIDR) - v.WhiteListUrls = append(v.WhiteListUrls, "https://8.8.8.8", "https://github.com:8888/opensvc/om3") + v.AllowedUrl = append(v.AllowedUrl, "https://8.8.8.8", "https://github.com:8888/opensvc/om3", "https://www.github.com") // to verify loopback ranges - v.WhiteListUrls = append(v.WhiteListUrls, "https://localhost", "https://[::1]") + v.AllowedUrl = append(v.AllowedUrl, "https://localhost", "https://[::1]") // to verify invalid host lookup - v.WhiteListUrls = append(v.WhiteListUrls, "https://invalid-host-lookup.opensvc.com") + v.AllowedUrl = append(v.AllowedUrl, "https://invalid-host-lookup.opensvc.com") + + 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 AllowedCIDR: %s", v.AllowedCIDR) + t.Logf("policy BlockedUrl: %s", v.BlockedUrl) + t.Logf("policy BlockedCIDR: %s", v.BlockedCIDR) + t.Logf("policy AllowedCIDR: %s", v.AllowedCIDR) for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Logf("Check url: %s", tc.inputURL) - err := v.Check(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("got expected error: %s", err) } } else { + t.Logf("url detected ip %s, port %s", ip, port) assert.NoError(t, err) } }) @@ -189,13 +229,14 @@ func TestCheck(t *testing.T) { }) t.Run("zero checker must reject all urls", func(t *testing.T) { - v := New(nil, nil) + 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) - err := v.Check(tc.inputURL) + ip, port, err := v.Check(tc.inputURL) assert.Errorf(t, err, "expected error for %s", tc.inputURL) + t.Logf("url detected ip %s, port %s", ip, port) if err != nil { t.Logf("got expected error: %s", err) } From e395c07704ada9d75671a162e62158761977dc6d Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 14 Aug 2026 12:29:34 +0200 Subject: [PATCH 09/16] [util/uri] Introduce per-IP HTTP client for SSRF protections This commit prevents TOCTOU bugs: GET request now use the returned checker ip and it disable the allowed redirects by default. Enhance `Fetch` to use a specific HTTP client for the resolved IP and port from `Check`. Add support for redirect control and network-layer policy enforcement within `clientForIP`. --- util/uri/uri.go | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/util/uri/uri.go b/util/uri/uri.go index 3fbf9f0fd..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" @@ -34,11 +36,15 @@ func New(s string) T { } func (t T) Fetch() (string, error) { - policy := httppolicy.New(rawconfig.SSRFWhiteListUrl, rawconfig.SSRFBlockedCIDR) - if err := policy.Check(t.uri); err != nil { + 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 } - resp, err := http.Get(t.uri) + + client := clientForIP(ip, port, rawconfig.SSRFEnableRedirects) + resp, err = client.Get(t.uri) if err != nil { return "", err } @@ -111,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() From 8d5292f2bd6a0c39a84498fea8dba0a307a75fa0 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 14 Aug 2026 12:53:42 +0200 Subject: [PATCH 10/16] [core/rawconfig] Document default SSRF policy configurations in `setSSRF` // OSVC_SSRF_ALLOWED_URL = https://raw.githubusercontent.com/opensvc/opensvc_templates // OSVC_SSRF_BLOCKED_URL = * // OSVC_SSRF_BLOCKED_CIDR = 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 127.0.0.0/8 ::1/128 // OSVC_SSRF_ALLOWED_CIDR = // OSVC_SSRF_ENABLE_REDIRECTS = false --- core/rawconfig/ssrf_policy.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/rawconfig/ssrf_policy.go b/core/rawconfig/ssrf_policy.go index 34038a2eb..c959603ab 100644 --- a/core/rawconfig/ssrf_policy.go +++ b/core/rawconfig/ssrf_policy.go @@ -29,6 +29,12 @@ var ( ) // 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 = 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 127.0.0.0/8 ::1/128 +// OSVC_SSRF_ALLOWED_CIDR = +// OSVC_SSRF_ENABLE_REDIRECTS = false func setSSRF(allowedURL, blockedURL, AllowedCIDR, blockedCIDR, enableRedirects string) { if allowedURL == "" { SSRFAllowedURL = defaultSSRFAllowedURL From f9794f967c32d34f52e7c8d49995478036bf4ff4 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 14 Aug 2026 16:34:52 +0200 Subject: [PATCH 11/16] [httppolicy] Improves error messages --- util/httppolicy/main.go | 10 +++++----- util/httppolicy/main_test.go | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/util/httppolicy/main.go b/util/httppolicy/main.go index efc78f369..ef244d420 100644 --- a/util/httppolicy/main.go +++ b/util/httppolicy/main.go @@ -12,7 +12,7 @@ import ( type ( T struct { - // AllowedUrl defines the list of URL patterns that are explicitly + // AllowedUrl defines the list of URL that are explicitly // permitted by the policy regardless BlockedUrl setting. AllowedUrl []string @@ -64,13 +64,13 @@ func (t *T) Check(rawURL string) (ip net.IP, port string, err error) { } if err := rejectURL(parsedURL, t.AllowedUrl, t.BlockedUrl); err != nil { - return nil, "", fmt.Errorf("reject url %s: %w", parsedURL, err) + 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 host %s ip %s: %w", host, ip, err) + return nil, "", fmt.Errorf("reject url with host=%s ip=%s: %w", host, ip, err) } port = parsedURL.Port() if port == "" { @@ -135,7 +135,7 @@ func rejectURL(requestURL *url.URL, allowed, blocked []string) error { requestURLString := requestURL.String() for _, u := range blocked { if fnmatch.Match(u, requestURLString, 0) { - return fmt.Errorf("url %s is blocked by pattern %s and not in allowed list %s", requestURLString, u, allowed) + return fmt.Errorf("reject url %s: pattern %s is blocked (see SSRF blocked/allowed url lists)", requestURLString, u) } } @@ -192,7 +192,7 @@ func rejectIPs(ips []net.IP, allowedNet, blockedNets []*net.IPNet) (ip net.IP, e return ip, nil } } - return ip, fmt.Errorf("blocked net %s and not in exception net list %s", blockedNet, allowedNet) + return ip, fmt.Errorf("subnet %s is blocked (see SSRF blocked/allowed cidr lists)", blockedNet) } } } diff --git a/util/httppolicy/main_test.go b/util/httppolicy/main_test.go index 29dec7602..e2c04a052 100644 --- a/util/httppolicy/main_test.go +++ b/util/httppolicy/main_test.go @@ -218,7 +218,7 @@ func TestCheck(t *testing.T) { if tc.expectedErr { assert.Errorf(t, err, "expected error for %s", tc.inputURL) if err != nil { - t.Logf("got expected error: %s", err) + t.Logf("Check url error: %s", err) } } else { t.Logf("url detected ip %s, port %s", ip, port) @@ -235,7 +235,7 @@ func TestCheck(t *testing.T) { 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, "expected error for %s", 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) From b5a11c87b51ed648735dcea321f937392b63fec5 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 14 Aug 2026 18:32:10 +0200 Subject: [PATCH 12/16] [util/httppolicy] Add URL normalization and enhanced pattern matching Introduce `normalizePattern` and `normalizeURL` functions to standardize URL formats before matching. Replace exact URL matches with flexible pattern-based matching using `fnmatch`. Update test cases to validate the new behavior. --- core/rawconfig/ssrf_policy.go | 2 +- util/httppolicy/main.go | 104 +++++++++++++++++++++------------- util/httppolicy/main_test.go | 42 +++++++++++--- 3 files changed, 98 insertions(+), 50 deletions(-) diff --git a/core/rawconfig/ssrf_policy.go b/core/rawconfig/ssrf_policy.go index c959603ab..8362c5cbe 100644 --- a/core/rawconfig/ssrf_policy.go +++ b/core/rawconfig/ssrf_policy.go @@ -4,7 +4,7 @@ import "strings" var ( defaultSSRFAllowedURL = []string{ - "https://raw.githubusercontent.com/opensvc/opensvc_templates", + "https://raw.githubusercontent.com/opensvc/opensvc_templates/*", } defaultSSRFBlockedURL = []string{ diff --git a/util/httppolicy/main.go b/util/httppolicy/main.go index ef244d420..b4100690e 100644 --- a/util/httppolicy/main.go +++ b/util/httppolicy/main.go @@ -12,7 +12,7 @@ import ( type ( T struct { - // AllowedUrl defines the list of URL that are explicitly + // AllowedUrl defines the list of URL patterns that are explicitly // permitted by the policy regardless BlockedUrl setting. AllowedUrl []string @@ -104,64 +104,88 @@ func parseRequestURL(rawURL string) (*url.URL, error) { return parsedURL, nil } -func rejectURL(requestURL *url.URL, allowed, blocked []string) error { - host := requestURL.Hostname() - scheme := requestURL.Scheme - port := requestURL.Port() - normalizedPath := path.Clean(requestURL.Path) +func normalizePattern(pattern string) string { + if !strings.Contains(pattern, "://") { + return pattern + } - // Verify exception from the allowed list - for _, u := range allowed { - allowedURL, err := url.Parse(u) - if err != nil { - continue - } + u, err := url.Parse(pattern) + if err != nil { + return pattern + } - if isAllowedURLMatch(scheme, host, port, normalizedPath, allowedURL) { - return nil + port := u.Port() + if port == "" { + switch u.Scheme { + case "http": + port = "80" + case "https": + port = "443" + default: + return pattern } } - // verify if match blocked url - requestURL = &url.URL{ - Scheme: requestURL.Scheme, - Host: requestURL.Host, - Path: path.Clean(requestURL.Path), + normalizedPath := path.Clean(u.Path) + if normalizedPath == "." { + normalizedPath = "/" + } else if !strings.HasPrefix(normalizedPath, "/") { + normalizedPath = "/" + normalizedPath } - if len(blocked) == 0 { - blocked = []string{"*"} - } - requestURLString := requestURL.String() - for _, u := range blocked { - if fnmatch.Match(u, requestURLString, 0) { - return fmt.Errorf("reject url %s: pattern %s is blocked (see SSRF blocked/allowed url lists)", requestURLString, u) + 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 "" } } - return nil + 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 isAllowedURLMatch(scheme, host, port, normalizedPath string, allowedURL *url.URL) bool { - if allowedURL.Scheme != "http" && allowedURL.Scheme != "https" { - return false +func rejectURL(requestURL *url.URL, allowed, blocked []string) error { + normalizedURL := normalizeURL(requestURL) + if normalizedURL == "" { + return fmt.Errorf("invalid url scheme") } - allowedHost := allowedURL.Hostname() - if allowedHost == "" { - return false + // Verify exception from the allowed list + for _, pattern := range allowed { + if fnmatch.Match(normalizePattern(pattern), normalizedURL, 0) { + return nil + } } - if scheme != allowedURL.Scheme || host != allowedHost || port != allowedURL.Port() { - return false + // verify if match blocked url + if len(blocked) == 0 { + blocked = []string{"*"} } - - if allowedURL.Path == "" { - return true + 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) + } } - allowedPath := path.Clean(allowedURL.Path) - return normalizedPath == allowedPath || strings.HasPrefix(normalizedPath, allowedPath+"/") + return nil } func parseBlockedCIDRs(blockedCIDRs []string) []*net.IPNet { diff --git a/util/httppolicy/main_test.go b/util/httppolicy/main_test.go index e2c04a052..c57d4d2bd 100644 --- a/util/httppolicy/main_test.go +++ b/util/httppolicy/main_test.go @@ -130,12 +130,12 @@ func TestCheck(t *testing.T) { expectedErr: true, }, { - name: "reject_ula_ipv6_cidr", + name: "rejects_ula_ipv6_cidr", inputURL: "https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:626b]", expectedErr: true, }, { - name: "allow_ula_ipv6_cidr_exception", + name: "allows_ula_ipv6_cidr_exception", inputURL: "https://[fd7a:115c:a1e0:ab12:4843:cd96:626b:430b]", expectedErr: false, }, @@ -161,7 +161,32 @@ func TestCheck(t *testing.T) { }, { name: "rejects_unresolvable_host", - inputURL: "https://invalid-host-lookup.opensvc.com", + 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, }, { @@ -189,13 +214,13 @@ func TestCheck(t *testing.T) { 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://www.github.com") + 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]") - // to verify invalid host lookup - v.AllowedUrl = append(v.AllowedUrl, "https://invalid-host-lookup.opensvc.com") + // 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") @@ -207,10 +232,10 @@ func TestCheck(t *testing.T) { ) v.AllowedCIDR = append(v.AllowedCIDR, "fd7a:115c:a1e0:ab12:4843:cd96:626b:430b/128") - t.Logf("policy AllowedCIDR: %s", v.AllowedCIDR) + t.Logf("policy AllowedUrl: %s", v.AllowedUrl) t.Logf("policy BlockedUrl: %s", v.BlockedUrl) - t.Logf("policy BlockedCIDR: %s", v.BlockedCIDR) 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) @@ -243,5 +268,4 @@ func TestCheck(t *testing.T) { }) } }) - } From d5cfe6bfe6ab96436c9b5eb806add5cc45dca1e9 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 14 Aug 2026 19:05:29 +0200 Subject: [PATCH 13/16] [core/rawconfig] Refactor `setSSRF` to simplify configuration handling This allows empty value definitions inside /etc/default/opensvc: example: OSVC_SSRF_BLOCKED_URL= OSVC_SSRF_BLOCKED_CIDR= Update `setSSRF` to take an environment map for configuration, replacing individual parameter assignments with `getSSRFValue` utility. Simplifies and consolidates SSRF policy initialization logic. --- core/rawconfig/node.go | 7 +----- core/rawconfig/ssrf_policy.go | 42 +++++++++++++++-------------------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/core/rawconfig/node.go b/core/rawconfig/node.go index 81d982843..34114ff5a 100644 --- a/core/rawconfig/node.go +++ b/core/rawconfig/node.go @@ -50,12 +50,7 @@ func Load(env map[string]string) { } if env != nil { - blockedURL, _ := env["OSVC_SSRF_BLOCKED_URL"] - allowedURL, _ := env["OSVC_SSRF_ALLOWED_URL"] - blockedCIDR, _ := env["OSVC_SSRF_BLOCKED_CIDR"] - allowedCIDR, _ := env["OSVC_SSRF_ALLOWED_CIDR"] - enableRedirects, _ := env["OSVC_SSRF_ENABLE_REDIRECTS"] - setSSRF(allowedURL, blockedURL, allowedCIDR, blockedCIDR, enableRedirects) + setSSRF(env) } var root string diff --git a/core/rawconfig/ssrf_policy.go b/core/rawconfig/ssrf_policy.go index 8362c5cbe..c82568e92 100644 --- a/core/rawconfig/ssrf_policy.go +++ b/core/rawconfig/ssrf_policy.go @@ -30,36 +30,30 @@ var ( // 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_ALLOWED_URL = https://raw.githubusercontent.com/opensvc/opensvc_templates/* // OSVC_SSRF_BLOCKED_URL = * // OSVC_SSRF_BLOCKED_CIDR = 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 127.0.0.0/8 ::1/128 // OSVC_SSRF_ALLOWED_CIDR = // OSVC_SSRF_ENABLE_REDIRECTS = false -func setSSRF(allowedURL, blockedURL, AllowedCIDR, blockedCIDR, enableRedirects string) { - if allowedURL == "" { - SSRFAllowedURL = defaultSSRFAllowedURL - } else { - SSRFAllowedURL = strings.Fields(allowedURL) - } - if blockedURL == "" { - SSRFBlockedURL = defaultSSRFBlockedURL - } else { - SSRFBlockedURL = strings.Fields(blockedURL) - } - if AllowedCIDR == "" { - SSRFAllowedCIDR = defaultSSRFAllowedCIDR - } else { - SSRFAllowedCIDR = strings.Fields(AllowedCIDR) - } - if blockedCIDR == "" { - SSRFBlockedCIDR = defaultSSRFBlockedCIDR +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 { - SSRFBlockedCIDR = strings.Fields(blockedCIDR) + SSRFEnableRedirects = false } +} - // SSRFEnableRedirects is always false by default unless enableRedirects == "true" - SSRFEnableRedirects = false - if enableRedirects == "true" { - SSRFEnableRedirects = true +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)...) } } From 8e57b519ce1156b64683eaec14f302706f1f44e7 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 17 Aug 2026 09:15:18 +0200 Subject: [PATCH 14/16] [core/rawconfig] Expand default SSRF blocked CIDR ranges Add reserved IPv4 and IPv6 ranges to the default SSRFBlockedCIDR, including RFC 5737 TEST-NET and link-local ranges, to enhance security and compliance with best practices. --- core/rawconfig/ssrf_policy.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/core/rawconfig/ssrf_policy.go b/core/rawconfig/ssrf_policy.go index c82568e92..ec021754b 100644 --- a/core/rawconfig/ssrf_policy.go +++ b/core/rawconfig/ssrf_policy.go @@ -14,17 +14,20 @@ var ( defaultSSRFAllowedCIDR = []string{} defaultSSRFBlockedCIDR = []string{ - // RFC 1918 private address ranges - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - - // RFC 4193 Unique Local IPv6 Unicast Addresses - "fc00::/7", - - // Loopback ranges - "127.0.0.0/8", // RFC 1122 - "::1/128", // RFC 4291 + "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 } ) @@ -32,7 +35,7 @@ var ( // Default is: // OSVC_SSRF_ALLOWED_URL = https://raw.githubusercontent.com/opensvc/opensvc_templates/* // OSVC_SSRF_BLOCKED_URL = * -// OSVC_SSRF_BLOCKED_CIDR = 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 127.0.0.0/8 ::1/128 +// 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) { From 6c1be5791d146a60c646f367b46be41812f11e67 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 17 Aug 2026 09:33:18 +0200 Subject: [PATCH 15/16] [doc] Add SSRF protection feature to CHANGELOG Document the new SSRF protection for HTTP fetches in the v3 changelog, including default policies, blocked CIDR ranges, and configuration overrides via environment variables. --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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. From c01ce398617eec2340af321c3ae448818cbfb358 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 17 Aug 2026 10:18:14 +0200 Subject: [PATCH 16/16] [omcmd] Add explanatory comment for cluster join token parsing mechanism Clarify the purpose of unverified token parsing in the `cluster_join` command, emphasizing its role in extracting the CA certificate for TLS trust establishment, with actual authentication deferred to server-side JWT signature validation. --- core/omcmd/cluster_join.go | 4 ++++ 1 file changed, 4 insertions(+) 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