Docker and the Linux firewall (iptables)

Published:  15/06/2023 10:20

Introduction

Docker uses netfilter (the Linux firewall) rules to create its internal container network and port bindings to the host system.

These rules have precedence over regular rules and may make it harder to filter traffic to containers not to mention the risk of unexpected behavior on container traffic.

Podman works differently and poses different possible firewall issues. We'll probably release a similar article about Podman later.

Example issue

Let's say you have a container running with a global port binding, for instance port TCP 22 for SSH.

You try blocking that port using a rule in the INPUT chain, e.g.:

iptables -A INPUT -p tcp -s !172.20.0.0/16 --dport 22 -j REJECT

It doesn't matter whether that rule is inserted before all the Docker rules or not, it won't block traffic and the port will still be fully accessible, which may be a security issue.

There's a warning on the Docker documentation page about installing Docker on Debian underlining this very issue:

Warning on the doc page: if you use ufw or firewalld to manage firewall settings, be aware that when you expose certain ports using Docker, these ports bypass your firewall rules

They mention ufw and firewalld but the issue will happen with basic iptables as well unless you use a very specific iptables chain as described later in the article.

The Docker iptables chains

Docker creates several of its own iptables chains.

You can see it using:

iptables -nL

Example listing:

Chain INPUT (policy ACCEPT)
target     prot opt source               destination

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination
DOCKER-USER  all  --  0.0.0.0/0            0.0.0.0/0
DOCKER-ISOLATION-STAGE-1  all  --  0.0.0.0/0            0.0.0.0/0
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
DOCKER     all  --  0.0.0.0/0            0.0.0.0/0
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination

Chain DOCKER (1 references)
target     prot opt source               destination
ACCEPT     tcp  --  0.0.0.0/0            172.17.0.2           tcp dpt:5667

Chain DOCKER-ISOLATION-STAGE-1 (1 references)
target     prot opt source               destination
DOCKER-ISOLATION-STAGE-2  all  --  0.0.0.0/0            0.0.0.0/0
RETURN     all  --  0.0.0.0/0            0.0.0.0/0

Chain DOCKER-ISOLATION-STAGE-2 (1 references)
target     prot opt source               destination
DROP       all  --  0.0.0.0/0            0.0.0.0/0
RETURN     all  --  0.0.0.0/0            0.0.0.0/0

Chain DOCKER-USER (1 references)
target     prot opt source               destination
RETURN     all  --  0.0.0.0/0            0.0.0.0/0

In regular cases, the port bindings should appear in the DOCKER chain, as seen with the port 5667 in the example listing above.

Prerequisite: persistent firewall rules

Adding rules with iptables doesn't make them persistent. Rules are compiled as an in-memory set ready to use for the kernel.

There are multiple ways to persist firewall rules. As of today and for the Debian distribution, we believe the easiest way is to use a special package called iptables-persistent.

First you need to install "iptables" as the utility isn't present by default because Debian 11 uses nftables (with the utility nft) which we may dedicate a future blog article to.

After Docker is installed, you can install these packages:

apt install iptables iptables-persistent

The config wizard will ask you if you want to save the current firewall rules now and you should always say yes, unless, for instance, Docker isn't installed yet.

If you need to manually persist the rules, you can always use the iptables-save utility:

iptables-save > /etc/iptables/rules.v4

Obviously only for the ipv4 rules. Make sure to have full coverage of rulesets if you use ipv6 for public access as well.

Do note that you still have to manually save the iptables rules with the command shown above when you add, delete or edit rules or they wont be restored at the next system start.

The DOCKER-USER chain

The DOCKER-USER chain is meant to hold your own firewall rules that are processed before all of the other rules and especially before the other Docker-related chains.

However, using it to filter by source IP address or range, which is almost always what we'll want to do, isn't that easy.

It requires using kernel connection tracking which isn't the fastest thing in the world but will work for your basic needs.

Allow everyone but some source IP addresses/ranges

First we need to allow already established traffic or nothing will work, the following rule has to be added first, and only once (don't forget to persist your rules using iptables-save when relevant):

iptables -I DOCKER-USER -p tcp -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

Now if you want to allow everyone to connect to a specific port except for, let's say, IP range 192.168.0.0/24, you could just add the following rule:

iptables -I DOCKER-USER -p tcp -m conntrack --ctorigsrc 192.168.0.0/24 --ctorigdstport 80 -j DROP

And it'll work, except what we usually want to do is block everyone by default and only allow certain IP addresses or ranges.

Also, while we're at it, do note that we need to specify the protocol (tcp or udp) and you'll have to duplicate the rule when you need to support both.

Block everyone but some source IP addresses/ranges

A more classical use case is only allowing specific IP addresses or ranges to reach the exposed container port.

Since rules are processed from top to bottom with the first matching rule getting applied, we need some kind of DROP or REJECT rules for the port at the very end of the ruleset, order is very important.

The DOCKER-USER chain should already come with a RETURN rule that can stay there provided it's always the very last rule:

~# iptables -L DOCKER-USER

Chain DOCKER-USER (1 references)
target     prot opt source               destination         
RETURN     all  --  anywhere             anywhere

To create the initial setup you can use the "-I" option of iptables (to insert) in that order so that the first rule will appear last (well, right before the initial RETURN rule):

iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 80 -j DROP
iptables -I DOCKER-USER -p tcp -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -I DOCKER-USER -s 172.17.0.0/16 -j ACCEPT

Where the container port in this example is TCP 80, make sure to change it to your needs.

We're always allowing the whole 172.17.0.0/16 range because without that, the containers won't be able to use the blocked port (port 80 in this example) at all for any destination IP address in the outside and that's pretty much never desired.

172.17.0.0/16 is the Docker network range on that host, it can sometimes be set to something else. Check that it's correct by looking at the assigned IP address and network on interface docker0, for instance by issuing the command:

ip -c a

Now when we want to allow some IP address or range to reach that port, we can just insert a rule above like so:

iptables -I DOCKER-USER -s <IP_ADDRESS_OR_RANGE> -p tcp -m conntrack --ctorigdstport 80 -j ACCEPT

Ruleset for DOCKER-USER should now look like so:

~# iptables -L DOCKER-USER
Chain DOCKER-USER (1 references)
target     prot opt source               destination         
ACCEPT     tcp  --  192.168.77.177       anywhere             ctorigdstport 80
ACCEPT     all  --  172.17.0.0/16        anywhere            
ACCEPT     tcp  --  anywhere             anywhere             ctstate RELATED,ESTABLISHED
DROP       tcp  --  anywhere             anywhere             ctorigdstport 80
RETURN     all  --  anywhere             anywhere

Where I allowed 192.168.77.177 to reach the Docker port 80 in this example.

Add more of these rules to allow more traffic to the ports you want.

Note: the Docker host should still be able to reach the port as its traffic doesn't seem to go through the DOCKER-USER chain. Please correct me in the comments if that's wrong.

You can find some more info about iptables filtering on the official documentation.

Conclusion & alternatives

There are alternatives to the solution presented here.

However, you probably now understand that Docker networking isn't that simple.

It's possible to use another dedicated bridge or even the "host" network for Docker container (the later is sometimes used for performance reasons) as well as more intricated firewall rules or userland forwarding / proxy programs, but that'll be a subject for another time.

In the meantime when you just want to bind a Docker container to a local port on the Docker host, you can specify 127.0.0.1 in the port bindings to make it only bind to the loopback interface.

E.g. for a simple Nginx container:

docker run -d --name nginx-server -p 127.0.0.1:80:80 nginx

Comments

Loading...