fix(search): pin httpx connection to resolved IP to block DNS rebinding (#704)

* fix(search): pin DNS-validated fetch connections

Rebase the DNS-rebinding SSRF fix onto current dev after search content moved behind the services.search.content canonical module.

Integrate the pinned httpcore NetworkBackend/BaseTransport approach with the current size-capped Client.stream fetch path, preserving Host/SNI semantics while forcing TCP connect to the already validated resolved IP.

Keep src.search.content as the compatibility wrapper and preserve existing OG-image http(s) behavior; this avoids reintroducing the unrelated scope changes that previously blocked review.

Add the explicit httpcore>=1.0,<2.0 requirement used by the public httpcore NetworkBackend and ConnectionPool APIs.

* test(search): restore and rebase DNS rebinding regressions

Keep the current security regression coverage that the stale PR branch had deleted, including auth-disabled localhost bypass and Ollama cookbook hardening tests.

Carry forward the DNS-rebinding coverage for private resolve blocking, pinned TCP connect behavior, Host header preservation, redirect revalidation, and the BaseTransport/public-httpcore static guard.

Update redirect tests to mock the current Client.stream-based capped fetch path rather than the older httpx.stream/get path.

* test(search): adapt size-cap fetch tests to pinned client stream

The DNS-rebinding repair moved _get_public_url from the module-level httpx.stream shortcut to httpx.Client(...).stream(...) so the fetch can use the pinned transport.

Keep the existing size-cap test fakes by routing Client.stream through the monkeypatched httpx.stream only when a test has installed that fake; otherwise fall back to a real Client.

This fixes the CI failures in tests/test_web_fetch_size_caps.py without touching unrelated upload-handler atomicity behavior, which is already flaky on clean origin/dev.

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
This commit is contained in:
Ernest Hysa
2026-07-02 13:08:06 +01:00
committed by GitHub
parent b1f9f67d9d
commit 5e9b415bd9
4 changed files with 553 additions and 68 deletions
+51
View File
@@ -13,6 +13,57 @@ import pytest
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES
from services.search import content as content_mod
import pytest as _pytest_for_client_stream_compat
@_pytest_for_client_stream_compat.fixture(autouse=True)
def _client_stream_compat_for_pinned_fetch(monkeypatch):
"""Adapt old size-cap tests to the current pinned Client.stream path.
These tests monkeypatch httpx.stream(...) to return fake responses. The
production fetcher now uses httpx.Client(...).stream(...) so it can pass a
pinned transport. When a test has replaced httpx.stream, route Client.stream
through that fake. When it has not, fall back to a real Client so unrelated
behavior in this file is not changed.
"""
import httpx
real_client_cls = httpx.Client
original_stream = httpx.stream
class _ClientProxy:
def __init__(self, *args, **kwargs):
self._args = args
self._kwargs = kwargs
self._real_cm = None
self._real_client = None
def __enter__(self):
if httpx.stream is original_stream:
self._real_cm = real_client_cls(*self._args, **self._kwargs)
self._real_client = self._real_cm.__enter__()
return self._real_client
return self
def __exit__(self, *args):
if self._real_cm is not None:
return self._real_cm.__exit__(*args)
return False
def stream(self, method, url):
if self._real_client is not None:
return self._real_client.stream(method, url)
kwargs = {
"headers": self._kwargs.get("headers"),
"timeout": self._kwargs.get("timeout"),
"follow_redirects": self._kwargs.get("follow_redirects"),
}
return httpx.stream(method, url, **kwargs)
monkeypatch.setattr(httpx, "Client", _ClientProxy)
class _FakeStream:
"""Stands in for the httpx.stream(...) context manager."""