diff --git a/Makefile b/Makefile index d48141f47..93cb2845f 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ CSV_VERSION = $(shell echo $(VERSION) | sed 's/v//') ifeq ($(VERSION), latest) CSV_VERSION := 0.0.0 endif -CERT_MANAGER_VERSION=v1.9.1 +CERT_MANAGER_VERSION=v1.20.2 IMAGE_ORG ?= $(USER) # CHANNELS define the bundle channels used in the bundle. diff --git a/bpf/ingress_node_firewall.h b/bpf/ingress_node_firewall.h index 8b6d773ee..e60e132ab 100644 --- a/bpf/ingress_node_firewall.h +++ b/bpf/ingress_node_firewall.h @@ -90,4 +90,9 @@ struct rulesVal_st { struct ruleType_st rules[MAX_RULES_PER_TARGET]; } __attribute__((packed)); +// IPv4 fragmentation constants (RFC 791) +#define IP_MF 0x2000 // More Fragments flag +#define IP_OFFSET_MASK 0x1FFF // Fragment offset mask (bits 0-12, in 8-byte units) +#define IP_DF 0x4000 // Don't Fragment flag + #endif diff --git a/bpf/ingress_node_firewall_kernel.c b/bpf/ingress_node_firewall_kernel.c index a85baca6b..325c930cb 100644 --- a/bpf/ingress_node_firewall_kernel.c +++ b/bpf/ingress_node_firewall_kernel.c @@ -97,6 +97,7 @@ volatile const __u32 debug_lookup = 0; * Return: * 0 for Success. * -1 for Failure. + * -2 for non-first IP fragment (security: deny fragments without L4 headers). */ __attribute__((__always_inline__)) static inline int ip_extract_l4info(void *data, void *dataEnd, __u8 *proto, __u16 *dstPort, @@ -110,6 +111,15 @@ ip_extract_l4info(void *data, void *dataEnd, __u8 *proto, __u16 *dstPort, return -1; } *proto = iph->protocol; + + // Security: Check for IP fragmentation (RFC 791) + // Non-first fragments don't have L4 headers, deny them to prevent bypass + __u16 frag_off = bpf_ntohs(iph->frag_off); + if (unlikely(frag_off & IP_OFFSET_MASK)) { + // Fragment offset > 0: this is a non-first fragment + // No L4 header present, deny by policy + return -2; + } } else { struct ipv6hdr *iph = dataStart; dataStart += sizeof(struct ipv6hdr); @@ -195,8 +205,13 @@ ipv4_firewall_lookup(void *data, void *data_end, __u32 ifId) { __u8 icmpCode = 0, icmpType = 0, proto = 0; int i; - if (unlikely(ip_extract_l4info(data, data_end, &proto, &dstPort, &icmpType, - &icmpCode, 1) < 0)) { + int l4_result = ip_extract_l4info(data, data_end, &proto, &dstPort, &icmpType, + &icmpCode, 1); + if (unlikely(l4_result < 0)) { + if (l4_result == -2) { + // Non-first IP fragment: deny by security policy + return SET_ACTIONRULE_RESPONSE(DENY, INVALID_RULE_ID); + } ingress_node_firewall_printk("failed to extract l4 info"); return SET_ACTION(UNDEF); } @@ -291,8 +306,13 @@ ipv6_firewall_lookup(void *data, void *data_end, __u32 ifId) { __u8 icmpCode = 0, icmpType = 0, proto = 0; int i; - if (unlikely(ip_extract_l4info(data, data_end, &proto, &dstPort, &icmpType, - &icmpCode, 0) < 0)) { + int l4_result = ip_extract_l4info(data, data_end, &proto, &dstPort, &icmpType, + &icmpCode, 0); + if (unlikely(l4_result < 0)) { + if (l4_result == -2) { + // Non-first IP fragment: deny by security policy + return SET_ACTIONRULE_RESPONSE(DENY, INVALID_RULE_ID); + } ingress_node_firewall_printk("failed to extract l4 info"); return SET_ACTION(UNDEF); } diff --git a/pkg/ebpf/bpf_arm64_bpfel.o b/pkg/ebpf/bpf_arm64_bpfel.o index 3d5860fed..f8cba0662 100644 Binary files a/pkg/ebpf/bpf_arm64_bpfel.o and b/pkg/ebpf/bpf_arm64_bpfel.o differ diff --git a/pkg/ebpf/bpf_powerpc_bpfel.o b/pkg/ebpf/bpf_powerpc_bpfel.o index a3b38362d..f8cba0662 100644 Binary files a/pkg/ebpf/bpf_powerpc_bpfel.o and b/pkg/ebpf/bpf_powerpc_bpfel.o differ diff --git a/pkg/ebpf/bpf_s390_bpfeb.o b/pkg/ebpf/bpf_s390_bpfeb.o index e3b0077a1..74cb119e8 100644 Binary files a/pkg/ebpf/bpf_s390_bpfeb.o and b/pkg/ebpf/bpf_s390_bpfeb.o differ diff --git a/pkg/ebpf/bpf_x86_bpfel.o b/pkg/ebpf/bpf_x86_bpfel.o index a3b38362d..f8cba0662 100644 Binary files a/pkg/ebpf/bpf_x86_bpfel.o and b/pkg/ebpf/bpf_x86_bpfel.o differ diff --git a/test/Dockerfile.netcat b/test/Dockerfile.netcat index ad9b76298..8abc74607 100644 --- a/test/Dockerfile.netcat +++ b/test/Dockerfile.netcat @@ -1,4 +1,6 @@ FROM alpine:latest RUN apk add --no-cache --update --verbose bash nmap-ncat && \ + apk add --no-cache --verbose hping3 \ + --repository=https://dl-cdn.alpinelinux.org/alpine/edge/testing && \ rm -rf /var/cache/apk/* /tmp/* diff --git a/test/e2e/functional/tests/e2e.go b/test/e2e/functional/tests/e2e.go index 2df03b260..8b98a9298 100644 --- a/test/e2e/functional/tests/e2e.go +++ b/test/e2e/functional/tests/e2e.go @@ -828,6 +828,43 @@ var _ = Describe("Ingress Node Firewall", func() { }, }, }, + { + "should deny fragmented TCP packets to blocked port", + []testIngressNodeFirewall{ + { + []string{testInterface}, + []testRule{ + { + []sourceCIDRsEntry{{clientOnePodName, v4SubnetLen, v6SubnetLen}}, + []func(proto ingressnodefwv1alpha1.IngressNodeFirewallRuleProtocolType, order uint32) ingressnodefwv1alpha1.IngressNodeFirewallProtocolRule{ + func(proto ingressnodefwv1alpha1.IngressNodeFirewallRuleProtocolType, order uint32) ingressnodefwv1alpha1.IngressNodeFirewallProtocolRule { + return infwutils.GetTransportProtocolBlockPortRule(proto, order, serverOnePort) + }, + }, + }, + }, + }, + }, + []reachable{ + { + clientOnePodName, + serverOnePodName, + serverOnePort, + false, + }, + }, + transportProtocols, + []func() (*corev1.Pod, func(), error){ + func() (*corev1.Pod, func(), error) { + return transport.GetAndEnsureRunningClient(testclient.Client, clientOnePodName, OperatorNameSpace, clientPodLabel, clientPodLabel, + serverPodLabel, retryInterval, timeout) + }, + func() (*corev1.Pod, func(), error) { + return transport.GetAndEnsureRunningTransportServer(testclient.Client, serverOnePodName, OperatorNameSpace, + serverPodLabel, serverPodLabel, clientPodLabel, retryInterval, timeout) + }, + }, + }, } BeforeEach(func() { @@ -973,6 +1010,11 @@ var _ = Describe("Ingress Node Firewall", func() { reach := reach reachabilityCheck(reach, podNameObj, entry.protocols, reach.connectivity) } + // test fragmented packets are always denied + for _, reach := range entry.reachables { + reach := reach + reachabilityCheckFragmentation(reach, podNameObj, entry.protocols) + } // Make sure all rules are deleted before running next test deleteAllTestRules(testINFs, testclient.Client, timeout, nodeStateList) }) @@ -1515,7 +1557,17 @@ func isConnectivitySeen(client *testclient.ClientSet, protocol ingressnodefwv1al // Connectivity will be confirmed with server output later and expecting server output to contain clients IP. _, _, _ = transport.ConnectToPortFromPod(client, protocol, v6, sourcePod, sourceIP, destinationIP, destinationPort) serverResult := <-serverResultsCh - return strings.Contains(serverResult, sourceIP) + ncatResult := strings.Contains(serverResult, sourceIP) + + // hping3 fragmented connectivity test (runs side by side with ncat) + hpingStdout, hpingStderr, hpingErr := transport.HpingFragmentedConnect(client, protocol, v6, sourcePod, destinationIP, destinationPort) + if hpingErr != nil && !strings.Contains(hpingErr.Error(), "exit code 1") { + log.Printf("hping3 frag probe failed to execute: %v", hpingErr) + } + hpingResult := transport.IsHpingResponseSeen(hpingStdout, hpingStderr) + log.Printf("isConnectivitySeen from %s to %s:%s (hping3 = %v) && (ncat = %v)", sourceIP, destinationIP, destinationPort, hpingResult, ncatResult) + + return ncatResult } else { panic("Unexpected protocol") } @@ -1620,6 +1672,49 @@ func reachabilityCheck(reach reachable, podNameObj map[string]*corev1.Pod, } } +func reachabilityCheckFragmentation(reach reachable, podNameObj map[string]*corev1.Pod, + protocols []ingressnodefwv1alpha1.IngressNodeFirewallRuleProtocolType) { + sourcePod := podNameObj[reach.source] + for _, protocol := range protocols { + if skipProtocol(protocol, !v6Enabled) { + continue + } + if !infwutils.IsTransportProtocol(protocol) || protocol == ingressnodefwv1alpha1.ProtocolTypeSCTP { + continue + } + // v4 fragmentation tests + if v4Enabled && protocol != ingressnodefwv1alpha1.ProtocolTypeICMP6 { + destinationPodV4IP := pods.GetIPV4(podNameObj[reach.destination].Status.PodIPs) + By(fmt.Sprintf("[IPV4] Fragmentation check for protocol %s from pod %q to %s:%s", + protocol, reach.source, destinationPodV4IP, reach.port)) + Eventually(func() bool { + stdout, stderr, err := transport.HpingFragmentedConnect( + testclient.Client, protocol, false, sourcePod, destinationPodV4IP, reach.port) + if err != nil && !strings.Contains(err.Error(), "exit code 1") { + Fail(fmt.Sprintf("hping3 IPv4 frag probe failed to execute: %v", err)) + } + return transport.IsHpingResponseSeen(stdout, stderr) + }, timeout, retryInterval).Should(BeFalse(), + "Failed: IPv4 fragmented packets should be denied") + } + // v6 fragmentation tests + if !isSingleStack && v6Enabled && protocol != ingressnodefwv1alpha1.ProtocolTypeICMP { + destinationPodV6IP := pods.GetIPV6(podNameObj[reach.destination].Status.PodIPs) + By(fmt.Sprintf("[IPV6] Fragmentation check for protocol %s from pod %q to %s:%s", + protocol, reach.source, destinationPodV6IP, reach.port)) + Eventually(func() bool { + stdout, stderr, err := transport.HpingFragmentedConnect( + testclient.Client, protocol, true, sourcePod, destinationPodV6IP, reach.port) + if err != nil && !strings.Contains(err.Error(), "exit code 1") { + Fail(fmt.Sprintf("hping3 IPv6 frag probe failed to execute: %v", err)) + } + return transport.IsHpingResponseSeen(stdout, stderr) + }, timeout, retryInterval).Should(BeFalse(), + "Failed: IPv6 fragmented packets should be denied") + } + } +} + func checkNodeStateCreate(client *testclient.ClientSet, nodeStateList *ingressnodefwv1alpha1.IngressNodeFirewallNodeStateList) { Eventually(func() bool { err := client.List(context.Background(), nodeStateList) diff --git a/test/e2e/transport/transport.go b/test/e2e/transport/transport.go index 04fa4ce1f..b66dc2728 100644 --- a/test/e2e/transport/transport.go +++ b/test/e2e/transport/transport.go @@ -67,6 +67,11 @@ func getClient(clientPodName, namespace string, labels, affinity, antiAffinity m Name: "client", Image: images.NetcatImage(), Command: []string{"/bin/sh", "-c", "sleep INF"}, + SecurityContext: &corev1.SecurityContext{ + Capabilities: &corev1.Capabilities{ + Add: []corev1.Capability{"NET_RAW"}, + }, + }, Resources: corev1.ResourceRequirements{ Requests: map[corev1.ResourceName]resource.Quantity{corev1.ResourceMemory: resource.MustParse("256Mi")}, Limits: map[corev1.ResourceName]resource.Quantity{corev1.ResourceMemory: resource.MustParse("512Mi")}, @@ -224,3 +229,62 @@ func ncClientTransport(client *testclient.ClientSet, sourcePod *corev1.Pod, sour command := []string{"sh", "-c", fmt.Sprintf("ncat %s --wait 1 %s %s --verbose", strings.Join(additionalFlag, " "), destinationIP, dPort)} return exec.RunExecCommandWithStdin(client, sourcePod, sourceIP, command...) } + +// HpingFragmentedConnect sends a fragmented packet to the destination using hping3. +// Analogous to ConnectToPortFromPod but for fragmented traffic testing. +func HpingFragmentedConnect(client *testclient.ClientSet, proto ingressnodefwv1alpha1.IngressNodeFirewallRuleProtocolType, + v6 bool, sourcePod *corev1.Pod, destinationIP, destinationPort string) (string, string, error) { + switch proto { + case ingressnodefwv1alpha1.ProtocolTypeTCP: + if v6 { + return hpingTCPFragV6(client, sourcePod, destinationIP, destinationPort) + } + return hpingTCPFragV4(client, sourcePod, destinationIP, destinationPort) + case ingressnodefwv1alpha1.ProtocolTypeUDP: + if v6 { + return hpingUDPFragV6(client, sourcePod, destinationIP, destinationPort) + } + return hpingUDPFragV4(client, sourcePod, destinationIP, destinationPort) + case ingressnodefwv1alpha1.ProtocolTypeSCTP: + return "", "", fmt.Errorf("hping3 does not support SCTP") + default: + panic("Unsupported protocol for hping3") + } +} + +// IsHpingResponseSeen parses hping3 output to determine if a response was received. +// Returns true if the target responded (packet reached it), false if dropped. +func IsHpingResponseSeen(stdout, stderr string) bool { + combined := stdout + stderr + // hping3 prints "flags=SA" (SYN-ACK) or "flags=RA" (RST-ACK) when a response is received. + if strings.Contains(combined, "flags=") { + return true + } + // "0 packets received" or "100% packet loss" means no response + if strings.Contains(combined, "0 packets received") || strings.Contains(combined, "100% packet loss") { + return false + } + return false +} + +func hpingTCPFragV4(client *testclient.ClientSet, sourcePod *corev1.Pod, destinationIP, dPort string) (string, string, error) { + return hpingClient(client, sourcePod, destinationIP, dPort, "-S", "-f", "-c", "1") +} + +func hpingTCPFragV6(client *testclient.ClientSet, sourcePod *corev1.Pod, destinationIP, dPort string) (string, string, error) { + return hpingClient(client, sourcePod, destinationIP, dPort, "-S", "-f", "-c", "1", "-6") +} + +func hpingUDPFragV4(client *testclient.ClientSet, sourcePod *corev1.Pod, destinationIP, dPort string) (string, string, error) { + return hpingClient(client, sourcePod, destinationIP, dPort, "--udp", "-f", "-c", "1") +} + +func hpingUDPFragV6(client *testclient.ClientSet, sourcePod *corev1.Pod, destinationIP, dPort string) (string, string, error) { + return hpingClient(client, sourcePod, destinationIP, dPort, "--udp", "-f", "-c", "1", "-6") +} + +func hpingClient(client *testclient.ClientSet, sourcePod *corev1.Pod, destinationIP, dPort string, flags ...string) (string, string, error) { + command := append([]string{"hping3"}, flags...) + command = append(command, "-p", dPort, destinationIP) + return exec.RunExecCommand(client, sourcePod, command...) +}