@@ -16,7 +16,7 @@
1616exceptImportError:
1717ssl=None
181819-fromunittestimportTestCase, skipUnless
19+fromunittestimportmock, TestCase, skipUnless
2020fromtestimportsupport
2121fromtest.supportimportrequires_subprocess
2222fromtest.supportimportthreading_helper
@@ -1145,6 +1145,40 @@ def testTimeoutDirectAccess(self):
11451145ftp.close()
11461146114711471148+classTestFtpcpSecurity(TestCase):
1149+"""ftpcp() must not trust the host a source server advertises in PASV.
1150+1151+ A malicious source server can otherwise redirect the target server's
1152+ data connection to an arbitrary host:port (SSRF), so ftpcp() uses the
1153+ source server's actual peer address instead, the same as FTP.makepasv().
1154+ """
1155+1156+def_make_pair(self, *, advertised_host, real_host, trust=False):
1157+source=mock.Mock(spec=ftplib.FTP)
1158+source.trust_server_pasv_ipv4_address=trust
1159+source.sock.getpeername.return_value= (real_host, 21)
1160+# PASV replies give the host as comma-separated octets, not dotted.
1161+advertised=advertised_host.replace('.', ',')
1162+source.sendcmd.side_effect=lambdacmd: (
1163+f'227 Entering Passive Mode ({advertised},1,2).'
1164+ifcmd=='PASV'else'150 ok')
1165+target=mock.Mock(spec=ftplib.FTP)
1166+target.sendcmd.return_value='150 ok'
1167+returnsource, target
1168+1169+deftest_ftpcp_ignores_untrusted_pasv_host(self):
1170+source, target=self._make_pair(advertised_host='10.0.0.5',
1171+real_host='198.51.100.7')
1172+ftplib.ftpcp(source, 'a', target, 'b')
1173+target.sendport.assert_called_once_with('198.51.100.7', 258)
1174+1175+deftest_ftpcp_trust_server_pasv_ipv4_address(self):
1176+source, target=self._make_pair(advertised_host='10.0.0.5',
1177+real_host='198.51.100.7', trust=True)
1178+ftplib.ftpcp(source, 'a', target, 'b')
1179+target.sendport.assert_called_once_with('10.0.0.5', 258)
1180+1181+11481182classMiscTestCase(TestCase):
11491183deftest__all__(self):
11501184not_exported= {