diff --git a/pkg/webhook/ipam_validation.go b/pkg/webhook/ipam_validation.go new file mode 100644 index 00000000..da78863d --- /dev/null +++ b/pkg/webhook/ipam_validation.go @@ -0,0 +1,150 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "strings" + + netutils "k8s.io/utils/net" +) + +const whereaboutsIPAMType = "whereabouts" + +func validateIPAMConfigs(config []byte) error { + var c map[string]interface{} + if err := json.Unmarshal(config, &c); err != nil { + return fmt.Errorf("invalid json: %w", err) + } + + if plugins, ok := c["plugins"].([]interface{}); ok { + for _, plugin := range plugins { + pluginConfig, ok := plugin.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid plugin config") + } + if err := validatePluginIPAM(pluginConfig); err != nil { + return err + } + } + return nil + } + + return validatePluginIPAM(c) +} + +func validatePluginIPAM(plugin map[string]interface{}) error { + ipamRaw, ok := plugin["ipam"] + if !ok { + return nil + } + + ipam, ok := ipamRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid ipam config") + } + + ipamType, _ := ipam["type"].(string) + if ipamType != whereaboutsIPAMType { + return nil + } + + return validateWhereaboutsIPAM(ipam) +} + +func validateWhereaboutsIPAM(ipam map[string]interface{}) error { + if rangeStr, ok := ipam["range"].(string); ok && rangeStr != "" { + if err := validateWhereaboutsRange(rangeStr); err != nil { + return fmt.Errorf("invalid whereabouts ipam range: %w", err) + } + } + + if err := validateWhereaboutsStringIP(ipam, "range_start"); err != nil { + return err + } + if err := validateWhereaboutsStringIP(ipam, "range_end"); err != nil { + return err + } + if err := validateWhereaboutsStringIP(ipam, "gateway"); err != nil { + return err + } + + if err := validateWhereaboutsExcludeList(ipam["exclude"]); err != nil { + return err + } + + ipRangesRaw, ok := ipam["ipRanges"].([]interface{}) + if !ok { + return nil + } + + for idx, ipRangeRaw := range ipRangesRaw { + ipRange, ok := ipRangeRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid whereabouts ipam ipRanges entry at index %d", idx) + } + + rangeStr, _ := ipRange["range"].(string) + if rangeStr != "" { + if err := validateWhereaboutsRange(rangeStr); err != nil { + return fmt.Errorf("invalid whereabouts ipam ipRanges[%d].range: %w", idx, err) + } + } + + if err := validateWhereaboutsExcludeList(ipRange["exclude"]); err != nil { + return fmt.Errorf("invalid whereabouts ipam ipRanges[%d].exclude: %w", idx, err) + } + } + + return nil +} + +func validateWhereaboutsExcludeList(excludeRaw interface{}) error { + excludeList, ok := excludeRaw.([]interface{}) + if !ok || len(excludeList) == 0 { + return nil + } + + for idx, excludeEntry := range excludeList { + excludeStr, ok := excludeEntry.(string) + if !ok { + return fmt.Errorf("invalid exclude entry at index %d", idx) + } + if err := validateWhereaboutsRange(excludeStr); err != nil { + return fmt.Errorf("invalid CIDR in exclude list %s: %w", excludeStr, err) + } + } + + return nil +} + +func validateWhereaboutsStringIP(ipam map[string]interface{}, field string) error { + value, ok := ipam[field].(string) + if !ok || value == "" { + return nil + } + + if netutils.ParseIPSloppy(value) == nil { + return fmt.Errorf("invalid whereabouts ipam %s: %s", field, value) + } + + return nil +} + +func validateWhereaboutsRange(rangeStr string) error { + parts := strings.SplitN(rangeStr, "-", 2) + if len(parts) == 2 { + if netutils.ParseIPSloppy(strings.TrimSpace(parts[0])) == nil { + return fmt.Errorf("invalid range start IP: %s", parts[0]) + } + if _, _, err := netutils.ParseCIDRSloppy(strings.TrimSpace(parts[1])); err != nil { + return fmt.Errorf("invalid CIDR '%s': %w", parts[1], err) + } + return nil + } + + if _, _, err := netutils.ParseCIDRSloppy(rangeStr); err != nil { + return fmt.Errorf("invalid CIDR %s: %w", rangeStr, err) + } + + return nil +} diff --git a/pkg/webhook/ipam_validation_test.go b/pkg/webhook/ipam_validation_test.go new file mode 100644 index 00000000..a1f5a051 --- /dev/null +++ b/pkg/webhook/ipam_validation_test.go @@ -0,0 +1,265 @@ +package webhook + +import ( + "encoding/json" + "fmt" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + netv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" +) + +func whereaboutsConfig(ipam map[string]interface{}) string { + config := map[string]interface{}{ + "cniVersion": "0.3.1", + "name": "whereabouts-test", + "type": "macvlan", + "mode": "bridge", + "ipam": ipam, + } + bytes, err := json.Marshal(config) + Expect(err).NotTo(HaveOccurred()) + return string(bytes) +} + +func whereaboutsNAD(name string, ipam map[string]interface{}) netv1.NetworkAttachmentDefinition { + return netv1.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: netv1.NetworkAttachmentDefinitionSpec{ + Config: whereaboutsConfig(ipam), + }, + } +} + +var _ = Describe("Whereabouts IPAM validation", func() { + DescribeTable("validateWhereaboutsRange rejects invalid values", + func(rangeStr string) { + err := validateWhereaboutsRange(rangeStr) + Expect(err).To(HaveOccurred()) + }, + Entry("original repro range", "abc.169.1.0/24"), + Entry("original repro exclude style", "a.b.c.d/23"), + Entry("plain text", "abcd"), + Entry("placeholder octets", "x.x.x.x/24"), + Entry("out of range octets", "999.999.999.999/24"), + Entry("missing prefix", "192.168.1.0"), + Entry("invalid prefix", "192.168.1.0/33"), + Entry("empty string with slash", "/24"), + Entry("only slash", "/"), + Entry("hostname not cidr", "example.com/24"), + Entry("invalid hyphen range start", "abcd-192.168.1.0/24"), + Entry("invalid hyphen range end", "192.168.1.10-abcd/24"), + ) + + DescribeTable("validateWhereaboutsRange accepts valid values", + func(rangeStr string) { + err := validateWhereaboutsRange(rangeStr) + Expect(err).NotTo(HaveOccurred()) + }, + Entry("ipv4 cidr", "192.168.169.0/24"), + Entry("ipv4 host route", "192.168.169.10/32"), + Entry("ipv6 cidr", "2001:db8::/64"), + Entry("whereabouts hyphen range", "192.168.1.10-192.168.1.20/24"), + ) + + DescribeTable("validateIPAMConfigs rejects invalid whereabouts ipam", + func(ipam map[string]interface{}) { + err := validateIPAMConfigs([]byte(whereaboutsConfig(ipam))) + Expect(err).To(HaveOccurred()) + }, + Entry("invalid range only", map[string]interface{}{ + "type": "whereabouts", + "range": "abc.169.1.0/24", + }), + Entry("valid range invalid exclude", map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.169.0/24", + "exclude": []interface{}{ + "a.b.c.d/23", + }, + }), + Entry("invalid gateway", map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.169.0/24", + "gateway": "abcd", + }), + Entry("invalid range_start", map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.169.0/24", + "range_start": "not-an-ip", + }), + Entry("invalid range_end", map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.169.0/24", + "range_end": "xyz", + }), + Entry("invalid ipRanges range", map[string]interface{}{ + "type": "whereabouts", + "ipRanges": []interface{}{ + map[string]interface{}{ + "range": "abcd/24", + }, + }, + }), + Entry("invalid ipRanges exclude", map[string]interface{}{ + "type": "whereabouts", + "ipRanges": []interface{}{ + map[string]interface{}{ + "range": "192.168.10.0/24", + "exclude": []interface{}{ + "foo.bar.baz/32", + }, + }, + }, + }), + Entry("multiple excludes with one invalid", map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.169.0/24", + "exclude": []interface{}{ + "192.168.169.10/32", + "abcd/24", + }, + }), + ) + + DescribeTable("validateIPAMConfigs accepts valid whereabouts ipam", + func(ipam map[string]interface{}) { + err := validateIPAMConfigs([]byte(whereaboutsConfig(ipam))) + Expect(err).NotTo(HaveOccurred()) + }, + Entry("basic valid config", map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.169.0/24", + "exclude": []interface{}{ + "192.168.169.10/32", + }, + "gateway": "192.168.169.1", + }), + Entry("ipv6 config", map[string]interface{}{ + "type": "whereabouts", + "range": "2001:db8::/64", + "exclude": []interface{}{ + "2001:db8::1/128", + }, + }), + Entry("ipRanges config", map[string]interface{}{ + "type": "whereabouts", + "ipRanges": []interface{}{ + map[string]interface{}{ + "range": "192.168.20.0/24", + "exclude": []interface{}{ + "192.168.20.1/32", + }, + }, + }, + }), + Entry("hyphen range", map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.50.10-192.168.50.20/24", + }), + ) + + It("does not validate non-whereabouts ipam plugins", func() { + config := `{ + "cniVersion": "0.3.1", + "type": "macvlan", + "ipam": { + "type": "host-local", + "subnet": "abcd/24" + } + }` + err := validateIPAMConfigs([]byte(config)) + Expect(err).NotTo(HaveOccurred()) + }) + + It("validates whereabouts ipam inside a plugin list", func() { + config := `{ + "cniVersion": "0.3.1", + "name": "conflist-network", + "plugins": [{ + "type": "macvlan", + "ipam": { + "type": "whereabouts", + "range": "abcd/24" + } + }] + }` + err := validateIPAMConfigs([]byte(config)) + Expect(err).To(HaveOccurred()) + }) + + DescribeTable("validateNetworkAttachmentDefinition end-to-end whereabouts checks", + func(nad netv1.NetworkAttachmentDefinition, shouldFail bool) { + allowed, err := validateNetworkAttachmentDefinition(nad) + if shouldFail { + Expect(allowed).To(BeFalse()) + Expect(err).To(HaveOccurred()) + return + } + Expect(allowed).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + }, + Entry( + "original bug repro", + whereaboutsNAD("checking-nad", map[string]interface{}{ + "type": "whereabouts", + "range": "abc.169.1.0/24", + "exclude": []interface{}{ + "a.b.c.d/23", + }, + }), + true, + ), + Entry( + "invalid range abcd", + whereaboutsNAD("bad-range", map[string]interface{}{ + "type": "whereabouts", + "range": "abcd", + }), + true, + ), + Entry( + "valid whereabouts nad", + whereaboutsNAD("valid-whereabouts-nad", map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.169.0/24", + "exclude": []interface{}{ + "192.168.169.10/32", + }, + "gateway": "192.168.169.1", + }), + false, + ), + ) + + DescribeTable("validateNetworkAttachmentDefinition rejects many invalid ranges", + func(rangeValue string) { + nad := whereaboutsNAD("invalid-range-nad", map[string]interface{}{ + "type": "whereabouts", + "range": rangeValue, + }) + allowed, err := validateNetworkAttachmentDefinition(nad) + Expect(allowed).To(BeFalse()) + Expect(err).To(HaveOccurred()) + }, + Entry("abcd", "abcd"), + Entry("abc.def.ghi.jkl/24", "abc.def.ghi.jkl/24"), + Entry("a.b.c.d/23", "a.b.c.d/23"), + Entry("192.168.1.256/24", "192.168.1.256/24"), + Entry("not-an-ip/24", "not-an-ip/24"), + ) +}) + +var _ = Describe("Whereabouts IPAM validation helpers", func() { + It("formats NAD config for table tests", func() { + config := whereaboutsConfig(map[string]interface{}{ + "type": "whereabouts", + "range": "192.168.1.0/24", + }) + Expect(config).To(ContainSubstring(`"type":"whereabouts"`)) + Expect(config).To(ContainSubstring(fmt.Sprintf(`"range":"192.168.1.0/24"`))) + }) +}) diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index 90fd37b4..11e072cc 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -147,6 +147,10 @@ func validateNetworkAttachmentDefinition(netAttachDef netv1.NetworkAttachmentDef return false, err } } + if err := validateIPAMConfigs(confBytes); err != nil { + glog.Info(err) + return false, err + } } else { glog.Infof("Allowing empty spec.config") diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go index 48b5942c..ac8a1b84 100644 --- a/pkg/webhook/webhook_test.go +++ b/pkg/webhook/webhook_test.go @@ -205,5 +205,54 @@ var _ = Describe("Webhook", func() { }, true, false, ), + Entry( + "invalid whereabouts ipam range and exclude", + netv1.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "checking-nad", + }, + Spec: netv1.NetworkAttachmentDefinitionSpec{ + Config: `{ + "cniVersion": "0.3.1", + "name": "whereabouts_sba", + "type": "macvlan", + "mode": "bridge", + "ipam": { + "type": "whereabouts", + "range": "abc.169.1.0/24", + "exclude": [ + "a.b.c.d/23" + ] + } + }`, + }, + }, + false, true, + ), + Entry( + "valid whereabouts ipam config", + netv1.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-whereabouts-nad", + }, + Spec: netv1.NetworkAttachmentDefinitionSpec{ + Config: `{ + "cniVersion": "0.3.1", + "name": "whereabouts-valid", + "type": "macvlan", + "mode": "bridge", + "ipam": { + "type": "whereabouts", + "range": "192.168.169.0/24", + "exclude": [ + "192.168.169.10/32" + ], + "gateway": "192.168.169.1" + } + }`, + }, + }, + true, false, + ), ) })