roothelper: pin bridge name + IP + CIDR to a banger-managed shape

priv.ensure_bridge / priv.create_tap accepted the daemon's network
config triple (BridgeName, BridgeIP, CIDR) and forwarded it straight
to `ip link` / `ip addr` / `ip link set master`. Argv-style exec
ruled out shell injection, but the kernel happily honours those
commands against any iface a compromised owner-uid daemon names —
including eth0/docker0/lo. Concretely:

  * priv.ensure_bridge could `ip link set <iface> up` against any
    host interface and `ip addr add` arbitrary IP/CIDR to it.
  * priv.create_tap could `ip link set <new-tap> master <iface>`,
    bridging the per-VM tap into the host's primary LAN so the
    guest sees host-local broadcast traffic.
  * priv.sync_resolver_routing / priv.clear_resolver_routing only
    enforced "name shaped like a Linux iface" — no banger constraint.

New validators (single chokepoint via validateNetworkConfig):
  * validateBangerBridgeName: name must equal "br-fc" or start with
    "br-fc-". Stops a compromised daemon from naming any host iface
    in these RPCs. Users with a custom bridge keep the prefix.
  * validateCIDRPrefix: numeric in [8, 32]. Wider prefixes would
    silently widen the bridge subnet beyond what the daemon intends.
  * validateNetworkConfig bundles bridge-name + validateIPv4 +
    validateCIDRPrefix so every helper RPC that takes the triple
    stays in lockstep.

Wired into methodEnsureBridge, methodCreateTap, and the resolver-
routing pair (replacing the older validateLinuxIfaceName-only check
with the stricter banger-bridge check).

docs/privileges.md updated: the helper-RPC table rows now spell out
the banger-managed bridge constraint, and the trust list includes
the new validators.

Tests: TestValidateBangerBridgeName (default + suffixed accepted,
host ifaces / wrong prefix / oversized rejected), TestValidate
CIDRPrefix (boundary + non-numeric + IPv6-style 64 rejected),
TestValidateNetworkConfig (happy path + each-field-bad cases).
Smoke at JOBS=4 still green — banger's defaults sail through the
new gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-28 16:19:28 -03:00
parent 4004ce2e7e
commit 182bccf8af
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 196 additions and 15 deletions

View file

@ -397,6 +397,102 @@ func TestValidateManagedPathPassesPlainSubpath(t *testing.T) {
}
}
func TestValidateBangerBridgeName(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
arg string
ok bool
}{
{name: "default", arg: "br-fc", ok: true},
{name: "suffixed", arg: "br-fc-alt", ok: true},
{name: "with_whitespace", arg: " br-fc ", ok: true},
{name: "wrong_prefix", arg: "br0", ok: false},
{name: "host_iface", arg: "eth0", ok: false},
{name: "docker", arg: "docker0", ok: false},
{name: "loopback", arg: "lo", ok: false},
{name: "empty", arg: "", ok: false},
{name: "br_dash_only", arg: "br-", ok: false}, // not "br-fc" exactly
{name: "almost_match", arg: "br-fcx", ok: false},
{name: "with_slash", arg: "br-fc/x", ok: false},
{name: "too_long", arg: "br-fc-aaaaaaaaaa", ok: false}, // 16 chars
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateBangerBridgeName(tc.arg)
if tc.ok && err != nil {
t.Fatalf("validateBangerBridgeName(%q) = %v, want nil", tc.arg, err)
}
if !tc.ok && err == nil {
t.Fatalf("validateBangerBridgeName(%q) succeeded, want error", tc.arg)
}
})
}
}
func TestValidateCIDRPrefix(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
arg string
ok bool
}{
{name: "default_24", arg: "24", ok: true},
{name: "min_8", arg: "8", ok: true},
{name: "max_32", arg: "32", ok: true},
{name: "with_whitespace", arg: " 16 ", ok: true},
{name: "below_min", arg: "7", ok: false},
{name: "above_max", arg: "33", ok: false},
{name: "non_numeric", arg: "abc", ok: false},
{name: "ipv6_prefix", arg: "64", ok: false}, // outside [8, 32]
{name: "with_slash", arg: "/24", ok: false},
{name: "empty", arg: "", ok: false},
{name: "negative", arg: "-1", ok: false},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateCIDRPrefix(tc.arg)
if tc.ok && err != nil {
t.Fatalf("validateCIDRPrefix(%q) = %v, want nil", tc.arg, err)
}
if !tc.ok && err == nil {
t.Fatalf("validateCIDRPrefix(%q) succeeded, want error", tc.arg)
}
})
}
}
func TestValidateNetworkConfig(t *testing.T) {
t.Parallel()
good := NetworkConfig{
BridgeName: "br-fc",
BridgeIP: "172.16.0.1",
CIDR: "24",
}
if err := validateNetworkConfig(good); err != nil {
t.Fatalf("validateNetworkConfig(default) = %v, want nil", err)
}
for _, tc := range []struct {
name string
mutate func(NetworkConfig) NetworkConfig
}{
{name: "bad_bridge", mutate: func(c NetworkConfig) NetworkConfig { c.BridgeName = "eth0"; return c }},
{name: "bad_ip", mutate: func(c NetworkConfig) NetworkConfig { c.BridgeIP = "::1"; return c }},
{name: "bad_cidr", mutate: func(c NetworkConfig) NetworkConfig { c.CIDR = "/24"; return c }},
{name: "missing_ip", mutate: func(c NetworkConfig) NetworkConfig { c.BridgeIP = ""; return c }},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if err := validateNetworkConfig(tc.mutate(good)); err == nil {
t.Fatalf("validateNetworkConfig(%s) succeeded, want error", tc.name)
}
})
}
}
func TestValidateLinuxIfaceName(t *testing.T) {
t.Parallel()