Skip to content

fix(crawler): refuse crawl targets that are not publicly routable - #1728

Open
ntdat812 wants to merge 1 commit into
MODSetter:devfrom
ntdat812:fix/crawler-ssrf-guard
Open

fix(crawler): refuse crawl targets that are not publicly routable#1728
ntdat812 wants to merge 1 commit into
MODSetter:devfrom
ntdat812:fix/crawler-ssrf-guard

Conversation

@ntdat812

@ntdat812 ntdat812 commented Aug 31, 2026

Copy link
Copy Markdown

WebCrawlerConnector.crawl_url validated its input with validators.url alone. That answers a syntactic question and says nothing about where the URL points, so loopback, private, link-local and cloud-metadata targets passed and were fetched from inside the backend's own network namespace.

Description

Resolve the target host before any tier runs, and refuse it unless every address it answers with is publicly routable.

The guard is a new app/utils/crawl/net_guard.py (is_publicly_routable), placed beside the existing generic crawler helpers rather than in url_policy.py, whose docstring states it holds pure functions with no I/O.

crawl_url is the single choke point, so one gate covers both entry paths: the spider reaches it through _ConnectorSession.fetch, which matters because a public page linking to an internal address would otherwise be followed.

Two details the obvious implementation gets wrong, both covered by tests:

  • ::ffff:127.0.0.1 reports is_loopback == False. The address flags only read true on the mapped IPv4 form, so the mapping is unwrapped before the address is judged. Reading the flags off the IPv6 object admits loopback under its v6 spelling.
  • A union of is_private / is_loopback / is_link_local / is_reserved / is_multicast admits carrier-grade NAT (100.64.0.0/10, RFC 6598), which is not publicly routable. is_global covers that case, but reports True for multicast, so multicast is excluded separately.

Resolution failures refuse, so an unreachable resolver is not a way past the guard. A host answering with both a public and a private address is refused, because the fetcher resolves independently and may pick either.

The malformed-URL and restricted-URL refusals carry distinct messages so the two diagnoses stay apart, which also keeps the existing test_invalid_url_is_failed assertion meaningful.

DNS blocks, so it is offloaded with asyncio.to_thread, the same way the browser tiers already are.

Scope

This narrows the reachable surface; it is not a defence against DNS rebinding. The fetcher resolves the host again on its own, so a name that answers differently between this check and that fetch is out of scope for any resolve-then-fetch guard. Worth stating plainly rather than implying the hole is closed.

Motivation and Context

FIX #1709

Reproduced on dev before the change. With the tier stubs removed, all three tiers actually attempt the connection:

ERROR  scrapling:static.py:490       Failed after 3 attempts: Failed to perform, curl: (7)
                                     Failed to connect to 127.0.0.1 port 8000
WARNING scrapling:_controllers.py:204 Attempt 1 failed: Page.goto: net::ERR_CONNECTION_REFUSED
                                      at http://127.0.0.1:8000/health
WARNING scrapling:_stealth.py:292     Attempt 1 failed: Page.goto: net::ERR_CONNECTION_REFUSED
                                      at http://127.0.0.1:8000/health

and http://169.254.169.254/latest/meta-data/ reached the tiers and returned EMPTY — it was fetched, not refused:

AssertionError: assert <CrawlOutcomeStatus.EMPTY: 'empty'> is <CrawlOutcomeStatus.FAILED: 'failed'>

Both new connector tests fail on the parent commit and pass with the change.

API Changes

  • This PR includes API changes

CrawlOutcome for a restricted target is FAILED with a new Restricted URL (host is not publicly routable): <url> message. No signature changes.

Change Type

  • Bug fix
  • New feature
  • Performance improvement
  • Refactoring
  • Documentation
  • Dependency/Build system
  • Breaking change
  • Other (specify):

Testing Performed

  • Tested locally
  • Manual/QA verification

New coverage. tests/unit/utils/crawl/test_net_guard.py — 20 cases. The literal-address cases need no resolver, so they exercise the real code end to end; only the hostname cases stub _resolve_host, since a unit test cannot depend on what DNS answers. Includes the IPv4-mapped-IPv6, CGNAT and multicast cases named above, plus fail-closed on resolution error, empty answer, and mixed public/private answers.

tests/unit/proprietary/web_crawler/test_connector.py — two cases: the restricted target is FAILED and reaches no tier, and the two refusal messages stay distinct.

Test hygiene. crawl_url now resolves, so the 13 existing tests calling it with https://example.com would have started doing live DNS. An autouse fixture in that module stubs the resolver, keeping them hermetic.

Results. Crawler + capability suites: 110 passed. Full unit suite: 2757 passed, 10 failed. Those 10 are pre-existing on a clean dev checkout — same 10, in knowledge_store/engines/test_git.py, knowledge_store/test_import_boundary.py, middleware/test_git_tree_backend.py and test_pat_fail_closed_static.py — and are unrelated to this change (verified by running them on upstream/dev).

Verified against the real resolver as a sanity check: example.com, github.com and www.google.com are allowed; 127.0.0.1, localhost, 169.254.169.254, [::1], [::ffff:127.0.0.1], 100.64.0.1 and 224.0.0.1 are refused.

ruff check and ruff format --check are clean on every touched file.

Checklist

  • Follows project coding standards and conventions
  • Documentation updated as needed
  • Dependencies updated as needed
  • No lint/build errors or new warnings
  • All relevant tests are passing

High-level PR Summary

This PR fixes a security vulnerability where the web crawler could be tricked into accessing internal network resources like loopback addresses (127.0.0.1), private networks, link-local addresses, and cloud metadata endpoints (169.254.169.254). The fix adds a DNS resolution check that validates every IP address a target URL resolves to is publicly routable before any crawling tier executes. The implementation handles edge cases like IPv4-mapped IPv6 addresses and carrier-grade NAT, and fails closed when DNS resolution fails to prevent bypassing the guard.

⏱️ Estimated Review Time: 30-90 minutes

💡 Review Order Suggestion
Order File Path
1 surfsense_backend/app/utils/crawl/net_guard.py
2 surfsense_backend/tests/unit/utils/crawl/test_net_guard.py
3 surfsense_backend/app/utils/crawl/__init__.py
4 surfsense_backend/app/proprietary/web_crawler/connector.py
5 surfsense_backend/tests/unit/proprietary/web_crawler/test_connector.py

Need help? Join our Discord

@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@ntdat812 is attempting to deploy a commit to the Rohan Verma's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c6d3be99-0193-4162-8db3-7ec1c36ac2d1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

`crawl_url` validated its input with `validators.url` alone, which answers a
syntactic question and says nothing about where the URL points. Both
`http://127.0.0.1:8000/` and `http://169.254.169.254/latest/meta-data/` — the
cloud metadata endpoint — pass it and are then fetched from inside the
backend's own network namespace, by all three tiers in turn.

Resolve the target host before any tier runs and refuse it unless every
address it answers with is publicly routable. `crawl_url` is the single choke
point: the spider reaches it through `_ConnectorSession.fetch`, so the same
gate covers link-following, where a public page linking to an internal address
would otherwise be followed.

Two details the obvious implementation gets wrong:

  - `::ffff:127.0.0.1` reports `is_loopback == False`. The flags only read true
    on the mapped IPv4 form, so the mapping is unwrapped before the address is
    judged; reading them off the IPv6 object admits loopback under its v6
    spelling.
  - A union of `is_private`/`is_loopback`/`is_link_local`/`is_reserved`/
    `is_multicast` admits carrier-grade NAT (100.64.0.0/10, RFC 6598), which is
    not publicly routable. `is_global` covers that, but reports True for
    multicast, so multicast is excluded separately.

Resolution failures refuse, so an unreachable resolver is not a way past the
guard, and a host answering with both a public and a private address is
refused because the fetcher resolves independently and may pick either.

This narrows the reachable surface; it is not a defence against DNS rebinding,
which a resolve-then-fetch guard cannot address.

The malformed-URL and restricted-URL refusals carry distinct messages so the
two diagnoses stay apart. DNS blocks, so it is offloaded with
`asyncio.to_thread`, as the browser tiers already are.

Closes MODSetter#1709
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant