FTP: fix TLS session reuse on the data connection

FTP servers using SSL can be configured to check TLS session reuse on
the DATA connection. They hand out a new session on every CONTROL
connect and require to see the client using exactly that one when
up-/downloading on DATA.

This means:

1. We have to configure the SSL filter on the DATA connection with
   exactly the same peers.

2. We have to remember the SSL session on the CONTROL connection -
   separately from the session cache. The SSL filter on the DATA
   connection then looks for a session at the CONTROL filter and, if
   present, uses that.

Tests:

Enable `require_ssl_reuse` in our pytest setup for vsftpd. This
reproduces the problem reported in #22225 and verifies the fix.

Skip ftp+SSL pytests for rustls, as we have no possibility to reuse
sessions in that backend.

Schannel: we do not run these tests with the backend. I expect it has
similar problems but am not able to verify.

Reported-by: Laurent Sabourin
Fixes #22225
Closes #22246
This commit is contained in:
Stefan Eissing 2026-07-02 12:04:18 +02:00 committed by Daniel Stenberg
parent 0a7ec0ea4d
commit 84ecfb3ecc
No known key found for this signature in database
GPG key ID: 5CC908FDB71E12C2
17 changed files with 485 additions and 190 deletions

View file

@ -106,9 +106,7 @@ def httpd(env) -> Generator[Httpd, None, None]:
@pytest.fixture(scope="session")
def nghttpx(env, httpd) -> Generator[Union[Nghttpx, bool], None, None]:
nghttpx = NghttpxQuic(env=env)
if nghttpx.exists():
if not nghttpx.supports_h3() and env.have_h3_curl():
log.warning("nghttpx does not support QUIC, but curl does")
if nghttpx.exists() and nghttpx.supports_h3() and env.have_h3_curl():
nghttpx.clear_logs()
assert nghttpx.initial_start()
yield nghttpx

View file

@ -37,6 +37,10 @@ log = logging.getLogger(__name__)
@pytest.mark.skipif(condition=not Env.has_vsftpd(), reason="missing vsftpd")
@pytest.mark.skipif(condition=Env.curl_uses_lib('rustls-ffi'),
reason="rustls does not support TLS session reuse")
@pytest.mark.skipif(condition=Env.curl_uses_lib('libressl'),
reason="libressl fails on TLS session reuse")
class TestVsFTPD:
SUPPORTS_SSL = True
@ -101,31 +105,39 @@ class TestVsFTPD:
self.check_downloads(curl, srcfile, count)
r.check_stats_timelines()
@pytest.mark.parametrize("docname", [
'data-1k', 'data-1m', 'data-10m'
@pytest.mark.parametrize("docname,count,secure", [
['data-1k', 10, True],
['data-1m', 5, True],
['data-1m', 5, False],
['data-10m', 2,True]
])
def test_31_03_download_10_serial(self, env: Env, vsftpds: VsFTPD, docname):
def test_31_03_download_10_serial(self, env: Env, vsftpds: VsFTPD, docname, count, secure):
curl = CurlClient(env=env)
srcfile = os.path.join(vsftpds.docs_dir, f'{docname}')
count = 10
url = f'ftp://{env.ftp_domain}:{vsftpds.port}/{docname}?[0-{count-1}]'
r = curl.ftp_ssl_get(urls=[url], with_stats=True)
xargs = []
if not secure:
xargs.append('--insecure')
r = curl.ftp_ssl_get(urls=[url], with_stats=True, extra_args=xargs)
r.check_stats(count=count, http_status=226)
self.check_downloads(curl, srcfile, count)
assert r.total_connects == count + 1, 'should reuse the control conn'
r.check_stats_timelines()
@pytest.mark.parametrize("docname", [
'data-1k', 'data-1m', 'data-10m'
@pytest.mark.parametrize("docname,count,secure", [
['data-1k', 10, True],
['data-1m', 5, True],
['data-1m', 5, False],
['data-10m', 2,True]
])
def test_31_04_download_10_parallel(self, env: Env, vsftpds: VsFTPD, docname):
def test_31_04_download_10_parallel(self, env: Env, vsftpds: VsFTPD, docname, count, secure):
curl = CurlClient(env=env)
srcfile = os.path.join(vsftpds.docs_dir, f'{docname}')
count = 10
url = f'ftp://{env.ftp_domain}:{vsftpds.port}/{docname}?[0-{count-1}]'
r = curl.ftp_ssl_get(urls=[url], with_stats=True, extra_args=[
'--parallel'
])
xargs = ['--parallel']
if not secure:
xargs.append('--insecure')
r = curl.ftp_ssl_get(urls=[url], with_stats=True, extra_args=xargs)
r.check_stats(count=count, http_status=226)
self.check_downloads(curl, srcfile, count)
assert r.total_connects > count + 1, 'should have used several control conns'

View file

@ -37,6 +37,10 @@ log = logging.getLogger(__name__)
@pytest.mark.skipif(condition=not Env.has_vsftpd(), reason="missing vsftpd")
@pytest.mark.skipif(condition=Env.curl_uses_lib('rustls-ffi'),
reason="rustls does not support TLS session reuse")
@pytest.mark.skipif(condition=Env.curl_uses_lib('libressl'),
reason="libressl fails on TLS session reuse")
class TestFtpsVsFTPD:
SUPPORTS_SSL = True
@ -101,15 +105,20 @@ class TestFtpsVsFTPD:
self.check_downloads(curl, srcfile, count)
r.check_stats_timelines()
@pytest.mark.parametrize("docname", [
'data-1k', 'data-1m', 'data-10m'
@pytest.mark.parametrize("docname,count,secure", [
['data-1k', 10, True],
['data-1m', 5, True],
['data-1m', 5, False],
['data-10m', 2,True]
])
def test_32_03_download_10_serial(self, env: Env, vsftpds: VsFTPD, docname):
def test_32_03_download_10_serial(self, env: Env, vsftpds: VsFTPD, docname, count, secure):
curl = CurlClient(env=env)
srcfile = os.path.join(vsftpds.docs_dir, f'{docname}')
count = 10
url = f'ftps://{env.ftp_domain}:{vsftpds.port}/{docname}?[0-{count-1}]'
r = curl.ftp_get(urls=[url], with_stats=True)
xargs = []
if not secure:
xargs.append('--insecure')
r = curl.ftp_get(urls=[url], with_stats=True, extra_args=xargs)
r.check_stats(count=count, http_status=226)
self.check_downloads(curl, srcfile, count)
assert r.total_connects == count + 1, 'should reuse the control conn'

View file

@ -217,7 +217,7 @@ class VsFTPD:
f'rsa_cert_file={creds.cert_file}',
f'rsa_private_key_file={creds.pkey_file}',
# require_ssl_reuse=YES means ctrl and data connection need to use the same session
'require_ssl_reuse=NO',
'require_ssl_reuse=YES',
])
if self._ssl_implicit:
conf.extend([