fix(k3s): deny credential-bearing host ports to pod sources (#399)

* fix(k3s): deny credential-bearing host ports to pod sources

* test: assert the k3s pod firewall drops sensitive ports

* docs(security): record the pod firewall boundary
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-07 15:03:56 +08:00
1 parent b79c5807ae
commit 43f39e8e9f
5 files changed
+61 -6

No files matched your search

+5
View File
@@ -198,6 +198,11 @@ checks remain necessary after deployment.
`sec=sys,no_root_squash`. File mode 0755 does not contain an allowed client with root privileges; `sec=sys,no_root_squash`. File mode 0755 does not contain an allowed client with root privileges;
such a client can act as server root within this export. The network allowlist and client trust such a client can act as server root within this export. The network allowlist and client trust
are therefore material boundaries, not merely filesystem permissions. are therefore material boundaries, not merely filesystem permissions.
- **Pod-to-host firewall:** pod CIDR sources (`10.0.0.0/8`, `fd00::/104`) are accepted on the k3s
nodes, but the credential-bearing host ports are denied first (SSH, NFS, Postgres, restic, VNC,
Immich, and the exporter/monitoring ports). The broad accept stays so Cilium and in-cluster
service traffic keep working; a full port allowlist is future work. Ports 80/443 are deliberately
not denied, so in-cluster calls through the ingress are not broken.
Only selected pod security fields, RBAC rules/binding metadata, listeners and export metadata were Only selected pod security fields, RBAC rules/binding metadata, listeners and export metadata were
queried. Secret contents and raw kubeconfigs were not inspected. No live access policy, workload, queried. Secret contents and raw kubeconfigs were not inspected. No live access policy, workload,
+9 -3
View File
@@ -25,10 +25,16 @@ in
enableIPv6 = true; enableIPv6 = true;
}; };
# Cilium pods reach host services (e.g. the API server) from the pod CIDR. # Pods may reach the host services they need, but they are not part of the
# `mkAfter` keeps this after the shared base rules (incl. the node_exporter # host's trusted LAN. Deny the credential-bearing host ports before the broad
# drop), so pods cannot scrape node_exporter either. # pod-network accept below, so a compromised pod cannot reach NFS, the
# database, the backup server, the VNC consoles, or an exporter. `mkAfter`
# keeps this after the shared base rules (incl. the node_exporter drop).
# Ports 80/443 stay reachable so in-cluster calls through the ingress are not
# broken; the ingress is the authentication boundary there.
networking.firewall.extraInputRules = pkgs.lib.mkAfter '' networking.firewall.extraInputRules = pkgs.lib.mkAfter ''
ip saddr 10.0.0.0/8 tcp dport { 22, 2049, 2283, 5432, 5900, 5901, 5902, 5903, 8000, 8081, 8082, 9090, 9093, 9100, 9633, 9835 } drop
ip6 saddr fd00::/104 tcp dport { 22, 2049, 2283, 5432, 5900, 5901, 5902, 5903, 8000, 8081, 8082, 9090, 9093, 9100, 9633, 9835 } drop
ip saddr 10.0.0.0/8 accept ip saddr 10.0.0.0/8 accept
ip6 saddr fd00::/104 accept ip6 saddr fd00::/104 accept
''; '';
+9 -3
View File
@@ -52,10 +52,16 @@ in
enableIPv6 = true; enableIPv6 = true;
}; };
# Cilium pods reach host services (e.g. the API server) from the pod CIDR. # Pods may reach the host services they need, but they are not part of the
# `mkAfter` keeps this after the shared base rules (incl. the node_exporter # host's trusted LAN. Deny the credential-bearing host ports before the broad
# drop), so pods cannot scrape node_exporter either. # pod-network accept below, so a compromised pod cannot reach NFS, the
# database, the backup server, the VNC consoles, or an exporter. `mkAfter`
# keeps this after the shared base rules (incl. the node_exporter drop).
# Ports 80/443 stay reachable so in-cluster calls through the ingress are not
# broken; the ingress is the authentication boundary there.
networking.firewall.extraInputRules = lib.mkAfter '' networking.firewall.extraInputRules = lib.mkAfter ''
ip saddr 10.0.0.0/8 tcp dport { 22, 2049, 2283, 5432, 5900, 5901, 5902, 5903, 8000, 8081, 8082, 9090, 9093, 9100, 9633, 9835 } drop
ip6 saddr fd00::/104 tcp dport { 22, 2049, 2283, 5432, 5900, 5901, 5902, 5903, 8000, 8081, 8082, 9090, 9093, 9100, 9633, 9835 } drop
ip saddr 10.0.0.0/8 accept ip saddr 10.0.0.0/8 accept
ip6 saddr fd00::/104 accept ip6 saddr fd00::/104 accept
''; '';
@@ -0,0 +1,5 @@
{
lib,
outputs,
}:
lib.genAttrs (builtins.attrNames outputs.nixosConfigurations) (_: true)
@@ -0,0 +1,33 @@
{
lib,
outputs,
}:
let
# Index of the first occurrence of `needle` in `haystack`, or null.
firstIndex =
needle: haystack:
let
parts = lib.splitString needle haystack;
in
if builtins.length parts < 2 then null else builtins.stringLength (builtins.head parts);
in
lib.genAttrs (builtins.attrNames outputs.nixosConfigurations) (
name:
let
cfg = outputs.nixosConfigurations.${name}.config;
rules = cfg.networking.firewall.extraInputRules;
dropAt = firstIndex "saddr 10.0.0.0/8 tcp dport" rules;
acceptAt = firstIndex "saddr 10.0.0.0/8 accept" rules;
in
if !(cfg.services.k3s.enable or false) then
true
else
# The port deny must exist and must precede the broad pod accept, because
# nftables takes the first matching rule.
dropAt != null
&& acceptAt != null
&& dropAt < acceptAt
&& lib.hasInfix "2049" rules
&& lib.hasInfix "5432" rules
&& lib.hasInfix "5902" rules
)