Validate port bindings for gateway_mode=routed

When bridge driver opt com.docker.network.bridge.gatway_mode_ipv[46]
is set to "routed", there is no NAT.

When there's no NAT, there's no meaning to the HostPort field in a
port mapping (all the port mapping does is open the container's port),
and the HostIP field is only used to determine the address family.

So, check port bindings, and raise errors if fields are unexpectedly
set when the mapping only applies to a gateway_mode=routed network.
Zero-addresses are allowed, to say the mapping/open-port should be
IPv4-only or IPv6-only, and host ports are not allowed.

A mapping with no host address, so it applies to IPv4 and IPv6 when
the default binding is 0.0.0.0, may include a host port if either
uses NAT. The port number is ignored for the directly-routed family.

Signed-off-by: Rob Murray <rob.murray@docker.com>
This commit is contained in:
Rob Murray
2024-06-11 10:22:18 +01:00
parent 2a291c1855
commit 01eecb6cdf
2 changed files with 179 additions and 1 deletions

View File

@@ -56,6 +56,13 @@ func (n *bridgeNetwork) addPortMappings(
containerIPv6 = epAddrV6.IP
}
disableNAT4, disableNAT6 := n.getNATDisabled()
if err := validatePortBindings(cfg,
!disableNAT4 && len(containerIPv4) > 0,
!disableNAT6 && len(containerIPv6) > 0); err != nil {
return nil, err
}
bindings := make([]portBinding, 0, len(cfg)*2)
defer func() {
@@ -70,7 +77,6 @@ func (n *bridgeNetwork) addPortMappings(
sortAndNormPBs(sortedCfg)
proxyPath := n.userlandProxyPath()
disableNAT4, disableNAT6 := n.getNATDisabled()
// toBind accumulates port bindings that should be allocated the same host port
// (if required by NAT config). If the host address is unspecified, and defHostIP
@@ -130,6 +136,60 @@ func (n *bridgeNetwork) addPortMappings(
return bindings, nil
}
// Limit the number of errors reported, because there may be a lot of port
// bindings (host port ranges are expanded by the CLI).
const validationErrLimit = 6
// validatePortBindings checks that, if NAT is disabled for all uses of a
// PortBinding, no HostPort, or non-zero HostIP, is specified - because they have
// no meaning. A zero HostIP is allowed, as it's used to determine the address
// family.
//
// The default binding IP is not considered, meaning that no error is raised if
// there is a default binding address that is not used but could have been.
//
// For example, the default is an IPv6 interface address, no HostIP is specified,
// and NAT6 is disabled; the default is ignored and no error will be raised. (Note
// that this example may be valid if the container has no IPv6 address, and
// docker-proxy is used to forward between the default IPv6 address and the
// container's IPv4. So, simply disallowing a non-zero IPv6 default when NAT6
// is disabled for the network would be incorrect.)
func validatePortBindings(pbs []types.PortBinding, nat4, nat6 bool) error {
var errs []error
for i := range pbs {
pb := &pbs[i]
disallowHostPort := false
if !nat4 && len(pb.HostIP) > 0 && pb.HostIP.To4() != nil && !pb.HostIP.Equal(net.IPv4zero) {
// There's no NAT4, so don't allow a nonzero IPv4 host address in the mapping. The port will
// accessible via any host interface.
errs = append(errs,
fmt.Errorf("NAT is disabled, omit host address in port mapping %s, or use 0.0.0.0::%d to open port %d for IPv4-only",
pb, pb.Port, pb.Port))
// The mapping is IPv4-specific but there's no NAT4, so a host port would make no sense.
disallowHostPort = true
} else if !nat6 && len(pb.HostIP) > 0 && pb.HostIP.To4() == nil && !pb.HostIP.Equal(net.IPv6zero) {
// There's no NAT6, so don't allow an IPv6 host address in the mapping. The port will
// accessible via any host interface.
errs = append(errs,
fmt.Errorf("NAT is disabled, omit host address in port mapping %s, or use [::]::%d to open port %d for IPv6-only",
pb, pb.Port, pb.Port))
// The mapping is IPv6-specific but there's no NAT6, so a host port would make no sense.
disallowHostPort = true
} else if !nat4 && !nat6 {
// There's no NAT, so it would make no sense to specify a host port.
disallowHostPort = true
}
if disallowHostPort && pb.HostPort != 0 {
errs = append(errs,
fmt.Errorf("host port must not be specified in mapping %s because NAT is disabled", pb))
}
if len(errs) >= validationErrLimit {
break
}
}
return errors.Join(errs...)
}
// sortAndNormPBs normalises cfg by making HostPortEnd=HostPort (rather than 0) if the
// host port isn't a range - and sorts it into the ordering defined by cmpPortBinding.
func sortAndNormPBs(cfg []types.PortBinding) {

View File

@@ -180,6 +180,124 @@ func loopbackUp() error {
return nlHandle.LinkSetUp(iface)
}
func TestValidatePortBindings(t *testing.T) {
testcases := []struct {
name string
nat4 bool
nat6 bool
pbs []types.PortBinding
expErrs []string
}{
{
name: "no nat or addrs or ports",
pbs: []types.PortBinding{
{Proto: types.TCP, Port: 80},
},
},
{
name: "no nat with addrs",
pbs: []types.PortBinding{
{Proto: types.TCP, HostIP: newIPNet(t, "233.252.0.2/24").IP, Port: 80},
{Proto: types.TCP, HostIP: newIPNet(t, "2001:db8::2/64").IP, Port: 80},
},
expErrs: []string{
"NAT is disabled, omit host address in port mapping 233.252.0.2::80/tcp, or use 0.0.0.0::80 to open port 80 for IPv4-only",
"NAT is disabled, omit host address in port mapping [2001:db8::2]::80/tcp, or use [::]::80 to open port 80 for IPv6-only",
},
},
{
name: "no nat with zero addrs",
pbs: []types.PortBinding{
{Proto: types.TCP, HostIP: newIPNet(t, "0.0.0.0/0").IP, Port: 80},
{Proto: types.TCP, HostIP: newIPNet(t, "::/0").IP, Port: 80},
},
},
{
name: "no nat with host port",
pbs: []types.PortBinding{
{Proto: types.TCP, HostPort: 8080, Port: 80},
},
expErrs: []string{
"host port must not be specified in mapping 8080:80/tcp because NAT is disabled",
},
},
{
name: "nat4 any addr with host port",
nat4: true,
pbs: []types.PortBinding{
{Proto: types.TCP, HostPort: 8080, Port: 80},
},
},
{
name: "nat6 any addr with host port",
nat6: true,
pbs: []types.PortBinding{
{Proto: types.TCP, HostPort: 8080, Port: 80},
},
},
{
name: "nat and addrs and ports",
nat4: true,
nat6: true,
pbs: []types.PortBinding{
{Proto: types.TCP, HostIP: newIPNet(t, "233.252.0.2/24").IP, HostPort: 8080, Port: 80},
{Proto: types.TCP, HostIP: newIPNet(t, "2001:db8::2/64").IP, HostPort: 8080, Port: 80},
},
},
{
name: "no nat and addrs and ports",
pbs: []types.PortBinding{
{Proto: types.TCP, HostIP: newIPNet(t, "233.252.0.2/24").IP, HostPort: 8080, Port: 80},
{Proto: types.TCP, HostIP: newIPNet(t, "2001:db8::2/64").IP, HostPort: 8080, Port: 80},
},
expErrs: []string{
"NAT is disabled, omit host address in port mapping 233.252.0.2:8080:80/tcp, or use 0.0.0.0::80 to open port 80 for IPv4-only",
"NAT is disabled, omit host address in port mapping [2001:db8::2]:8080:80/tcp, or use [::]::80 to open port 80 for IPv6-only",
"host port must not be specified in mapping 233.252.0.2:8080:80/tcp because NAT is disabled",
"host port must not be specified in mapping [2001:db8::2]:8080:80/tcp because NAT is disabled",
},
},
{
name: "max errs reached",
pbs: []types.PortBinding{
{Proto: types.TCP, HostPort: 8080, Port: 80},
{Proto: types.TCP, HostPort: 8081, Port: 80},
{Proto: types.TCP, HostPort: 8082, Port: 80},
{Proto: types.TCP, HostPort: 8083, Port: 80},
{Proto: types.TCP, HostPort: 8084, Port: 80},
{Proto: types.TCP, HostPort: 8085, Port: 80},
{Proto: types.TCP, HostPort: 8086, Port: 80},
},
expErrs: []string{
"host port must not be specified in mapping 8080:80/tcp because NAT is disabled",
"host port must not be specified in mapping 8081:80/tcp because NAT is disabled",
"host port must not be specified in mapping 8082:80/tcp because NAT is disabled",
"host port must not be specified in mapping 8083:80/tcp because NAT is disabled",
"host port must not be specified in mapping 8084:80/tcp because NAT is disabled",
"host port must not be specified in mapping 8085:80/tcp because NAT is disabled",
},
},
}
for _, tc := range testcases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
err := validatePortBindings(tc.pbs, tc.nat4, tc.nat6)
if tc.expErrs == nil {
assert.Check(t, err)
} else {
assert.Assert(t, err != nil)
for _, e := range tc.expErrs {
assert.Check(t, is.ErrorContains(err, e))
}
numErrs := len(err.(interface{ Unwrap() []error }).Unwrap())
assert.Check(t, is.Equal(numErrs, len(tc.expErrs)),
fmt.Sprintf("expected %d errors, got %d in %s", len(tc.expErrs), numErrs, err.Error()))
}
})
}
}
func TestCmpPortBindings(t *testing.T) {
pb := types.PortBinding{
Proto: types.TCP,