From 69d9fce1794451ef2246948ee9e7df917baab714 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 10 Nov 2026 12:12:33 -0500 Subject: [PATCH] feature: a panel behind a reverse proxy can trust it to say who the visitor is TRUSTED_PROXIES in traefik-config.env lists the addresses whose X-Forwarded-* Traefik keeps. Without it every request behind a reverse proxy arrives as the proxy's address. --- overlays/config/traefik-config.env.example | 8 ++++ tests/gateway/fresh_install_test.sh | 17 +++++++ tests/scripts/traefik_install_test.sh | 33 ++++++++++++++ utils/install_traefik.sh | 53 +++++++++++++++++----- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/overlays/config/traefik-config.env.example b/overlays/config/traefik-config.env.example index 01e9d86e..50e69ce0 100644 --- a/overlays/config/traefik-config.env.example +++ b/overlays/config/traefik-config.env.example @@ -2,3 +2,11 @@ # are already taken on this machine. HTTP_PORT=80 HTTPS_PORT=443 + +# Behind your own reverse proxy (nginx, Nginx Proxy Manager, HAProxy, ...) every +# request reaches the panel from the proxy's address, so the panel can't tell +# visitors apart and 5Stack can't see this server's public address. List the +# address the proxy connects from (or a range, separated by commas) and the +# panel believes its X-Forwarded-For instead. Only ever your own proxy: anyone +# listed here can claim to be any visitor. Run ./update.sh after changing it. +TRUSTED_PROXIES= diff --git a/tests/gateway/fresh_install_test.sh b/tests/gateway/fresh_install_test.sh index 69fb313c..7b991d5c 100644 --- a/tests/gateway/fresh_install_test.sh +++ b/tests/gateway/fresh_install_test.sh @@ -638,6 +638,21 @@ test_http_mode_also_answers_on_443_for_a_proxy_that_forwards_there() { done } +# Without TRUSTED_PROXIES the sender is the visitor, whatever it says. +test_an_unlisted_sender_cannot_name_the_visitor() { + wait_for 200 http "$API" /me -H 'X-Forwarded-For: 203.0.113.7' + expect_json '.headers["x-forwarded-for"]' "$CLIENT_IP" +} + +test_a_trusted_proxy_names_the_visitor() { + echo "TRUSTED_PROXIES=$CLIENT_IP" >>"$PANEL_DIR/overlays/config/traefik-config.env" + install_traefik "$PANEL_DIR/overlays/config/traefik-config.env" >/dev/null || fail "install_traefik failed" + wait_for 200 http "$API" /me -H 'X-Forwarded-For: 203.0.113.7' + expect_json '.headers["x-forwarded-for"]' "203.0.113.7, $CLIENT_IP" + wait_for 200 https "$API" /me -H 'X-Forwarded-For: 203.0.113.7' + expect_json '.headers["x-forwarded-for"]' "203.0.113.7, $CLIENT_IP" +} + start_cluster || exit 1 make_panel || exit 1 run_test test_every_panel_route_names_its_listeners_host @@ -704,5 +719,7 @@ run_test test_http_mode_also_answers_on_443_for_a_proxy_that_forwards_there run_test test_http_mode_shares_443_with_the_shared_gateway run_test test_an_upgrade_keeps_the_shared_certificate run_test test_an_upgrade_renews_a_shared_certificate_about_to_expire +run_test test_an_unlisted_sender_cannot_name_the_visitor +run_test test_a_trusted_proxy_names_the_visitor summary diff --git a/tests/scripts/traefik_install_test.sh b/tests/scripts/traefik_install_test.sh index b6dda4fc..0ff6d7d8 100644 --- a/tests/scripts/traefik_install_test.sh +++ b/tests/scripts/traefik_install_test.sh @@ -381,6 +381,39 @@ test_traefik_waits_behind_servicelb_while_ingress_nginx_holds_the_ports() { assert_not_contains "$(gateway_values)" "hostPort" } +test_no_proxy_is_trusted_until_one_is_listed() { + ports_file HTTP_PORT=80 HTTPS_PORT=443 TRUSTED_PROXIES= + run_install install_traefik + assert_not_contains "$(gateway_values)" "forwardedHeaders" +} + +test_listed_proxies_are_trusted_on_both_ports() { + ports_file HTTP_PORT=80 HTTPS_PORT=443 'TRUSTED_PROXIES=10.0.2.61, 192.168.0.0/24,fd00::1' + run_install install_traefik + assert_contains "$(gateway_values)" 'web: {exposedPort: 80, hostPort: 80, forwardedHeaders: {trustedIPs: ["10.0.2.61", "192.168.0.0/24", "fd00::1"]}}' + assert_contains "$(gateway_values)" 'websecure: {exposedPort: 443, hostPort: 443, forwardedHeaders: {trustedIPs: ["10.0.2.61", "192.168.0.0/24", "fd00::1"]}}' +} + +test_listed_proxies_are_trusted_behind_servicelb_too() { + nginx_ports 80 443 + ports_file HTTP_PORT=80 HTTPS_PORT=443 TRUSTED_PROXIES=10.0.2.61 + run_install install_traefik + assert_contains "$(gateway_values)" 'web: {exposedPort: 80, forwardedHeaders: {trustedIPs: ["10.0.2.61"]}}' +} + +test_a_trusted_proxy_that_is_not_an_address_stops_the_install() { + local bad + for bad in proxy.example.com 0.0.0.0/0 ::/0 10.0.2.61/33 '10.0.2.61;true' '10.0.2.61]}'; do + ports_file HTTP_PORT=80 HTTPS_PORT=443 "TRUSTED_PROXIES=$bad" + rm -f "$WORK_DIR/server-side-applied" + if try_install install_traefik; then + fail "install_traefik took TRUSTED_PROXIES=$bad" + fi + assert_contains "$(cat "$WORK_DIR/output")" "TRUSTED_PROXIES" + assert_eq "$(gateway_values)" "" + done +} + test_traefik_left_behind_servicelb_moves_onto_the_host_ports() { echo LoadBalancer >"$WORK_DIR/traefik-type" ports_file HTTP_PORT=80 HTTPS_PORT=443 diff --git a/utils/install_traefik.sh b/utils/install_traefik.sh index 88215022..8413eea5 100644 --- a/utils/install_traefik.sh +++ b/utils/install_traefik.sh @@ -39,7 +39,7 @@ carry_over_ingress_nginx_ports() { install_traefik() { local ports_env="${1:-$PANEL_DIR/overlays/config/traefik-config.env}" - local minor http_port https_port crd behind nginx crds_values left_out held object deadline + local minor http_port https_port trusted crd behind nginx crds_values left_out held object deadline local -a kc=() handed=() [ -n "${KUBECONFIG:-}" ] && kc=(--kubeconfig="$KUBECONFIG") @@ -61,6 +61,10 @@ install_traefik() { err "HTTP_PORT and HTTPS_PORT in $ports_env must be port numbers" return 1 fi + trusted="$(trusted_proxy_list "$(grep -s '^TRUSTED_PROXIES=' "$ports_env" | tail -n 1 | cut -d= -f2- | tr -d '\r" ')")" || { + err "TRUSTED_PROXIES in $ports_env must be IP addresses or CIDR ranges separated by commas, and never everyone (/0)" + return 1 + } # Where k3s installs the Gateway API itself, gateway-crds brings only # Traefik's CRDs. One that still holds k3s's objects, from before k3s was @@ -123,7 +127,7 @@ install_traefik() { pull_traefik_image || return 1 - install_helm_chart gateway "$PANEL_DIR/charts/gateway" "$(gateway_chart_values "$http_port" "$https_port" "$behind")" || return 1 + install_helm_chart gateway "$PANEL_DIR/charts/gateway" "$(gateway_chart_values "$http_port" "$https_port" "$behind" "$trusted")" || return 1 if ! kubectl "${kc[@]}" -n traefik rollout status deployment/traefik --timeout=300s; then err "Traefik never became ready" @@ -163,23 +167,50 @@ pull_traefik_image() { output_redirect kubectl "${kc[@]}" -n traefik delete pod traefik-pull --wait=false } -# gateway_chart_values HTTP_PORT HTTPS_PORT [servicelb]: the gateway chart's -# values. Traefik binds the host's ports itself, or with servicelb sits behind -# ServiceLB while ingress-nginx still holds them. Only the node running +# trusted_proxy_list ADDRESSES: TRUSTED_PROXIES as the YAML list Traefik takes, +# or nothing when it is empty. Fails on anything that isn't an address or a +# range, and on /0: trusting everyone lets any visitor name its own address. +trusted_proxy_list() { + local entry list="" + local v4='^([0-9]{1,3}\.){3}[0-9]{1,3}(/([1-9]|[12][0-9]|3[0-2]))?$' + local v6='^[0-9a-fA-F]*:[0-9a-fA-F:]*(/([1-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$' + local -a entries=() + IFS=',' read -r -a entries <<<"$1" + for entry in "${entries[@]}"; do + if [ -z "$entry" ]; then + continue + fi + if ! [[ "$entry" =~ $v4 || "$entry" =~ $v6 ]]; then + return 1 + fi + list="${list:+$list, }\"$entry\"" + done + if [ -n "$list" ]; then + printf '[%s]' "$list" + fi +} + +# gateway_chart_values HTTP_PORT HTTPS_PORT [servicelb] [TRUSTED]: the gateway +# chart's values. Traefik binds the host's ports itself, or with servicelb sits +# behind ServiceLB while ingress-nginx still holds them. Only the node running # Traefik listens on these ports, and DNS points at the server node, not at a -# game node that joined later. +# game node that joined later. TRUSTED is trusted_proxy_list's: the reverse +# proxies whose X-Forwarded-* Traefik keeps instead of writing its own. gateway_chart_values() { - local binding + local binding trust="" + if [ -n "${4:-}" ]; then + trust=", forwardedHeaders: {trustedIPs: $4}" + fi if [ "${3:-}" = servicelb ]; then binding=" ports: - web: {exposedPort: $1} - websecure: {exposedPort: $2} + web: {exposedPort: $1$trust} + websecure: {exposedPort: $2$trust} service: spec: {type: LoadBalancer, externalTrafficPolicy: Local}" else binding=" ports: - web: {exposedPort: $1, hostPort: $1} - websecure: {exposedPort: $2, hostPort: $2}" + web: {exposedPort: $1, hostPort: $1$trust} + websecure: {exposedPort: $2, hostPort: $2$trust}" fi cat <