Skip to content

Commit 3a629f0

Browse files
committed
gh-44672: Stop pre-resolving the FTP host in urllib.request
FTPHandler.ftp_open() resolved the host with socket.gethostbyname() before connecting. gethostbyname() is IPv4-only, so an ftp:// URL for an IPv6-only host failed there even when the server was reachable. As diagnosed on the tracker in 2009, ftp_open() has no need to resolve the host itself: it is passed to connect_ftp(), then to ftpwrapper, and on to ftplib.FTP.connect(). That uses socket.create_connection(), which resolves via getaddrinfo() and is IPv6-aware. Remove the pre-resolution and let the host flow through unresolved. An unresolvable host now raises socket.gaierror inside ftplib; gaierror is an OSError and therefore in ftplib.all_errors, so ftp_open()'s existing handler still turns it into URLError and callers see the same error type as before.
1 parent aeedae8 commit 3a629f0

3 files changed

Lines changed: 44 additions & 5 deletions

File tree

Lib/test/test_urllib2.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ def connect_ftp(self, user, passwd, host, port, dirs,
787787
# ftp authentication not yet implemented by FTPHandler
788788
self.assertEqual(h.user, user)
789789
self.assertEqual(h.passwd, passwd)
790-
self.assertEqual(h.host, socket.gethostbyname(host))
790+
self.assertEqual(h.host, host)
791791
self.assertEqual(h.port, port)
792792
self.assertEqual(h.dirs, dirs)
793793
self.assertEqual(h.ftpwrapper.filename, filename)
@@ -797,6 +797,45 @@ def connect_ftp(self, user, passwd, host, port, dirs,
797797
self.assertEqual(int(headers["Content-length"]), len(data))
798798
r.close()
799799

800+
def test_ftp_no_hostname_resolution(self):
801+
# gh-44672: the host must reach connect_ftp unresolved so that
802+
# ftplib resolves it via getaddrinfo(), which supports IPv6.
803+
class MockFTPWrapper:
804+
def retrfile(self, filename, filetype):
805+
return io.StringIO(""), 0
806+
807+
def close(self):
808+
pass
809+
810+
class NullFTPHandler(urllib.request.FTPHandler):
811+
def connect_ftp(self, user, passwd, host, port, dirs, timeout):
812+
self.host = host
813+
return MockFTPWrapper()
814+
815+
h = NullFTPHandler()
816+
req = Request("ftp://localhost/foo/bar.html")
817+
req.timeout = None
818+
with mock.patch.object(socket, "gethostbyname",
819+
side_effect=AssertionError(
820+
"ftp_open must not pre-resolve the host")):
821+
r = h.ftp_open(req)
822+
r.close()
823+
self.assertEqual(h.host, "localhost")
824+
825+
def test_ftp_gaierror(self):
826+
# gh-44672: without pre-resolution the lookup failure now surfaces
827+
# from ftplib as socket.gaierror; it must still become a URLError.
828+
class GaiErrorFTPHandler(urllib.request.FTPHandler):
829+
def connect_ftp(self, user, passwd, host, port, dirs, timeout):
830+
raise socket.gaierror(-2, "Name or service not known")
831+
832+
h = GaiErrorFTPHandler()
833+
req = Request("ftp://nonexistent.invalid/")
834+
req.timeout = None
835+
with self.assertRaises(urllib.error.URLError) as cm:
836+
h.ftp_open(req)
837+
self.assertIsInstance(cm.exception.__cause__, socket.gaierror)
838+
800839
@support.requires_resource("network")
801840
def test_ftp_error(self):
802841
class ErrorFTPHandler(urllib.request.FTPHandler):

Lib/urllib/request.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,10 +1526,6 @@ def ftp_open(self, req):
15261526
user = user or ''
15271527
passwd = passwd or ''
15281528

1529-
try:
1530-
host = socket.gethostbyname(host)
1531-
except OSError as msg:
1532-
raise URLError(msg)
15331529
path, attrs = _splitattr(req.selector)
15341530
dirs = path.split('/')
15351531
dirs = list(map(unquote, dirs))
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:func:`urllib.request.urlopen` can now fetch ``ftp://`` URLs whose host is
2+
IPv6-only. :class:`~urllib.request.FTPHandler` no longer pre-resolves the host
3+
with the IPv4-only :func:`socket.gethostbyname`; the unresolved host is passed to
4+
:mod:`ftplib`, which resolves it with :func:`socket.getaddrinfo`.

0 commit comments

Comments
 (0)