From 94d5fd8a9cb74c43f07832af275402a454d355d1 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Wed, 29 Jul 2026 03:32:38 +0800 Subject: [PATCH 1/3] fix: drop connection headers on cross-host redirect in HttpAsyncHook --- .../src/airflow/providers/http/hooks/http.py | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/providers/http/src/airflow/providers/http/hooks/http.py b/providers/http/src/airflow/providers/http/hooks/http.py index 3401c589c812a..b8ed25717a3c6 100644 --- a/providers/http/src/airflow/providers/http/hooks/http.py +++ b/providers/http/src/airflow/providers/http/hooks/http.py @@ -512,19 +512,63 @@ async def run( url = _url_from_endpoint(self.base_url, endpoint) merged_headers = {**(self.headers or {}), **(headers or {})} extra_options = {**(self.extra_options or {}), **(extra_options or {})} + # Track which headers came from the connection so they can be dropped + # on a cross-host redirect (aiohttp only strips Authorization). + connection_header_names = set(self.headers.keys()) if self.headers else set() async def request_func() -> ClientResponse: - response = await self._request( - url, - params=data if self.method == "GET" else None, - data=data if self.method in {"POST", "PUT", "PATCH"} else None, - json=json, - headers=merged_headers, - auth=self.auth, - **extra_options, + nonlocal url + # aiohttp does not drop custom headers on cross-host redirects, + # so we disable automatic redirect following and walk the chain + # manually, dropping connection-supplied headers when the target + # changes host. + max_redirects = 5 + current_headers = merged_headers + remaining_connection_headers = connection_header_names.copy() + + for _ in range(max_redirects): + response = await self._request( + url, + params=data if self.method == "GET" else None, + data=data if self.method in {"POST", "PUT", "PATCH"} else None, + json=json, + headers=current_headers, + auth=self.auth, + allow_redirects=False, + **extra_options, + ) + + if response.status in (301, 302, 303, 307, 308): + redirect_url = response.headers.get("Location") + if not redirect_url: + response.raise_for_status() + return response + + # Resolve relative Location URLs against the current URL. + redirect_url = _url_from_endpoint(url, redirect_url) + redirect_parsed = urlparse(redirect_url) + current_parsed = urlparse(url) + + if current_parsed.netloc != redirect_parsed.netloc: + # Cross-host redirect: drop connection-supplied headers, + # keeping only headers the caller explicitly passed in. + current_headers = {k: v for k, v in merged_headers.items() if k not in remaining_connection_headers} + remaining_connection_headers.clear() + + url = redirect_url + await response.release() + continue + + response.raise_for_status() + return response + + raise ClientResponseError( + request_info=response.request_info, + history=(), + status=599, + message=f"Too many redirects ({max_redirects})", + headers=response.headers, ) - response.raise_for_status() - return response async for attempt in AsyncRetrying( stop=stop_after_attempt(self.retry_limit), From 1d9a3d6f4a54630e142ca85395b3654293c60c58 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Wed, 29 Jul 2026 03:32:41 +0800 Subject: [PATCH 2/3] test: add headers and release() to MockAiohttpClientResponse --- devel-common/src/tests_common/test_utils/aiohttp.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/devel-common/src/tests_common/test_utils/aiohttp.py b/devel-common/src/tests_common/test_utils/aiohttp.py index f88b65c8723c5..0fe08d06f9240 100644 --- a/devel-common/src/tests_common/test_utils/aiohttp.py +++ b/devel-common/src/tests_common/test_utils/aiohttp.py @@ -40,6 +40,7 @@ def __init__( method: str = "GET", url: str = "http://example.com", reason: str | None = None, + headers: dict[str, str] | None = None, ) -> None: self.status = status self._payload = payload @@ -47,6 +48,11 @@ def __init__( self._method = method self._url = url self._reason = reason + self.headers = headers or {} + + async def release(self) -> None: + """No-op for unit tests; aiohttp frees the underlying connection.""" + pass @property def reason(self) -> str: From a7b46797a186fbae16070c191e4577e50660c431 Mon Sep 17 00:00:00 2001 From: water <672684719@qq.com> Date: Wed, 29 Jul 2026 03:32:43 +0800 Subject: [PATCH 3/3] test: add async redirect header forwarding test --- .../http/tests/unit/http/hooks/test_http.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/providers/http/tests/unit/http/hooks/test_http.py b/providers/http/tests/unit/http/hooks/test_http.py index f89532d7e2756..2cd7a91c34674 100644 --- a/providers/http/tests/unit/http/hooks/test_http.py +++ b/providers/http/tests/unit/http/hooks/test_http.py @@ -898,3 +898,52 @@ async def test_build_request_url_from_endpoint_param(self): async with aiohttp.ClientSession() as session: await hook.run(session=session, endpoint="test.com:8080/v1/test") assert mocked_function.call_args.args[0] == "http://test.com:8080/v1/test" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("location", "expected_forwarded_key"), + [ + pytest.param("http://other.host/v1/redirected", None, id="cross-host"), + pytest.param("http://test:8080/v1/redirected", "secret-key", id="same-host"), + ], + ) + async def test_async_connection_header_is_only_forwarded_on_a_same_host_redirect( + self, create_connection_without_db, location, expected_forwarded_key + ): + """Connection extra headers must not be forwarded on a cross-host redirect (async).""" + create_connection_without_db( + Connection( + conn_id="http_conn_with_api_key", + conn_type="http", + host="test:8080/", + extra='{"X-API-Key": "secret-key"}', + ) + ) + hook = HttpAsyncHook(http_conn_id="http_conn_with_api_key") + + with mock.patch("aiohttp.ClientSession.get", new_callable=mock.AsyncMock) as mocked_get: + redirect_response = MockAiohttpClientResponse( + status=302, + method="GET", + url="http://test:8080/v1/test", + headers={"Location": location}, + ) + final_response = MockAiohttpClientResponse( + status=200, + payload={"message": "OK"}, + method="GET", + url=location, + ) + mocked_get.side_effect = [redirect_response, final_response] + + async with aiohttp.ClientSession() as session: + resp = await hook.run(session=session, endpoint="v1/test") + assert resp.status == 200 + + # First request should carry the connection header + first_headers = mocked_get.call_args_list[0].kwargs["headers"] + assert first_headers.get("X-API-Key") == "secret-key" + + # Second request should only carry it on a same-host redirect + second_headers = mocked_get.call_args_list[1].kwargs["headers"] + assert second_headers.get("X-API-Key") == expected_forwarded_key