From c609254aef7bb5b41557e1ace82e0003589235dc Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 15 Jun 2026 21:57:16 -0400 Subject: [PATCH 1/2] Fix: Select iptables backend by kernel module, fail loud on misprogram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect_iptables_cmd() preferred iptables-legacy whenever the legacy nat table was merely listable (iptables-legacy -t nat -L -n). On nft-only nodes (OpenShift/ROSA, EKS-nft) the empty legacy table still lists fine, so legacy got chosen and our chains landed in a backend the live datapath never consults — a silent fail-open for a control whose whole job is to enforce egress. A write-probe is no better: individual legacy ops succeed via nft_compat even where the full nat pipeline later fails. Detect the backend that is actually functional instead, by reading the kernel module table: iptable_nat present in /proc/modules -> iptables-legacy (Kind, kubeadm) absent -> iptables (nft) (OpenShift/ROSA) /proc/modules is host-kernel-wide, readable from the pod netns, timing-independent (it does not depend on Istio's not-yet-installed rules), and not foolable by nft_compat. IPTABLES_CMD still overrides; the operator can set it per-platform. PROC_MODULES is overridable for tests. Also add require_jump(): after each chain is hooked into OUTPUT/PREROUTING (both redirect and enforce-redirect modes), assert the jump actually landed in the chosen backend and exit non-zero with an actionable message if not. set -e already aborts on hard errors; this turns the subtler "command returned 0 but the rule isn't there" case from a silent bypass into a visible crash. Note: this is not a ROSA enablement. On ROSA HCP it makes proxy-init select nft and then fail loud on the container_t -> nf_tables SELinux denial, instead of silently mis-selecting legacy. Making nft actually program on ROSA (SELinux/SCC, or node-level/ambient redirection) is a separate track. Extend test-enforce-redirect.sh with a detection unit test driven by the PROC_MODULES seam (no real kernel needed). Refs #502 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/proxy-init/Dockerfile.init | 3 +- authbridge/proxy-init/init-iptables.sh | 48 ++++++++++++++++--- .../proxy-init/test-enforce-redirect.sh | 23 +++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/authbridge/proxy-init/Dockerfile.init b/authbridge/proxy-init/Dockerfile.init index 6a74d9ce3..71282a21e 100644 --- a/authbridge/proxy-init/Dockerfile.init +++ b/authbridge/proxy-init/Dockerfile.init @@ -1,7 +1,8 @@ FROM docker.io/library/alpine:3.23 # Install both iptables-nft and iptables-legacy; init-iptables.sh auto-detects -# the correct backend at runtime (prefers legacy for Kind/kubeadm compatibility). +# the correct backend at runtime by reading /proc/modules (iptable_nat loaded => +# legacy, as on Kind/kubeadm; absent => nft, as on OpenShift/ROSA). # The separate 'iptables-legacy' package is required — Alpine's base 'iptables' # only provides nft binaries since Alpine 3.20+. RUN apk add --no-cache iptables iptables-legacy diff --git a/authbridge/proxy-init/init-iptables.sh b/authbridge/proxy-init/init-iptables.sh index 8a21f28ca..f7a5d0556 100644 --- a/authbridge/proxy-init/init-iptables.sh +++ b/authbridge/proxy-init/init-iptables.sh @@ -118,9 +118,11 @@ # but the cluster's networking stack uses legacy tables, our PREROUTING rules # have no effect — traffic bypasses Envoy's inbound listener entirely. # -# This script auto-detects the correct backend by checking if iptables-legacy -# is available and can manipulate the nat table in this network namespace. -# Override with IPTABLES_CMD env var if needed. +# This script auto-detects the correct backend by reading the kernel module +# table (/proc/modules): the legacy nat path needs the `iptable_nat` module, +# which is loaded on legacy clusters (Kind, kubeadm — kube-proxy uses it) and +# ABSENT on nft-only platforms (OpenShift/ROSA expose only nf_tables + +# nft_compat). Override with IPTABLES_CMD env var if needed. # # ───────────────────────────────────────────────────────────────────────────── @@ -148,16 +150,26 @@ case "${MODE}" in esac # --- Auto-detect iptables backend --- -# Prefer iptables-legacy for maximum compatibility with Kubernetes networking. -# The nft backend sets rules in a different netfilter table that may not be -# processed for PREROUTING when the cluster uses legacy iptables. +# Select the backend that is actually FUNCTIONAL in this kernel, not merely +# listable. A readability probe (`iptables-legacy -t nat -L`) mis-selects legacy +# on nft-only nodes: the empty legacy nat table still lists fine, so legacy gets +# chosen and our rules land in a table nothing consults (silent fail-open). A +# write-probe is unreliable too — individual legacy ops succeed via nft_compat +# even where the full nat pipeline later fails. Instead we check whether the +# `iptable_nat` kernel module is loaded: present => legacy nat is in use, match +# it (Kind, kubeadm); absent => nf_tables is the only working path (OpenShift/ +# ROSA, EKS-nft). /proc/modules is host-kernel-wide, readable from the pod netns, +# timing-independent (unlike matching Istio's not-yet-installed rules), and not +# foolable by nft_compat. Override with IPTABLES_CMD (the operator can set it +# per-platform). PROC_MODULES is overridable for tests. +PROC_MODULES="${PROC_MODULES:-/proc/modules}" detect_iptables_cmd() { if [ -n "${IPTABLES_CMD:-}" ]; then echo "${IPTABLES_CMD}" return fi if command -v iptables-legacy >/dev/null 2>&1 && \ - iptables-legacy -t nat -L -n >/dev/null 2>&1; then + grep -q '^iptable_nat ' "${PROC_MODULES}" 2>/dev/null; then echo "iptables-legacy" else echo "iptables" @@ -167,6 +179,24 @@ detect_iptables_cmd() { IPT=$(detect_iptables_cmd) echo "Using iptables command: ${IPT} ($(${IPT} --version 2>/dev/null || echo 'unknown version'))" +# Fail loud if a jump rule we just installed is not actually present in the +# chosen backend. `set -e` already aborts on hard programming errors; this also +# catches the subtler case where a command returned 0 but the rule did not land +# (e.g. rules written into a backend that is not the live datapath), so a +# fail-closed traffic guard can never silently no-op. Usage: +# require_jump [extra match tokens...] +require_jump() { + _rj_cmd="$1"; _rj_tbl="$2"; _rj_parent="$3"; _rj_chain="$4"; shift 4 + if ! ${_rj_cmd} -t "${_rj_tbl}" -C "${_rj_parent}" "$@" -j "${_rj_chain}" 2>/dev/null; then + echo "ERROR: ${_rj_cmd}: '${_rj_chain}' is not installed in ${_rj_tbl} ${_rj_parent}." >&2 + echo "ERROR: traffic interception is NOT active — refusing to start with enforcement disabled." >&2 + echo "ERROR: the selected iptables backend could not program rules. On nft-only or managed" >&2 + echo "ERROR: platforms set IPTABLES_CMD=iptables and ensure NET_ADMIN/NET_RAW are granted" >&2 + echo "ERROR: (and, on OpenShift, an SELinux context that permits nf_tables access)." >&2 + exit 1 + fi +} + PROXY_PORT="${PROXY_PORT:-15123}" INBOUND_PROXY_PORT="${INBOUND_PROXY_PORT:-15124}" # enforce-redirect mode: the forward proxy's transparent listener port, the @@ -307,6 +337,7 @@ setup_enforce_redirect() { if ! ${IPT} -t nat -C OUTPUT -j "${REDIR_CHAIN}" 2>/dev/null; then ${IPT} -t nat -I OUTPUT 1 -j "${REDIR_CHAIN}" fi + require_jump "${IPT}" nat OUTPUT "${REDIR_CHAIN}" # --- IPv4: mangle DROP for non-TCP --- ${IPT} -t mangle -N "${NOTCP_CHAIN}" 2>/dev/null || true @@ -332,6 +363,7 @@ setup_enforce_redirect() { if ! ${IPT} -t mangle -C OUTPUT -j "${NOTCP_CHAIN}" 2>/dev/null; then ${IPT} -t mangle -I OUTPUT 1 -j "${NOTCP_CHAIN}" fi + require_jump "${IPT}" mangle OUTPUT "${NOTCP_CHAIN}" echo "enforce-redirect: IPv4 egress capture configured" # --- IPv6 --- @@ -483,6 +515,7 @@ ${IPT} -t nat -A PROXY_OUTPUT -p tcp -j REDIRECT --to-port "${PROXY_PORT}" if ! ${IPT} -t nat -C OUTPUT -p tcp -j PROXY_OUTPUT 2>/dev/null; then ${IPT} -t nat -I OUTPUT 1 -p tcp -j PROXY_OUTPUT fi +require_jump "${IPT}" nat OUTPUT PROXY_OUTPUT -p tcp # --- Mangle rule: prevent Envoy→app loop through ISTIO_OUTPUT --- # After inbound JWT validation, Envoy (UID 1337) connects to the local app. @@ -566,6 +599,7 @@ ${IPT} -t nat -A PROXY_INBOUND -p tcp -j REDIRECT --to-port "${INBOUND_PROXY_POR if ! ${IPT} -t nat -C PREROUTING -p tcp -j PROXY_INBOUND 2>/dev/null; then ${IPT} -t nat -I PREROUTING 1 -p tcp -j PROXY_INBOUND fi +require_jump "${IPT}" nat PREROUTING PROXY_INBOUND -p tcp echo "Inbound iptables rules configured successfully" echo "Inbound traffic will be redirected to port ${INBOUND_PROXY_PORT}" diff --git a/authbridge/proxy-init/test-enforce-redirect.sh b/authbridge/proxy-init/test-enforce-redirect.sh index 3a6f9740d..79d4cae06 100755 --- a/authbridge/proxy-init/test-enforce-redirect.sh +++ b/authbridge/proxy-init/test-enforce-redirect.sh @@ -160,6 +160,29 @@ else echo "PASS: init aborts fail-closed when resolv.conf has no nameservers" fi +echo "### Backend detection unit test (/proc/modules seam)" +# Pull detect_iptables_cmd (and its PROC_MODULES default) out of the script and +# exercise it against fixture module tables — no real kernel needed. The legacy +# branch also requires the iptables-legacy binary, so skip the legacy-positive +# case when it is not installed on the host. +eval "$(sed -n '/^PROC_MODULES=/,/^}/p' "${INIT}")" +mods_legacy=$(mktemp); printf 'ip_tables 28672 4 - Live 0x0\niptable_nat 12288 19 - Live 0x0\n' > "${mods_legacy}" +mods_nft=$(mktemp); printf 'nf_tables 315392 344 nft_compat - Live 0x0\nnft_compat 20480 0 - Live 0x0\n' > "${mods_nft}" +if command -v iptables-legacy >/dev/null 2>&1; then + got=$(PROC_MODULES="${mods_legacy}" detect_iptables_cmd) + [ "${got}" = "iptables-legacy" ] && echo "PASS: iptable_nat loaded => iptables-legacy" \ + || { echo "FAIL: expected iptables-legacy, got '${got}'"; fail=1; } +else + echo "SKIP: iptables-legacy not installed on host — legacy-positive case skipped" +fi +got=$(PROC_MODULES="${mods_nft}" detect_iptables_cmd) +[ "${got}" = "iptables" ] && echo "PASS: iptable_nat absent => iptables (nft)" \ + || { echo "FAIL: expected iptables, got '${got}'"; fail=1; } +got=$(IPTABLES_CMD=iptables-legacy PROC_MODULES="${mods_nft}" detect_iptables_cmd) +[ "${got}" = "iptables-legacy" ] && echo "PASS: IPTABLES_CMD override wins over detection" \ + || { echo "FAIL: override ignored, got '${got}'"; fail=1; } +rm -f "${mods_legacy}" "${mods_nft}" + echo [ "${fail}" -eq 0 ] && echo "ALL TESTS PASSED" || echo "SOME TESTS FAILED" exit "${fail}" From d6e1f9109c7e5597330bedec05e046e0d48fda01 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 15 Jun 2026 22:13:33 -0400 Subject: [PATCH 2/2] Fix: Guard IPv6 enforce-redirect jumps with require_jump (review #518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IPv6 nat OUTPUT (AB_REDIRECT) and mangle OUTPUT (AB_NOTCP) jumps were hooked without require_jump, so the loud-fail-on-misprogram guarantee was IPv4-only — a silent v6 jump misprogram (exactly what require_jump exists to catch) would still fail open on a real egress/exfil path. Add the two missing require_jump calls so v6 capture, when attempted (it stays gated on ip6tables availability), succeeds or fails loud like v4. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/proxy-init/init-iptables.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/authbridge/proxy-init/init-iptables.sh b/authbridge/proxy-init/init-iptables.sh index f7a5d0556..f6cc6b0c3 100644 --- a/authbridge/proxy-init/init-iptables.sh +++ b/authbridge/proxy-init/init-iptables.sh @@ -389,6 +389,7 @@ setup_enforce_redirect() { if ! ${IP6T} -t nat -C OUTPUT -j "${REDIR_CHAIN}" 2>/dev/null; then ${IP6T} -t nat -I OUTPUT 1 -j "${REDIR_CHAIN}" fi + require_jump "${IP6T}" nat OUTPUT "${REDIR_CHAIN}" ${IP6T} -t mangle -N "${NOTCP_CHAIN}" 2>/dev/null || true ${IP6T} -t mangle -F "${NOTCP_CHAIN}" @@ -409,6 +410,7 @@ setup_enforce_redirect() { if ! ${IP6T} -t mangle -C OUTPUT -j "${NOTCP_CHAIN}" 2>/dev/null; then ${IP6T} -t mangle -I OUTPUT 1 -j "${NOTCP_CHAIN}" fi + require_jump "${IP6T}" mangle OUTPUT "${NOTCP_CHAIN}" echo "enforce-redirect: IPv6 egress capture configured" else echo "enforce-redirect: ip6tables unavailable — skipping IPv6 egress capture"