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;
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.
- **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
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;
};
# Cilium pods reach host services (e.g. the API server) from the pod CIDR.
# `mkAfter` keeps this after the shared base rules (incl. the node_exporter
# drop), so pods cannot scrape node_exporter either.
# Pods may reach the host services they need, but they are not part of the
# host's trusted LAN. Deny the credential-bearing host ports before the broad
# 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 ''
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
ip6 saddr fd00::/104 accept
'';
+9 -3
View File
@@ -52,10 +52,16 @@ in
enableIPv6 = true;
};
# Cilium pods reach host services (e.g. the API server) from the pod CIDR.
# `mkAfter` keeps this after the shared base rules (incl. the node_exporter
# drop), so pods cannot scrape node_exporter either.
# Pods may reach the host services they need, but they are not part of the
# host's trusted LAN. Deny the credential-bearing host ports before the broad
# 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 ''
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
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
)