From 55abdfcecd99d71c57281d15bd8c01667838c5fb Mon Sep 17 00:00:00 2001 From: Sylvain Brunato Date: Wed, 16 Sep 2026 11:57:34 +0200 Subject: [PATCH] fix(plugins): filter out undesired headers from HTTPDownload.stream_download --- eodag/plugins/download/http.py | 23 ++++++++++++- tests/units/test_download_plugins.py | 51 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/eodag/plugins/download/http.py b/eodag/plugins/download/http.py index f5c10def81..5eb777dcd9 100644 --- a/eodag/plugins/download/http.py +++ b/eodag/plugins/download/http.py @@ -90,6 +90,21 @@ logger = logging.getLogger("eodag.download.http") +# hop-by-hop / transport-specific headers that must not be forwarded from the +# provider's response to the eodag client +EXCLUDED_RESPONSE_HEADERS = { + "connection", + "content-encoding", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} + class HTTPDownload(Download): """HTTPDownload plugin. Handles product download over HTTP protocol @@ -1073,7 +1088,13 @@ def _raw_stream_download( self._process_exception(None, product, ordered_message) stream_size = self._check_stream_size(product) or None - product.headers = product._stream.headers + product.headers = CaseInsensitiveDict( + { + k: v + for k, v in product._stream.headers.items() + if k.lower() not in EXCLUDED_RESPONSE_HEADERS + } + ) filename = self._check_product_filename(product) content_type = product.headers.get("Content-Type") guessed_content_type = ( diff --git a/tests/units/test_download_plugins.py b/tests/units/test_download_plugins.py index 37e15a6d77..0d758419c3 100644 --- a/tests/units/test_download_plugins.py +++ b/tests/units/test_download_plugins.py @@ -1061,6 +1061,57 @@ def test_stream_download_fallback_to_product(self): self.assertEqual(list(response.content), [b"first_chunk", b"second_chunk"]) self.assertEqual(response.headers, self.product.headers) + def test_stream_download_strips_hop_by_hop_headers(self): + """HTTPDownload.stream_download() must strip hop-by-hop headers but keep useful ones""" + + plugin = self.get_download_plugin(self.product) + # plugin instances are cached per provider, remove any leftover mock from other tests + plugin.__dict__.pop("_raw_stream_download", None) + + self.product.assets = mock.Mock() + self.product.assets.get_values.return_value = [] + self.product.assets.__len__ = lambda self=self.product.assets: 0 + self.product.location = self.product.remote_location = "http://somewhere" + + fake_response = mock.Mock() + fake_response.headers = CaseInsensitiveDict( + { + # hop-by-hop headers that must be stripped + "Connection": "keep-alive", + "Content-Encoding": "br", + "Transfer-Encoding": "chunked", + "Keep-Alive": "timeout=5", + # useful headers that must be kept + "Content-Type": "application/octet-stream", + "Content-Disposition": 'attachment; filename="foo.zip"', + "ETag": '"abc123"', + "content-length": "12", + } + ) + fake_response.status_code = 200 + fake_response.url = "http://somewhere/foo.zip" + fake_response.raise_for_status = mock.Mock() + fake_response.iter_content = mock.Mock(return_value=iter([b"some_content"])) + + with mock.patch( + "eodag.plugins.download.http.requests.Session.request", + return_value=fake_response, + ): + response = plugin.stream_download(self.product, output_dir=self.output_dir) + + for excluded_header in ( + "Connection", + "Content-Encoding", + "Transfer-Encoding", + "Keep-Alive", + ): + self.assertNotIn(excluded_header, response.headers) + + self.assertEqual(response.headers["Content-Type"], "application/octet-stream") + self.assertEqual(response.headers["ETag"], '"abc123"') + # filename is derived from Content-Disposition and re-set by StreamResponse + self.assertIn("foo.zip", response.headers["Content-Disposition"]) + def test_stream_download_product_empty_raises(self): """HTTPDownload.stream_download() must raise NotAvailableError if no asset and no product headers"""