Skip to content

fix(websearch): harden SSRF guard in ContentFetcher (#383) - #429

Merged
hedhoud merged 1 commit into
refactor/hexagonalfrom
fix/383-ssrf-web-search-fetcher
May 26, 2026
Merged

fix(websearch): harden SSRF guard in ContentFetcher (#383)#429
hedhoud merged 1 commit into
refactor/hexagonalfrom
fix/383-ssrf-web-search-fetcher

Conversation

@hedhoud

@hedhoud hedhoud commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Why

The web search fetcher fetches URLs returned by an external provider. A compromised or misbehaving provider could return URLs pointing at internal infrastructure. Three vectors were unguarded.

What was wrong

  • Only loopback addresses were blocked — RFC 1918 private ranges, link-local (169.254.x.x), CGNAT (100.64.x.x), and other reserved networks were accepted.
  • Decimal-integer-encoded IPv4 addresses (e.g. 2130706433 == 127.0.0.1) were not caught because ipaddress.ip_address() only accepts dotted-decimal notation.
  • follow_redirects=True meant a legitimate initial URL could silently redirect to a private address after the initial SSRF check passed.

What changed

  • _is_loopback_url replaced by _is_safe_url (module-level), which checks all private/reserved/non-global IP flags explicitly, handles decimal-integer IPv4 encoding, and rejects non-HTTP(S) schemes.
  • Redirect following is now manual (follow_redirects=False): every redirect target is validated by _is_safe_url before the next request is sent, with a hard cap of 10 hops.

Tests

39 cases covering: all private/reserved ranges, decimal-integer encoding, non-HTTP schemes, redirect-to-private-IP (redirect target never contacted), safe redirect chains, and redirect limit.

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced security for web search functionality by validating URLs and preventing access to internal/private addresses and unsafe schemes. All redirect paths are now validated to prevent bypass attempts.

Review Change Stack

Three vectors were unguarded:

1. Only loopback addresses were blocked — private RFC 1918 ranges,
   link-local (169.254.x.x), CGNAT (100.64.x.x), and other reserved
   networks were passed through.

2. Decimal-integer-encoded IPv4 addresses (e.g. 2130706433 == 127.0.0.1)
   were not caught because ipaddress.ip_address() only parses dotted
   decimal; the integer form is parsed by a second int() coercion.

3. follow_redirects=True meant a legitimate initial URL could silently
   redirect to a private address after the initial check passed.

Fix: replace _is_loopback_url with _is_safe_url (module-level) that
checks all private/reserved/non-global flags explicitly, handles
decimal-integer IPs, and blocks non-HTTP(S) schemes.  Redirect following
is now manual (follow_redirects=False) so every hop is validated before
the next request is sent.

Tests: 39 cases covering private ranges, decimal encoding, non-HTTP
schemes, redirect-to-private-IP, safe redirect following, and redirect
chain length cap.
@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR hardens the ContentFetcher against SSRF attacks by introducing URL/IP validation rules and reworking redirect handling. The validation blocks unsafe schemes and private/reserved IP ranges (including decimal-encoded IPv4 forms), while the redirect loop manually validates each hop before following it, respecting a configurable redirect limit.

Changes

SSRF Hardening with Manual Redirect Validation

Layer / File(s) Summary
SSRF validation foundation
openrag/components/websearch/content_fetcher.py
Introduces _MAX_REDIRECTS, _is_blocked_address, and _is_safe_url helpers that enforce SSRF-safe URL rules: blocking non-HTTP(s) schemes, localhost, private/reserved/link-local/loopback/multicast IPs, and decimal-integer-encoded IPv4 literals. Adds urljoin import for redirect target construction.
SSRF validation unit tests
openrag/components/websearch/test_content_fetcher.py
TestIsSafeUrl test suite verifies that _is_safe_url blocks private/reserved/loopback and non-HTTP(s) schemes while allowing publicly routable IPs and normal hostnames.
Manual redirect loop with safety checks
openrag/components/websearch/content_fetcher.py
_fetch_single reworked to follow redirects manually: validates the current URL before each request, performs requests with follow_redirects=False, updates the URL from the location header, and stops if a redirect target is unsafe or the redirect limit is exceeded. Removes the previous in-class loopback-only guard and auto-redirect behavior. Adds explicit logging when redirects are exhausted.
Redirect handling integration tests
openrag/components/websearch/test_content_fetcher.py
Async mock-transport tests validate that _fetch_single blocks redirects to private IPs (ensuring unsafe targets are never contacted), follows safe/public redirects and returns their content, and aborts redirect chains exceeding the configured maximum.

Sequence Diagram

sequenceDiagram
  participant Client
  participant ContentFetcher
  participant SafetyCheck
  participant TargetServer
  
  Client->>ContentFetcher: fetch URL
  loop Redirect loop
    ContentFetcher->>SafetyCheck: _is_safe_url(current_url)
    SafetyCheck-->>ContentFetcher: valid?
    alt URL unsafe
      ContentFetcher-->>Client: return None
    end
    ContentFetcher->>TargetServer: GET (follow_redirects=False)
    TargetServer-->>ContentFetcher: response
    alt Redirect (301/302/etc)
      ContentFetcher->>ContentFetcher: update current_url from Location
      alt Too many redirects
        ContentFetcher-->>Client: return None
      end
    else Non-redirect
      ContentFetcher-->>Client: process & return content
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A safeguard hops through every link,
Validates the path before we blink,
No sneaky redirects shall pass the gate,
Private ranges meet their fate,
Secure web-search, hopping straight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(websearch): harden SSRF guard in ContentFetcher' clearly and concisely describes the main change: hardening SSRF protections in the web search content fetcher component.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/383-ssrf-web-search-fetcher

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@hedhoud

hedhoud commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

What was wrong

ContentFetcher._fetch_single had three gaps in its SSRF guard:

  1. Only loopback was blocked. The old _is_loopback_url checked localhost and whether ip_address.is_global was False for IP literals, but did not explicitly cover RFC 1918 private ranges, link-local (169.254.x.x), CGNAT (100.64.x.x), or other reserved blocks.

  2. Decimal-integer IPv4 encoding slipped through. A URL like http://2130706433/ (where 2130706433 is 127.0.0.1 as a 32-bit integer) fails ipaddress.ip_address("2130706433") with ValueError, so the old guard returned False (safe) and let it through. glibc's resolver interprets decimal integers as packed IPv4 addresses, so the actual TCP connection would target 127.0.0.1.

  3. Redirects were followed without re-checking. follow_redirects=True meant a public URL could return a 302 pointing at an internal address; the redirect target was never validated.

What was changed

  • _is_loopback_url replaced by _is_safe_url (module-level), which checks is_loopback | is_private | is_link_local | is_reserved | is_unspecified | is_multicast | not is_global explicitly and rejects non-HTTP(S) schemes.
  • Decimal-integer IPv4 now caught via a second attempt: ipaddress.ip_address(int(host))ipaddress interprets the integer as a packed IPv4 address, matching the OS resolver.
  • follow_redirects=False replaces the blind follow. Redirects are followed manually (up to 10 hops); _is_safe_url is called on every redirect target before the next request is sent.

Tests

39 cases: all private/reserved address ranges, decimal-integer encoding, non-HTTP schemes, redirect-to-private-IP (redirect target never contacted), safe redirect chains, and redirect chain length cap.

Note on DNS rebinding

Full DNS rebinding protection (where a hostname resolves to a public IP at check time then to a private IP at connect time) requires a custom transport layer and is out of scope here. The per-hop redirect validation and IP-literal checks cover the practical attack vectors from a compromised search provider.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
openrag/components/websearch/test_content_fetcher.py (1)

66-68: ⚡ Quick win

Avoid asserting that any regular hostname is safe.

This test encodes the current bypass behavior. Please replace/extend it with a DNS-resolution-aware case (e.g., mocked getaddrinfo) to assert that hostnames resolving to blocked ranges are rejected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openrag/components/websearch/test_content_fetcher.py` around lines 66 - 68,
The test test_allows_regular_hostname currently asserts
_is_safe_url("https://example.com/page") is True without checking DNS
resolution; update it to be DNS-resolution aware by mocking socket.getaddrinfo
(or the resolver used by _is_safe_url) and add two assertions: one where
getaddrinfo returns an address in an allowed/public range and assert
_is_safe_url(...) is True, and another where getaddrinfo returns an IP in a
blocked/private range and assert _is_safe_url(...) is False; keep references to
the _is_safe_url function and the test_allows_regular_hostname test when
implementing the mocks so the change targets the correct logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@openrag/components/websearch/content_fetcher.py`:
- Around line 84-85: The hostname-only branch in _is_safe_url currently returns
True without DNS resolution; change it to perform A and AAAA lookups for the
parsed hostname and reject if any resolved IP falls into blocked ranges
(localhost 127.0.0.0/8, IPv6 loopback, private RFC1918 ranges, link-local,
etc.); do this check per redirect hop (same place where redirects are
re-validated) and only return True after all DNS-resolved addresses pass the
existing ip-in-blocklist tests used elsewhere in _is_safe_url.

---

Nitpick comments:
In `@openrag/components/websearch/test_content_fetcher.py`:
- Around line 66-68: The test test_allows_regular_hostname currently asserts
_is_safe_url("https://example.com/page") is True without checking DNS
resolution; update it to be DNS-resolution aware by mocking socket.getaddrinfo
(or the resolver used by _is_safe_url) and add two assertions: one where
getaddrinfo returns an address in an allowed/public range and assert
_is_safe_url(...) is True, and another where getaddrinfo returns an IP in a
blocked/private range and assert _is_safe_url(...) is False; keep references to
the _is_safe_url function and the test_allows_regular_hostname test when
implementing the mocks so the change targets the correct logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6218a3c8-41cf-4445-8925-af4a2bf51971

📥 Commits

Reviewing files that changed from the base of the PR and between c231627 and b097554.

📒 Files selected for processing (2)
  • openrag/components/websearch/content_fetcher.py
  • openrag/components/websearch/test_content_fetcher.py

Comment on lines +84 to +85
# Regular hostname — passes initial check; every redirect hop is re-validated.
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Resolve hostnames before treating them as SSRF-safe.

On Line 84, regular hostnames are accepted without checking what they resolve to. A malicious/misbehaving provider can return a hostname that DNS-resolves to 127.0.0.1, 10.0.0.0/8, link-local, or other internal ranges, which bypasses this guard.

Please resolve A/AAAA records and reject if any resolved IP is blocked (and keep this check per redirect hop).

Suggested direction
+import socket
+
+def _hostname_resolves_to_blocked_ip(host: str) -> bool:
+    try:
+        infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
+    except socket.gaierror:
+        return True  # fail closed for untrusted external URLs
+
+    for family, _, _, _, sockaddr in infos:
+        ip_str = sockaddr[0]
+        if _is_blocked_address(ipaddress.ip_address(ip_str)):
+            return True
+    return False

Then use this before the final return True path in _is_safe_url.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openrag/components/websearch/content_fetcher.py` around lines 84 - 85, The
hostname-only branch in _is_safe_url currently returns True without DNS
resolution; change it to perform A and AAAA lookups for the parsed hostname and
reject if any resolved IP falls into blocked ranges (localhost 127.0.0.0/8, IPv6
loopback, private RFC1918 ranges, link-local, etc.); do this check per redirect
hop (same place where redirects are re-validated) and only return True after all
DNS-resolved addresses pass the existing ip-in-blocklist tests used elsewhere in
_is_safe_url.

@hedhoud
hedhoud merged commit 58e5025 into refactor/hexagonal May 26, 2026
6 checks passed
@hedhoud
hedhoud deleted the fix/383-ssrf-web-search-fetcher branch May 26, 2026 13:43
@Ahmath-Gadji Ahmath-Gadji added the fix Fix issue label Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Fix issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants