Docker omzeilt UFW: oorzaak en definitieve oplossing
Docker publiceert poorten via iptables regels die de UFW firewall overslaan. Hierdoor blijven poorten openstaan ondanks een deny regel. Leer hoe u dit veilig oplost.
Waarom Docker UFW omzeilt
Docker omzeilt UFW omdat gepubliceerde containerpoorten nooit de firewallregels passeren die UFW beheert. Wanneer u docker run -p 8080:80 uitvoert, schrijft Docker een DNAT-regel (destination network address translation) naar zijn eigen DOCKER-chain in de nat-tabel van de kernel, waarnaar de PREROUTING-chain verwijst. Die regel herschrijft de bestemming van elk pakket naar het privéadres van de container voordat de kernel beslist waar het pakket naartoe gaat. Het herschreven pakket wordt vervolgens doorgestuurd naar de container via de FORWARD-chain, die door Docker wordt beheerd. De regels van UFW bevinden zich in de INPUT-chain en het pakket komt daar nooit terecht. Hierdoor toont ufw status een standaard weigering, rapporteert sudo ufw deny 8080 succes, en antwoordt poort 8080 nog steeds op het gehele internet.
Dit is geen bug in Docker en UFW is niet defect. Beide tools programmeren dezelfde kernel-firewall. De regels van Docker werken simpelweg op een eerder punt in het pad van het pakket, waardoor UFW nooit wordt geraadpleegd. Deze handleiding demonstreert de omzeiling, legt het mechanisme uit en behandelt vervolgens de twee werkende oplossingen: poorten publiceren op 127.0.0.1 en filteren in de DOCKER-USER-chain. Als UFW nieuw voor u is, stel het dan eerst in met de basisgids voor de UFW-firewall, omdat een firewall met standaard weigering nog steeds de juiste basis vormt voor al het overige op de server.
De bypass op uw eigen server bekijken
Begin op een VPS waar UFW actief is met een standaard 'deny'-beleid voor inkomend verkeer. Start een webcontainer met een gepubliceerde poort:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose toont Default: deny (incoming), allow (outgoing) en geen regel voor poort 8080. Volgens het eigen rapport van de firewall is de poort gesloten. Test dit nu vanaf een andere machine, niet vanaf de server zelf:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKDe container antwoordt. Voeg een expliciete 'deny'-regel toe en test opnieuw:
sudo ufw deny 8080/tcpDe poort antwoordt nog steeds, omdat de 'deny'-regel zich in een chain bevindt die het pakket nooit bereikt. UFW heeft niet gefaald. De firewall werd simpelweg niet geraadpleegd. Dit is ook de reden waarom het probleem zo goed verborgen blijft: er wordt nergens een foutmelding getoond, de implementatie werkt en de status van de firewall ziet eruit als die van een correct beveiligde server.
Het mechanisme: PREROUTING wordt uitgevoerd vóór INPUT
De kernel verwerkt een inkomend pakket in een vaste volgorde, en het gehele probleem vindt zijn oorsprong in die volgorde.
PREROUTINGwordt als eerste uitgevoerd. Regels die vanaf hier worden bereikt, kunnen de bestemming van het pakket herschrijven. De regel van Docker voor een gepubliceerde poort, in deDOCKERchain waarPREROUTINGnaartoe springt, doet precies dat.- De routeringsbeslissing volgt daarna. Een pakket dat geadresseerd is aan de host zelf, gaat naar de
INPUTchain. Een pakket dat geadresseerd is aan een andere machine, gaat naar deFORWARDchain. - De regels van UFW bevinden zich in
INPUT. De regels van Docker bevinden zich inFORWARD.
Bekijk de regel van Docker voor de container die u zojuist heeft gestart:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80De DNAT regel vertelt het hele verhaal. Elk pakket dat binnenkomt voor poort 8080 krijgt zijn bestemming herschreven naar 172.17.0.2:80, het adres van de container op het private bridge-netwerk van Docker. Na de herschrijving is het pakket niet langer geadresseerd aan de host, waardoor de routeringsbeslissing het pakket via het FORWARD pad stuurt. Docker heeft daar al regels toegevoegd die verkeer naar zijn eigen netwerken accepteren. Uw deny 8080/tcp regel wacht in INPUT op een pakket dat nooit aankomt.
Op Ubuntu 24.04 is het iptables commando een front-end voor nftables, maar de volgorde van de chains en het resultaat zijn identiek. Zowel UFW als Docker schrijven naar dezelfde packet pipeline van de kernel, en het toegangspunt van Docker ligt eerder. Dit is niet specifiek voor UFW: firewalld op een Rocky of AlmaLinux VPS filtert op hetzelfde punt in die pipeline en wordt door dezelfde DNAT-regel omzeild. De onderstaande oplossingen zijn daarom ook daar van toepassing.
De dagelijkse oplossing: poorten publiceren op 127.0.0.1
De meeste containers hoefden in de eerste plaats nooit publiek toegankelijk te zijn. Een database, een applicatieserver achter een reverse proxy, een beheerpaneel, een metrics-endpoint: geen van deze hoort direct op het internet te reageren. Publiceer ze op het loopback-adres:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineOf in een Compose-bestand:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Dit werkt omdat de DNAT-regel van Docker nu alleen overeenkomt met pakketten die gericht zijn aan 127.0.0.1. Sinds Docker Engine 28.0.0 voegt Docker bovendien een regel toe in de PREROUTING-chain van de raw-tabel die elk pakket voor die loopback-poort dat op een andere interface dan loopback binnenkomt, dropt. In die releases is de poort bereikbaar vanaf de host, en vanaf niets anders. In releases ouder dan 28.0.0 documenteert Docker dat hosts binnen hetzelfde L2-segment, bijvoorbeeld machines op dezelfde netwerkswitch, poorten die op localhost zijn gepubliceerd nog steeds kunnen bereiken. Controleer daarom docker version en voer een upgrade uit voordat u hierop vertrouwt. Controleer de binding:
sudo ss -tlnp | grep 8080U wilt 127.0.0.1:8080 in de uitvoer zien, niet 0.0.0.0:8080 of [::]:8080. Bevestig vervolgens vanaf een andere machine dat curl http://your-vps-ip:8080/ geen antwoord krijgt. Een pakket dat naar uw publieke adres wordt verzonden, wordt niet langer door Docker herschreven. Het bereikt daarom INPUT, waar het standaard deny-beleid van UFW en de deny 8080/tcp-regel van eerder het pakket stilletjes droppen in plaats van het te weigeren. curl blijft daarom hangen en eindigt in een connection timeout, niet in "connection refused".
Voor de services die wel aan het internet moeten worden blootgesteld, draait u één reverse proxy die poorten 80 en 443 beheert en routeert op basis van hostnaam; publiceer verder niets. Dat is het patroon dat de Traefik reverse proxy handleiding opbouwt, en het is de manier waarop een zelfgehoste applicatie zoals Nextcloud op een VPS onbereikbaar blijft, behalve via de proxy. Hoe ports:-items worden gedeclareerd en de rest van de Compose-workflow worden behandeld in de Docker Compose basis handleiding.
Met elke interne container op loopback doet UFW weer zijn normale werk: het bewaken van de poorten die de host zelf aanbiedt. Bouw die regelset hier op en voer vervolgens de commando's in volgorde uit:
Echte filtering: de DOCKER-USER chain
Soms moet een containerpoort gepubliceerd blijven naar het netwerk, maar wel beperkt worden. Denk aan een database-replica-poort die alleen bereikbaar mag zijn vanaf één kantooradres. Hiervoor biedt Docker de DOCKER-USER chain. Elk pakket dat naar een container gaat, passeert DOCKER-USER voordat de eigen accept-regels van Docker worden toegepast. Deze chain is bedoeld voor uw eigen regels; Docker verwijdert of wijzigt deze niet, ook niet na een herstart van de daemon.
Deze chain hoort bij de standaard iptables-firewall-backend van Docker. Docker Engine 29 heeft een experimentele nftables-backend toegevoegd die u moet inschakelen met de firewall-backend daemon-optie. Hierin bestaat geen DOCKER-USER chain: aangepaste filtering plaatst u in een eigen nftables-tabel, met een base chain op de forward hook. Als u die optie niet heeft gewijzigd, gebruikt u iptables en is alles hieronder van toepassing. Het verschil tussen beide tools wordt behandeld in de gids over iptables versus nftables.
Eén valkuil vóór het commando: tegen de tijd dat een pakket DOCKER-USER bereikt, heeft de DNAT-herschrijving al plaatsgevonden. De doelpoort van het pakket is de containerpoort (80 in ons voorbeeld), niet de gepubliceerde poort (8080). Een regel die matcht op --dport 8080 doet daarom niets. De betrouwbare methode is matchen op de poort die de client oorspronkelijk heeft aangeroepen, wat de connection tracker van de kernel onthoudt:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPLees dit als: voor pakketten die binnenkwamen op eth0 en behoren bij een verbinding waarvan de oorspronkelijke doelpoort 8080 was, drop alles wat niet afkomstig is van 10.0.0.10. De --ctdir ORIGINAL match beperkt de regel tot de richting van client naar container, zodat antwoordpakketten niet per ongeluk worden tegengehouden. Vervang eth0 door uw publieke interface; ip route | grep default benoemt deze. Test dit op dezelfde manier als voorheen: curl vanaf het toegestane adres slaagt, en vanaf elke andere locatie loopt de verbinding vast. Die vertraging is het kenmerk van een DROP regel die zijn werk doet, in plaats van een poort waar niets achter zit. Het verschil tussen een geweigerde verbinding en een time-out is de snelste manier om een gefilterde poort te onderscheiden van een service die simpelweg niet luistert.
Regels die zijn toegevoegd met het iptables commando verdwijnen bij een reboot. Omdat UFW deze firewall al beheert, is /etc/ufw/after.rules de juiste plek om ze persistent te maken. Voeg een blok toe aan het einde van het bestand:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITVoer daarna sudo ufw reload uit. UFW voert dit bestand opnieuw uit bij elke reload en elke boot. Uw containerfiltering staat nu op dezelfde plek als de rest van uw firewall en overleeft zowel een reboot als een Docker-upgrade.
Waarom u de iptables-integratie van Docker niet moet uitschakelen
Oudere antwoorden op dit probleem suggereren het instellen van { "iptables": false } in /etc/docker/daemon.json. Doe dit niet. De firewallregels van Docker doen veel meer dan alleen poorten publiceren. De masquerade-regel zorgt ervoor dat containers uitgaande internettoegang hebben via het adres van de host. Als de integratie is uitgeschakeld, kunnen containers geen pakketmirrors bereiken of externe API's (application programming interface) aanroepen. De DNAT- en isolatieregels vervallen eveneens, en de documentatie van Docker waarschuwt dat zonder vervangende regels de poorten van de containers toegankelijk worden voor hosts op het lokale netwerk. U lost de omzeiling op door de netwerkfunctionaliteit van containers te verbreken, en u bent vervolgens zelf verantwoordelijk voor het schrijven en onderhouden van al deze regels. De eigen documentatie van Docker noemt deze instelling ongeschikt voor de meeste gebruikers en beschrijft het achteraf handmatig aanmaken van de regels als uiterst complex. De DOCKER-USER-chain bestaat juist om te voorkomen dat iemand deze instelling nodig heeft.
De IPv6-kant van hetzelfde probleem
Controleer eerst hoe de gepubliceerde poort eruitziet op IPv6:
sudo ss -tlnp | grep 8080Sinds Docker Engine 27 beheert Docker standaard ip6tables. Op een Docker-netwerk met ingeschakelde IPv6 krijgt een gepubliceerde poort dezelfde DNAT-behandeling in de IPv6-tabellen. De omzeiling bestaat daar dus ook en de oplossing is identiek: de DOCKER-USER-chain bestaat ook in ip6tables. Spiegel uw regel daarom met sudo ip6tables -I DOCKER-USER ... en test vanaf een externe locatie met curl tegen het publieke IPv6-adres van uw server, bijvoorbeeld curl -6 http://[2001:db8:2a::1]:8080/. Dit geldt voor de standaard iptables-backend van Docker; op de experimentele nftables-backend is er op geen van beide stacks een DOCKER-USER-chain en verloopt de IPv6-filtering in uw eigen ip6 nftables-tabel.
Op een netwerk zonder IPv6 worden IPv6-clients afgehandeld door docker-proxy, een normaal user-space proces dat luistert op [::]:8080 en het verkeer via IPv4 doorstuurt naar de container. Verkeer naar een host-proces verloopt wel via INPUT, waardoor UFW dat pad kan filteren, maar alleen wanneer UFW IPv6 überhaupt beheert. Of dit het geval is, en op welke andere manieren een IPv6-lek kan ontstaan op een VPS, is het onderwerp van de UFW en IPv6-handleiding.
Publiceren op loopback omzeilt de hele kwestie: -p 127.0.0.1:8080:80 bindt alleen aan de IPv4-loopback, waardoor er geen IPv6-listener is. Vanaf Docker Engine 28.0.0 en later is er op beide stacks niets vanaf de buitenkant bereikbaar.
Het patroon dat standhoudt
- Publiceer elke interne poort op
127.0.0.1, zodat deze in de eerste plaats nooit wordt blootgesteld, op Docker Engine 28.0.0 of later (oudere releases staan hosts op hetzelfde L2-segment toe deze te bereiken). - Wijs de publieke kant toe aan één reverse proxy die poort 80 en 443 beheert.
- Hanteer de standaard UFW-instelling 'deny' voor de host, waarbij alleen SSH en de proxy-poorten zijn toegestaan.
- Filter daadwerkelijk publieke containerpoorten in
DOCKER-USERop de standaard iptables-backend van Docker, gematcht op de oorspronkelijke doelpoort, persistent gemaakt in/etc/ufw/after.rules. - Laat de iptables-integratie van Docker ingeschakeld.
Eenmaal ingesteld, voorkomt dit verrassingen: ufw status beschrijft de host en DOCKER-USER beschrijft de containers. Niets wordt per ongeluk gepubliceerd en de volgende docker run -p die u typt, stelt precies bloot wat u bedoelde.
FAQ
Waarom kan ik mijn Docker-container bereiken terwijl UFW de poort blokkeert?
Omdat Docker de poort publiceert met een DNAT-regel in de DOCKER-chain van de nat-tabel, bereikbaar vanuit PREROUTING. Deze regel herschrijft de bestemming van het pakket naar het adres van de container voordat er enige filtering plaatsvindt. Het pakket volgt daarna het FORWARD-pad, terwijl de regels van UFW zich in INPUT bevinden; een chain waar het pakket nooit in terechtkomt. De firewall wordt nooit geraadpleegd, waardoor de weigeringsregels geen effect hebben op gepubliceerde containerpoorten.
Hoe zorg ik ervoor dat UFW de gepubliceerde poorten van Docker blokkeert?
UFW kan dit zelf niet, omdat de regels in de verkeerde chain staan. Stop ofwel met het openstellen van de poort door deze als 127.0.0.1:8080:80 te publiceren, zodat alleen de host deze kan bereiken op Docker Engine 28.0.0 en later, of filter in de DOCKER-USER-chain met een iptables-regel die via conntrack overeenkomt met de oorspronkelijke doelpoort. Sla die regel op in /etc/ufw/after.rules zodat deze reboots en ufw reload overleeft.
Moet ik "iptables": false instellen in de daemon.json van Docker?
Nee. Die instelling verwijdert het merendeel van de firewall- en NAT-regels van Docker, wat veel meer problemen veroorzaakt dan alleen de bypass. Containers verliezen hun uitgaande internettoegang omdat de masquerade-regel verdwijnt, en Docker waarschuwt dat zonder vervangende regels containerpoorten toegankelijk worden voor hosts op het lokale netwerk. Gebruik loopback-publicatie en de DOCKER-USER-chain; hiermee lost u de blootstelling op zonder de netwerkfunctionaliteit van de container te verbreken.
Omzeilt Docker ook UFW op IPv6?
Op Docker Engine 27 en later staat het beheer van ip6tables standaard aan. Een poort die op een IPv6-ingeschakeld Docker-netwerk wordt gepubliceerd, wordt daarom net als bij IPv4 om UFW heen herschreven. Op de standaard iptables-backend van Docker vereist dit dezelfde DOCKER-USER-regel, gespiegeld met ip6tables. Op de experimentele nftables-backend vanaf Engine 29 bestaat er geen DOCKER-USER-chain, dus die filtering moet in uw eigen nftables-tabel worden geplaatst. Op netwerken zonder IPv6 luistert het docker-proxy-proces op [::] en dat verkeer passeert wel INPUT, waar UFW het kan filteren als UFW IPv6 beheert. Publiceren op 127.0.0.1 vermijdt beide gevallen, omdat er dan helemaal niets op IPv6 luistert.