@@ -728,6 +728,11 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
728728 "nat-unprotected" : pingSuccess ,
729729 "routed" : pingSuccess ,
730730 }
731+ expMappedPortHTTP := map [string ]string {
732+ "nat" : httpFail ,
733+ "nat-unprotected" : httpSuccess ,
734+ "routed" : httpSuccess ,
735+ }
731736 expUnmappedPortHTTP := map [string ]string {
732737 "nat" : httpFail ,
733738 "nat-unprotected" : httpSuccess ,
@@ -769,13 +774,13 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
769774 testPing (t , "ping6" , networks [gwMode ].ipv6 , expPingExit [gwMode ])
770775 })
771776 t .Run (gwMode + "/v4/http/80" , func (t * testing.T ) {
772- testHttp (t , networks [gwMode ].ipv4 , "80" , httpSuccess )
777+ testHttp (t , networks [gwMode ].ipv4 , "80" , expMappedPortHTTP [ gwMode ] )
773778 })
774779 t .Run (gwMode + "/v4/http/81" , func (t * testing.T ) {
775780 testHttp (t , networks [gwMode ].ipv4 , "81" , expUnmappedPortHTTP [gwMode ])
776781 })
777782 t .Run (gwMode + "/v6/http/80" , func (t * testing.T ) {
778- testHttp (t , networks [gwMode ].ipv6 , "80" , httpSuccess )
783+ testHttp (t , networks [gwMode ].ipv6 , "80" , expMappedPortHTTP [ gwMode ] )
779784 })
780785 t .Run (gwMode + "/v6/http/81" , func (t * testing.T ) {
781786 testHttp (t , networks [gwMode ].ipv6 , "81" , expUnmappedPortHTTP [gwMode ])
@@ -874,6 +879,201 @@ func TestAccessPublishedPortFromAnotherNetwork(t *testing.T) {
874879 }
875880}
876881
882+ // TestDirectRemoteAccessOnExposedPort checks that remote hosts can't directly
883+ // reach a container on one of its exposed port. That is, if container has the
884+ // IP address 172.17.24.2, and its port 443 is exposed on the host, no remote
885+ // host should be able to reach 172.17.24.2:443 directly.
886+ //
887+ // Regression test for https://github.com/moby/moby/issues/45610.
888+ func TestDirectRemoteAccessOnExposedPort (t * testing.T ) {
889+ // This test checks iptables rules that live in dockerd's netns. In the case
890+ // of rootlesskit, this is not the same netns as the host, so they don't
891+ // have any effect.
892+ // TODO(aker): we need to figure out what we want to do for rootlesskit.
893+ // skip.If(t, testEnv.IsRootless, "rootlesskit has its own netns")
894+
895+ ctx := setupTest (t )
896+
897+ const (
898+ hostIPv4 = "192.168.120.2"
899+ hostIPv6 = "fdbc:277b:d40b::2"
900+ )
901+
902+ l3 := networking .NewL3Segment (t , "test-direct-remote-access" ,
903+ netip .MustParsePrefix ("192.168.120.1/24" ),
904+ netip .MustParsePrefix ("fdbc:277b:d40b::1/64" ))
905+ defer l3 .Destroy (t )
906+ // "docker" is the host where dockerd is running.
907+ l3 .AddHost (t , "docker" , networking .CurrentNetns , "test-eth" ,
908+ netip .MustParsePrefix (hostIPv4 + "/24" ),
909+ netip .MustParsePrefix (hostIPv6 + "/64" ))
910+ l3 .AddHost (t , "attacker" , "test-direct-remote-access-attacker" , "eth0" ,
911+ netip .MustParsePrefix ("192.168.120.3/24" ),
912+ netip .MustParsePrefix ("fdbc:277b:d40b::3/64" ))
913+
914+ d := daemon .New (t )
915+ d .StartWithBusybox (ctx , t )
916+ defer d .Stop (t )
917+
918+ c := d .NewClientT (t )
919+ defer c .Close ()
920+ for _ , tc := range []struct {
921+ name string
922+ gwMode string
923+ gwAddr netip.Prefix
924+ ipv4Disabled bool
925+ ipv6Disabled bool
926+ }{
927+ {
928+ name : "NAT/IPv4" ,
929+ gwMode : "nat" ,
930+ gwAddr : netip .MustParsePrefix ("172.24.10.1/24" ),
931+ },
932+ {
933+ name : "NAT/IPv6" ,
934+ gwMode : "nat" ,
935+ gwAddr : netip .MustParsePrefix ("fda9:a651:db6d::1/64" ),
936+ },
937+ {
938+ name : "NAT unprotected/IPv4" ,
939+ gwMode : "nat-unprotected" ,
940+ gwAddr : netip .MustParsePrefix ("172.24.10.1/24" ),
941+ },
942+ {
943+ name : "NAT unprotected/IPv6" ,
944+ gwMode : "nat-unprotected" ,
945+ gwAddr : netip .MustParsePrefix ("fda9:a651:db6d::1/64" ),
946+ },
947+ {
948+ name : "Proxy/IPv4" ,
949+ gwMode : "nat" ,
950+ gwAddr : netip .MustParsePrefix ("fd05:b021:403f::1/64" ),
951+ ipv4Disabled : true ,
952+ },
953+ {
954+ name : "Proxy/IPv6" ,
955+ gwMode : "nat" ,
956+ gwAddr : netip .MustParsePrefix ("172.24.11.1/24" ),
957+ ipv6Disabled : true ,
958+ },
959+ {
960+ name : "Routed/IPv4" ,
961+ gwMode : "routed" ,
962+ gwAddr : netip .MustParsePrefix ("172.24.12.1/24" ),
963+ },
964+ {
965+ name : "Routed/IPv6" ,
966+ gwMode : "routed" ,
967+ gwAddr : netip .MustParsePrefix ("fd82:5787:b217::1/64" ),
968+ },
969+ } {
970+ t .Run (tc .name , func (t * testing.T ) {
971+ skip .If (t , tc .gwMode == "routed" && testEnv .IsRootless (), "rootlesskit doesn't support routed mode as it's running in a separate netns" )
972+
973+ testutil .StartSpan (ctx , t )
974+
975+ nwOpts := []func (* networktypes.CreateOptions ){
976+ network .WithIPAM (tc .gwAddr .Masked ().String (), tc .gwAddr .Addr ().String ()),
977+ network .WithOption (bridge .IPv4GatewayMode , tc .gwMode ),
978+ network .WithOption (bridge .IPv6GatewayMode , tc .gwMode ),
979+ }
980+
981+ if tc .ipv4Disabled {
982+ nwOpts = append (nwOpts , network .WithIPv4Disabled ())
983+ }
984+ if tc .ipv6Disabled {
985+ nwOpts = append (nwOpts , network .WithIPv6Disabled ())
986+ }
987+ if tc .gwAddr .Addr ().Is6 () {
988+ nwOpts = append (nwOpts , network .WithIPv6 ())
989+ }
990+
991+ const bridgeName = "brattacked"
992+ network .CreateNoError (ctx , t , c , bridgeName , append (nwOpts ,
993+ network .WithDriver ("bridge" ),
994+ network .WithOption (bridge .BridgeName , bridgeName ),
995+ )... )
996+ defer network .RemoveNoError (ctx , t , c , bridgeName )
997+
998+ const hostPort = "5000"
999+ hostIP := hostIPv4
1000+ if tc .gwAddr .Addr ().Is6 () {
1001+ hostIP = hostIPv6
1002+ }
1003+
1004+ ctrIP := tc .gwAddr .Addr ().Next ()
1005+
1006+ test := func (t * testing.T , host networking.Host , daddr , payload string ) bool {
1007+ serverID := container .Run (ctx , t , c ,
1008+ container .WithName (sanitizeCtrName (t .Name ()+ "-server" )),
1009+ container .WithCmd ("nc" , "-lup" , "5000" ),
1010+ container .WithExposedPorts ("5000/udp" ),
1011+ container .WithPortMap (nat.PortMap {"5000/udp" : {{HostPort : hostPort }}}),
1012+ container .WithNetworkMode (bridgeName ),
1013+ container .WithEndpointSettings (bridgeName , & networktypes.EndpointSettings {
1014+ IPAddress : ctrIP .String (),
1015+ IPPrefixLen : ctrIP .BitLen (),
1016+ }))
1017+ defer c .ContainerRemove (ctx , serverID , containertypes.RemoveOptions {Force : true })
1018+
1019+ return sendPayloadFromHost (t , host , daddr , hostPort , payload , func () bool {
1020+ logs := getContainerStdout (t , ctx , c , serverID )
1021+ return strings .Contains (logs , payload )
1022+ })
1023+ }
1024+
1025+ if tc .gwMode != "routed" {
1026+ // Send a payload to the port mapped on the host -- this should work
1027+ res := test (t , l3 .Hosts ["attacker" ], hostIP , "foobar" )
1028+ assert .Assert (t , res , "Remote host should have access to port published on the host, but no payload was received by the container" )
1029+ }
1030+
1031+ // Now send a payload directly to the container. With gw_mode=routed,
1032+ // this should work. With gw_mode=nat, this should fail.
1033+ expDirectAccess := tc .gwMode == "routed" || (tc .gwMode == "nat-unprotected" && ! testEnv .IsRootless ())
1034+
1035+ l3 .Hosts ["attacker" ].Run (t , "ip" , "route" , "add" , tc .gwAddr .Masked ().String (), "via" , hostIP , "dev" , "eth0" )
1036+ defer l3 .Hosts ["attacker" ].Run (t , "ip" , "route" , "delete" , tc .gwAddr .Masked ().String (), "via" , hostIP , "dev" , "eth0" )
1037+
1038+ res := test (t , l3 .Hosts ["attacker" ], ctrIP .String (), "bar baz" )
1039+ if expDirectAccess {
1040+ assert .Assert (t , res , "Remote host should have direct access to the published port, but no payload was received by the container" )
1041+ } else {
1042+ assert .Assert (t , ! res , "Remote host should not have direct access to the published port, but payload was received by the container" )
1043+ }
1044+ })
1045+ }
1046+ }
1047+
1048+ // Send a payload to daddr:dport a few times from the 'host' netns. Stop
1049+ // sending payloads when 'check' returns true. Return the result of 'check'.
1050+ //
1051+ // UDP is preferred here as it's a one-way, connectionless protocol. With TCP
1052+ // the three-way handshake has to be completed before sending a payload, but
1053+ // since some test cases try to spoof the loopback address, the 'attacker host'
1054+ // will drop the SYN-ACK by default (because the source addr will be considered
1055+ // invalid / non-routable). This would require further tuning to make it work.
1056+ // With UDP, this problem doesn't exist - the payload can be sent straight away.
1057+ // But UDP is inherently unreliable, so we need to send the payload multiple
1058+ // times.
1059+ func sendPayloadFromHost (t * testing.T , host networking.Host , daddr , dport , payload string , check func () bool ) bool {
1060+ var res bool
1061+ host .Do (t , func () {
1062+ for i := 0 ; i < 10 ; i ++ {
1063+ t .Logf ("Sending probe #%d to %s:%s from host %s" , i , daddr , dport , host .Name )
1064+ icmd .RunCommand ("/bin/sh" , "-c" , fmt .Sprintf ("echo '%s' | nc -w1 -u %s %s" , payload , daddr , dport )).Assert (t , icmd .Success )
1065+
1066+ res = check ()
1067+ if res {
1068+ return
1069+ }
1070+
1071+ time .Sleep (50 * time .Millisecond )
1072+ }
1073+ })
1074+ return res
1075+ }
1076+
8771077func getContainerStdout (t * testing.T , ctx context.Context , c * client.Client , ctrID string ) string {
8781078 logReader , err := c .ContainerLogs (ctx , ctrID , containertypes.LogsOptions {
8791079 ShowStdout : true ,
0 commit comments