Repository navigation
fix(web_fetch): pin SSRF guard connect target in fetcher.py + link/pipeline.py (#13019) - #13022
Merged
Merged
Conversation
… and media/link/pipeline.py (#13019) Both modules validated a caller-supplied URL then connected separately, leaving a DNS-rebind TOCTOU window; media/link/pipeline.py additionally had no guard at all on its BeautifulSoup fallback (the check only ever gated the Jina attempt). Adds autobot_shared.security.ssrf_guard.pinned_request_with_redirects, which resolves + pins every hop (including redirects) independently, reusing the existing aiohttp pinned_connector primitive rather than duplicating it.
Contributor
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
This was referenced Jul 30, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #13019
Thinking Path
Phase 1 verified each claim in #13019 against source and corrected two of them before touching code:
web_fetch/fetcher.py— confirmed, but only for_fetch_bs4._fetch_bs4really does check_is_public_url(url)then connect separately (previously via the shared pooledget_http_client()session) — a genuine DNS-rebind TOCTOU. Correction:_fetch_jina_impldoes NOT share this defect. Its actual TCP connect target is always the fixed, trustedr.jina.aihost — the caller'surlis only embedded in the request path (https://r.jina.ai/{url}), never the connect target — so pinning it would be a no-op. Same for_fetch_playwright(connects to our own in-cluster Playwright service, noturl, mirroring PR fix(content_reach): pin SSRF guard connect target to close robots.txt redirect + TOCTOU gaps (#13017) #13020's Playwright/yt-dlp scope-narrowing). Reachability:WebFetcher.fetch/fetch_raw_htmlis called fromchat_workflow/tool_handler.py,api/knowledge_scrape.py,api/knowledge_mcp.py,api/knowledge_extract.py,tools/tool_registry.py,services/research/orchestrator.py,knowledge/connectors/web_crawler.py— broad, caller-controlled-URL exposure, not admin-only.media/link/pipeline.py— confirmed, and WORSE than reported._fetch_and_parse's_is_public_url_async(url)check only ever gated the Jina attempt — if it returnedFalse, the code fell through unconditionally to the BeautifulSoup fallback fetch with zero SSRF enforcement (an existing test,test_fetch_skips_jina_when_hostname_resolves_to_private_ip, explicitly asserted this: it mocked DNS to a private IP and still expected the BS4 fetch to proceed). This isn't merely a TOCTOU window — it's a missing gate. Fixed by making the pinned fetch unconditional, which closes both issues in one change. Reachability correction:LinkPipeline/MediaPipelineManagercurrently has no production caller —api/knowledge_scrape.pyand friends callweb_fetch.WebFetcherdirectly;process_url()is referenced only from its own test file. So severity here is "latent, not actively exploitable" rather than the issue's "Medium" — fixed anyway per the project's "never delete code — wire it in" stance, since perf(http): 134 raw aiohttp.ClientSession constructions remain (127 per-request) despite the shared pool at 151 sites #12979 already actively maintains this file.content_reach(httpx).media/link/pipeline.pyis also on the shared pooled client from perf(http): 134 raw aiohttp.ClientSession constructions remain (127 per-request) despite the shared pool at 151 sites #12979 for Jina, but Jina's fixed connect target means pooling there is unaffected by this fix. The existing aiohttpssrf_guard.pinned_connectoris reused as-is (aiohttp's own resolver only maps one hostname → one pinned IP, so a naivepinned_connector(url)+allow_redirects=Truewould silently misroute any cross-host redirect) — no httpx helper was needed, so nothing was promoted fromcontent_reach/_http.py.fetch_safe_url/api/knowledge.pyprecedent) would be a real functionality regression, so a new shared primitive,ssrf_guard.pinned_request_with_redirects, independently resolves + pins every hop (including redirects), rejecting a redirect to a private/rebound address outright instead of following it.What Changed
autobot_shared/security/ssrf_guard.py— newpinned_request_with_redirects(method, url, ...): an async context manager that issues each hop withallow_redirects=Falsethrough its own freshpinned_connector, re-validating theLocationbefore ever connecting to it. Bounded bymax_redirects(SSRFErroron exhaustion, not silent truncation).autobot_shared/ssot_config.py— newweb_fetch_max_redirectsfield (AUTOBOT_WEB_FETCH_MAX_REDIRECTS, default 5) shared by both call sites.web_fetch/fetcher.py—_fetch_bs4now routes throughpinned_request_with_redirectsinstead of the shared pooled client;SSRFErroris logged distinctly ("blocked by SSRF guard") from a natural fetch failure._MAX_REDIRECTSis now config-sourced instead of a hardcoded literal.media/link/pipeline.py—_fetch_and_parse's fallback now routes throughpinned_request_with_redirectsunconditionally (closing the missing-gate bug), preserving the existingssl=passthrough forallow_self_signed. New_MAX_REDIRECTSmodule constant, same config field.Not changed (by design):
_fetch_jina_impl/_try_jinaand_fetch_playwright— see correction #1 above.Verification
New/changed hostile-path tests (mock real DNS at
autobot_shared.url_safety.socket.getaddrinfo, never the guard — mirrorsapi/tests/test_provider_auth_ssrf.py/ PR #13020'stest_http_pinned_get.py):autobot_shared/security/ssrf_guard_test.py—pinned_request_with_redirects: blocks private IP / localhost / link-local metadata pre-connect; DNS-rebind (public hostname resolving private at connect); follows a legitimate cross-host redirect with independent per-hop pinning (asserts each hop gets its OWN connector); rejects a redirect to a private IP literal outright; exceeds-max_redirectsraisesSSRFErrorrather than truncating silently; verifies the connector's pinned resolver targets the IP literal whilehostname(Host/SNI) preserves the original name.tests/unit/web_fetch/test_fetcher.py(TestFetchBS4SSRFPinning, 5 tests) — private IP literal, link-local metadata, DNS-rebind (public at_is_public_urlpre-check / private at pinned connect), redirect-to-internal rejected outright, and a genuinely public URL still succeeds end-to-end.media/link/pipeline_test.py(TestLinkPipelineFetchSSRFPinning, 3 tests) — same private-IP/DNS-rebind/redirect-to-internal cases exercised through_fetch_and_parseitself, proving the previously-missing gate now blocks the fallback fetch unconditionally, not just Jina. The 7 pre-existing functional tests for the oldget_http_client().tracked_request()shape were rewired onto the new pinned-fetch factory (mocked directly for pure functional assertions — TLS passthrough, HTTP-error status, success) rather than duplicating DNS-mocking plumbing into every test.Discovered (filed separately, not in scope here)
media/link/pipeline.py:67's_MAX_CONTENT_LENGTH(1 MB cap) is declared but never enforced; unbounded body read in the same fallback fetch (resource-exhaustion class, distinct from SSRF).Model Used
Claude Sonnet 5