1
0
mirror of https://github.com/home-assistant/operating-system.git synced 2026-04-02 00:27:14 +01:00

Add patches with fixes for Docker engine (#4605)

This adds two patches with fixes/improvements for the Docker engine

- `0001-daemon-respect-explicit-AppArmor-profile-on-privileg.patch`:
  Makes sure that AppArmor rules are always loaded, also on reboot. This
  is a long standing bug in Docker and affects Supervisor which is a
  privileged container with an AppArmor profile.
  Upstream PR: https://github.com/moby/moby/pull/52215
- `0002-bridge-protect-bridge-subnet-from-direct-external-ac.patch`:
  Makes sure that the whole network (including gateway IP) of any Docker
  bridge network in NAT mode is firewalled from access from the outside.
  This essentially implements on Docker level what Supervisor applies on
  startup with https://github.com/home-assistant/supervisor/pull/6650.
  Upstream PR: https://github.com/moby/moby/pull/52224.
This commit is contained in:
Stefan Agner
2026-03-30 11:25:00 +02:00
committed by GitHub
parent a64767b123
commit 50c1efdb3a
2 changed files with 392 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
From 95063179445feb04e310d2b2af547c43b505f9ea Mon Sep 17 00:00:00 2001
From: Stefan Agner <stefan@agner.ch>
Date: Wed, 25 Mar 2026 10:47:05 +0100
Subject: [PATCH] daemon: respect explicit AppArmor profile on privileged
containers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
In saveAppArmorConfig, the privileged check unconditionally overwrites
the AppArmor profile to "unconfined", even when the user explicitly set
a custom profile via --security-opt apparmor=<profile>. This causes
the persisted container.AppArmorProfile to be "unconfined" regardless
of user intent.
On first container start this is masked because createSpec/WithApparmor
runs before saveAppArmorConfig and builds the OCI spec from the
in-memory value (which still has the correct profile from creation).
But saveAppArmorConfig then overwrites it to "unconfined" and
CheckpointTo persists that to disk. On subsequent starts (restart,
stop+start, host reboot), the container loads with AppArmorProfile
"unconfined", and WithApparmor picks up that stale value — resulting
in the container running without its intended AppArmor policy.
Fix the condition nesting so that "unconfined" and "docker-default" are
only applied as defaults when no explicit profile was set via
SecurityOpt. This makes saveAppArmorConfig consistent with the existing
behavior in both WithApparmor (oci_linux.go) and execSetPlatformOpt
(exec_linux.go), which already give explicit profiles precedence over
the privileged default.
The bug was introduced in d97a00df (Docker 17.04, #27083) and survived
a refactor in 483aa629 (#43130).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stefan Agner <stefan@agner.ch>
---
daemon/container_linux.go | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/daemon/container_linux.go b/daemon/container_linux.go
index 11d258f462..5348ac9377 100644
--- a/daemon/container_linux.go
+++ b/daemon/container_linux.go
@@ -18,10 +18,12 @@ func (daemon *Daemon) saveAppArmorConfig(container *container.Container) error {
return errdefs.InvalidParameter(err)
}
- if container.HostConfig.Privileged {
- container.AppArmorProfile = unconfinedAppArmorProfile
- } else if container.AppArmorProfile == "" {
- container.AppArmorProfile = defaultAppArmorProfile
+ if container.AppArmorProfile == "" {
+ if container.HostConfig.Privileged {
+ container.AppArmorProfile = unconfinedAppArmorProfile
+ } else {
+ container.AppArmorProfile = defaultAppArmorProfile
+ }
}
return nil
}
--
2.53.0

View File

@@ -0,0 +1,328 @@
From f6f8b309322869c1e2cb7d7c847b1eb8fb67ed80 Mon Sep 17 00:00:00 2001
From: Stefan Agner <stefan@agner.ch>
Date: Tue, 10 Mar 2026 23:29:13 +0100
Subject: [PATCH] bridge: protect bridge subnet from direct external access in
raw PREROUTING
Add a per-network rule to the raw PREROUTING chain dropping packets
destined to any address in the bridge subnet from interfaces other than
the bridge itself, loopback, or configured trusted host interfaces.
This covers both containers on unpublished ports and the bridge's own
gateway address (the host-side bridge IP, e.g. 172.30.32.1), which is
otherwise reachable from external hosts that have a route to the bridge
subnet. A single network-level subnet rule is simpler than per-container
rules and ensures the gateway address is covered by the same policy.
For iptables, the following rules are added per bridge (plus one ACCEPT
per trusted interface):
-t raw -A PREROUTING -d <subnet> -i lo -j ACCEPT
-t raw -A PREROUTING -d <subnet> ! -i <bridge> -j DROP
For nftables, a single compound rule is used:
<family> daddr <subnet> iifname != { <bridge>, lo, ... } drop
Loopback is always permitted: the gateway IP is a valid host address
reachable from local processes via lo. The rules are not added for
gateway modes "routed" or "nat-unprotected", nor when
--allow-direct-routing is set, consistent with the intent of those
options. DOCKER_INSECURE_NO_IPTABLES_RAW=1 disables the iptables rules.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stefan Agner <stefan@agner.ch>
---
.../bridge/internal/iptabler/cleaner.go | 14 +---
.../bridge/internal/iptabler/endpoint.go | 68 +------------------
.../bridge/internal/iptabler/network.go | 48 +++++++++++++
.../bridge/internal/nftabler/endpoint.go | 64 +----------------
.../bridge/internal/nftabler/network.go | 18 +++++
5 files changed, 74 insertions(+), 138 deletions(-)
diff --git a/daemon/libnetwork/drivers/bridge/internal/iptabler/cleaner.go b/daemon/libnetwork/drivers/bridge/internal/iptabler/cleaner.go
index 1de527b08b..9f702a5ec7 100644
--- a/daemon/libnetwork/drivers/bridge/internal/iptabler/cleaner.go
+++ b/daemon/libnetwork/drivers/bridge/internal/iptabler/cleaner.go
@@ -81,17 +81,9 @@ func (ic iptablesCleaner) DelNetwork(ctx context.Context, nc firewaller.NetworkC
}
}
-func (ic iptablesCleaner) DelEndpoint(ctx context.Context, nc firewaller.NetworkConfig, epIPv4, epIPv6 netip.Addr) {
- n := network{
- config: nc,
- ipt: &Iptabler{config: ic.config},
- }
- if n.ipt.config.IPv4 && epIPv4.IsValid() {
- _ = n.filterDirectAccess(ctx, iptables.IPv4, n.config.Config4, epIPv4, false)
- }
- if n.ipt.config.IPv6 && epIPv6.IsValid() {
- _ = n.filterDirectAccess(ctx, iptables.IPv6, n.config.Config6, epIPv6, false)
- }
+func (ic iptablesCleaner) DelEndpoint(_ context.Context, _ firewaller.NetworkConfig, _, _ netip.Addr) {
+ // Direct access filtering is now done at the network (subnet) level, not
+ // per-endpoint. There are no per-endpoint raw PREROUTING rules to clean up.
}
func (ic iptablesCleaner) DelPorts(ctx context.Context, nc firewaller.NetworkConfig, pbs []types.PortBinding) {
diff --git a/daemon/libnetwork/drivers/bridge/internal/iptabler/endpoint.go b/daemon/libnetwork/drivers/bridge/internal/iptabler/endpoint.go
index 69a5bbc4d2..af8ff3c83c 100644
--- a/daemon/libnetwork/drivers/bridge/internal/iptabler/endpoint.go
+++ b/daemon/libnetwork/drivers/bridge/internal/iptabler/endpoint.go
@@ -5,74 +5,12 @@ package iptabler
import (
"context"
"net/netip"
-
- "github.com/moby/moby/v2/daemon/libnetwork/drivers/bridge/internal/firewaller"
- "github.com/moby/moby/v2/daemon/libnetwork/iptables"
)
-func (n *network) AddEndpoint(ctx context.Context, epIPv4, epIPv6 netip.Addr) error {
- return n.modEndpoint(ctx, epIPv4, epIPv6, true)
-}
-
-func (n *network) DelEndpoint(ctx context.Context, epIPv4, epIPv6 netip.Addr) error {
- return n.modEndpoint(ctx, epIPv4, epIPv6, false)
-}
-
-func (n *network) modEndpoint(ctx context.Context, epIPv4, epIPv6 netip.Addr, enable bool) error {
- if n.ipt.config.IPv4 && epIPv4.IsValid() {
- if err := n.filterDirectAccess(ctx, iptables.IPv4, n.config.Config4, epIPv4, enable); err != nil {
- return err
- }
- }
- if n.ipt.config.IPv6 && epIPv6.IsValid() {
- if err := n.filterDirectAccess(ctx, iptables.IPv6, n.config.Config6, epIPv6, enable); err != nil {
- return err
- }
- }
+func (n *network) AddEndpoint(_ context.Context, _, _ netip.Addr) error {
return nil
}
-// filterDirectAccess drops packets addressed directly to the container's IP address,
-// when direct routing is not permitted by network configuration.
-//
-// It is a no-op if:
-// - the network is internal
-// - gateway mode is "nat-unprotected" or "routed".
-// - direct routing is enabled at the daemon level.
-// - "raw" rules are disabled (possibly because the host doesn't have the necessary
-// kernel support).
-//
-// Packets originating on the bridge's own interface and addressed directly to the
-// container are allowed - the host always has direct access to its own containers
-// (it doesn't need to use the port mapped to its own addresses, although it can).
-//
-// "Trusted interfaces" are treated in the same way as the bridge itself.
-func (n *network) filterDirectAccess(ctx context.Context, ipv iptables.IPVersion, config firewaller.NetworkConfigFam, epIP netip.Addr, enable bool) error {
- if n.config.Internal || config.Unprotected || config.Routed {
- return nil
- }
- // For config that may change between daemon restarts, make sure rules are
- // removed - if the container was left running when the daemon stopped, and
- // direct routing has since been disabled, the rules need to be deleted when
- // cleanup happens on restart. This also means a change in config over a
- // live-restore restart will take effect.
- if n.ipt.config.AllowDirectRouting || rawRulesDisabled(ctx) {
- enable = false
- }
- for _, ifName := range n.config.TrustedHostInterfaces {
- accept := iptables.Rule{IPVer: ipv, Table: iptables.Raw, Chain: "PREROUTING", Args: []string{
- "-d", epIP.String(),
- "-i", ifName,
- "-j", "ACCEPT",
- }}
- if err := appendOrDelChainRule(accept, "DIRECT ACCESS FILTERING - ACCEPT", enable); err != nil {
- return err
- }
- }
- accept := iptables.Rule{IPVer: ipv, Table: iptables.Raw, Chain: "PREROUTING", Args: []string{
- "-d", epIP.String(),
- "!", "-i", n.config.IfName,
- "-j", "DROP",
- }}
- return appendOrDelChainRule(accept, "DIRECT ACCESS FILTERING - DROP", enable)
+func (n *network) DelEndpoint(_ context.Context, _, _ netip.Addr) error {
+ return nil
}
diff --git a/daemon/libnetwork/drivers/bridge/internal/iptabler/network.go b/daemon/libnetwork/drivers/bridge/internal/iptabler/network.go
index 5eef4d6690..7ff03f4249 100644
--- a/daemon/libnetwork/drivers/bridge/internal/iptabler/network.go
+++ b/daemon/libnetwork/drivers/bridge/internal/iptabler/network.go
@@ -97,6 +97,13 @@ func (n *network) setupIPTables(ctx context.Context, ipVersion iptables.IPVersio
return n.setupNonInternalNetworkRules(ctx, ipVersion, config, false)
})
+ if err := n.setSubnetProtection(ctx, ipVersion, config, true); err != nil {
+ return fmt.Errorf("Failed to setup subnet protection: %w", err)
+ }
+ n.registerCleanFunc(func() error {
+ return n.setSubnetProtection(ctx, ipVersion, config, false)
+ })
+
if err := deleteLegacyFilterRules(ipVersion, n.config.IfName); err != nil {
return fmt.Errorf("failed to delete legacy rules in filter-FORWARD: %w", err)
}
@@ -360,6 +367,47 @@ func (n *network) setupNonInternalNetworkRules(ctx context.Context, ipVer iptabl
return nil
}
+// setSubnetProtection drops packets addressed directly to any IP in the bridge
+// subnet from interfaces other than the bridge or loopback. This prevents external
+// hosts that have a route to the bridge subnet from accessing containers on
+// unpublished ports, or services on the host bound to the gateway address.
+//
+// It is a no-op if:
+// - gateway mode is "nat-unprotected" or "routed" (direct access is intentional).
+// - direct routing is enabled at the daemon level.
+// - "raw" rules are disabled.
+func (n *network) setSubnetProtection(ctx context.Context, ipv iptables.IPVersion, config firewaller.NetworkConfigFam, enable bool) error {
+ if config.Unprotected || config.Routed || !config.Prefix.IsValid() {
+ return nil
+ }
+ if n.ipt.config.AllowDirectRouting || rawRulesDisabled(ctx) {
+ enable = false
+ }
+ subnet := config.Prefix.String()
+ // Accept loopback traffic to the subnet. This is needed for host-local access
+ // to the gateway IP, which is assigned to the bridge and reachable via lo.
+ loAccept := iptables.Rule{IPVer: ipv, Table: iptables.Raw, Chain: "PREROUTING", Args: []string{
+ "-d", subnet, "-i", "lo", "-j", "ACCEPT",
+ }}
+ if err := appendOrDelChainRule(loAccept, "SUBNET PROTECTION - ACCEPT LO", enable); err != nil {
+ return err
+ }
+ // Accept traffic from trusted interfaces (e.g. flannel.1 in VXLAN setups).
+ for _, ifName := range n.config.TrustedHostInterfaces {
+ accept := iptables.Rule{IPVer: ipv, Table: iptables.Raw, Chain: "PREROUTING", Args: []string{
+ "-d", subnet, "-i", ifName, "-j", "ACCEPT",
+ }}
+ if err := appendOrDelChainRule(accept, "SUBNET PROTECTION - ACCEPT TRUSTED", enable); err != nil {
+ return err
+ }
+ }
+ // Drop traffic from any other non-bridge interface to the subnet.
+ extDrop := iptables.Rule{IPVer: ipv, Table: iptables.Raw, Chain: "PREROUTING", Args: []string{
+ "-d", subnet, "!", "-i", n.config.IfName, "-j", "DROP",
+ }}
+ return appendOrDelChainRule(extDrop, "SUBNET PROTECTION - DROP", enable)
+}
+
func setIcc(ctx context.Context, version iptables.IPVersion, bridgeIface string, iccEnable, internal, insert bool) error {
args := []string{"-i", bridgeIface, "-o", bridgeIface, "-j"}
acceptRule := iptables.Rule{IPVer: version, Table: iptables.Filter, Chain: DockerForwardChain, Args: append(args, "ACCEPT")}
diff --git a/daemon/libnetwork/drivers/bridge/internal/nftabler/endpoint.go b/daemon/libnetwork/drivers/bridge/internal/nftabler/endpoint.go
index cfb45186d2..02b8dbcda1 100644
--- a/daemon/libnetwork/drivers/bridge/internal/nftabler/endpoint.go
+++ b/daemon/libnetwork/drivers/bridge/internal/nftabler/endpoint.go
@@ -4,76 +4,16 @@ package nftabler
import (
"context"
- "fmt"
"net/netip"
- "strings"
-
- "github.com/moby/moby/v2/daemon/libnetwork/drivers/bridge/internal/firewaller"
- "github.com/moby/moby/v2/daemon/libnetwork/internal/nftables"
)
func (n *network) AddEndpoint(ctx context.Context, epIPv4, epIPv6 netip.Addr) error {
if n.fw.cleaner != nil {
n.fw.cleaner.DelEndpoint(ctx, n.config, epIPv4, epIPv6)
}
- return n.modEndpoint(ctx, epIPv4, epIPv6, true)
-}
-
-func (n *network) DelEndpoint(ctx context.Context, epIPv4, epIPv6 netip.Addr) error {
- return n.modEndpoint(ctx, epIPv4, epIPv6, false)
-}
-
-func (n *network) modEndpoint(ctx context.Context, epIPv4, epIPv6 netip.Addr, enable bool) error {
- if n.fw.config.IPv4 && epIPv4.IsValid() {
- tm := nftables.Modifier{}
- updater := tm.Create
- if !enable {
- updater = tm.Delete
- }
- n.filterDirectAccess(updater, nftables.IPv4, n.config.Config4, epIPv4)
- if err := n.fw.table4.Apply(ctx, tm); err != nil {
- return fmt.Errorf("adding rules for bridge %s: %w", n.config.IfName, err)
- }
- }
- if n.fw.config.IPv6 && epIPv6.IsValid() {
- tm := nftables.Modifier{}
- updater := tm.Create
- if !enable {
- updater = tm.Delete
- }
- n.filterDirectAccess(updater, nftables.IPv6, n.config.Config6, epIPv6)
- if err := n.fw.table6.Apply(ctx, tm); err != nil {
- return fmt.Errorf("adding rules for bridge %s: %w", n.config.IfName, err)
- }
- }
return nil
}
-// filterDirectAccess drops packets addressed directly to the container's IP address,
-// when direct routing is not permitted by network configuration.
-//
-// It is a no-op if:
-// - gateway mode is "nat-unprotected" or "routed".
-// - direct routing is enabled at the daemon level.
-// - "raw" rules are disabled (possibly because the host doesn't have the necessary
-// kernel support).
-//
-// Packets originating on the bridge's own interface and addressed directly to the
-// container are allowed - the host always has direct access to its own containers.
-// (It doesn't need to use the port mapped to its own addresses, although it can.)
-//
-// "Trusted interfaces" are treated in the same way as the bridge itself.
-func (n *network) filterDirectAccess(updater func(nftables.Obj), fam nftables.Family, conf firewaller.NetworkConfigFam, epIP netip.Addr) {
- if n.config.Internal || conf.Unprotected || conf.Routed || n.fw.config.AllowDirectRouting {
- return
- }
- ifNames := strings.Join(n.config.TrustedHostInterfaces, ", ")
- updater(nftables.Rule{
- Chain: rawPreroutingChain,
- Group: rawPreroutingPortsRuleGroup,
- Rule: []string{
- string(fam), "daddr", epIP.String(),
- "iifname != {", n.config.IfName, ",", ifNames, `} counter drop comment "DROP DIRECT ACCESS"`,
- },
- })
+func (n *network) DelEndpoint(_ context.Context, _, _ netip.Addr) error {
+ return nil
}
diff --git a/daemon/libnetwork/drivers/bridge/internal/nftabler/network.go b/daemon/libnetwork/drivers/bridge/internal/nftabler/network.go
index dc77c33c1a..9a10d491c2 100644
--- a/daemon/libnetwork/drivers/bridge/internal/nftabler/network.go
+++ b/daemon/libnetwork/drivers/bridge/internal/nftabler/network.go
@@ -174,6 +174,24 @@ func (n *network) configure(ctx context.Context, table nftables.Table, conf fire
})
}
+ // Drop packets destined to any IP in the bridge subnet from interfaces
+ // other than the bridge, loopback, or any trusted host interfaces. This
+ // protects containers on unpublished ports and the host's gateway address
+ // from external hosts that have a direct route to the bridge subnet.
+ if conf.Prefix.IsValid() && !n.fw.config.AllowDirectRouting {
+ family := string(table.Family())
+ ifNames := strings.Join(append([]string{n.config.IfName, "lo"}, n.config.TrustedHostInterfaces...), ", ")
+ tm.Create(nftables.Rule{
+ Chain: rawPreroutingChain,
+ Group: initialRuleGroup,
+ Rule: []string{
+ family, "daddr", conf.Prefix.String(),
+ "iifname != {", ifNames, `}`,
+ `counter drop comment "SUBNET DROP EXTERNAL"`,
+ },
+ })
+ }
+
// ICMP
if conf.Routed {
rule := "ip protocol icmp"
--
2.53.0