Skip to content

fix(web-search): resolve the SearXNG base URL host instead of prefix-matching it - #931

Open
joaovictor91123 wants to merge 2 commits into
GeniePod:mainfrom
joaovictor91123:fix/web-search-local-base-url-host-parse
Open

fix(web-search): resolve the SearXNG base URL host instead of prefix-matching it#931
joaovictor91123 wants to merge 2 commits into
GeniePod:mainfrom
joaovictor91123:fix/web-search-local-base-url-host-parse

Conversation

@joaovictor91123

@joaovictor91123 joaovictor91123 commented Jul 31, 2026

Copy link
Copy Markdown

Summary

Closes #929. is_local_base_url gates web_search.allow_remote_base_url: with
the opt-in off, every household search query must stay on-device. It decided
that by prefix-matching the URL text, which is wrong in both directions.

Remote hosts passed as local, so queries left the device with the opt-in
still off:

base_url before after real host
http://localhost.attacker.example/search local remote localhost.attacker.example
http://localhost-evil.example local remote localhost-evil.example
http://127.evil.example/ local remote 127.evil.example
https://127.0.0.1.nip.io/ local remote 127.0.0.1.nip.io
http://127.0.0.1@evil.com/search local remote evil.com

The last row is the sharp one: the loopback text is userinfo, so the string
looks unambiguously local and the connection goes to evil.com.

Genuine loopback URLs were rejected, so a legitimate on-device SearXNG
behind credentials could not be used at all — the guard failed closed on the
configuration it exists to permit:

base_url before after
http://user@localhost:8888 remote local
http://user:pass@[::1]:8888 remote local

Changes

  • is_local_base_url now resolves the URL's host and tests it for loopback,
    instead of prefix-matching the string.
  • It delegates to security::sandbox::extract_host / is_loopback_host, which
    already do this correctly for validate_inference_routeextract_host
    strips userinfo and handles bracketed IPv6, and is_loopback_host documents
    itself as matching "a literal loopback target (not a hostname that merely
    starts with a loopback-looking prefix
    )". Those two are now pub(crate).
  • Deliberately not a fourth private copy of the check. The copies drift:
    genie-common::config::is_remote_url hardened its own duplicate after this
    exact class regressed once already ([bug] llm/provider: remote_url() blocks 127.0.0.0/8 except 127.0.0.1 #327, the 127.0.0.0/8 range), and its
    test comment names the genie-core check as the drifted sibling. This was the
    remaining weak copy, so the fix removes it rather than re-fixing it in place.

Behavior for the configurations already covered by tests is unchanged:
127.0.0.1, localhost and [::1] stay local; searx.example.com stays
remote. Newly correct: the whole 127.0.0.0/8 range, userinfo handling, and
mixed-case hosts.

Real Behavior Proof

  • I have built and run the affected code locally (or noted why I could not).
  • I have verified the change end-to-end on Jetson hardware.
  • I have NOT verified on Jetson hardware, and I explain the equivalent verification path or validation gap below.

Tested profile / hardware (check all that apply):

  • jetson
  • raspberry_pi
  • portable_sbc
  • laptop
  • mac
  • CI-only / docs-only
  • Not run locally

What I ran

x86_64 Linux laptop (Ubuntu 22.04 LTS, kernel 6.8.0-136-generic,
rustc 1.96.0 / cargo 1.96.0). No Jetson and no SearXNG instance available to
me — see the validation gap below.

cargo test -p genie-core
cargo test -p genie-core --no-default-features
cargo clippy -p genie-core --all-targets
cargo clippy -p genie-core --all-targets --no-default-features
cargo fmt -p genie-core -- --check

Plus a temporary probe calling is_local_base_url directly on main over the
full corpus, so the classification was read off the function rather than
reasoned about. The probe was removed before committing; only the permanent
tests remain.

What I observed

Probe output on main — five remote hosts accepted as local, two real loopback
hosts rejected:

is_local_base_url("http://localhost.attacker.example/search") = true
is_local_base_url("http://127.0.0.1@evil.com/search")         = true
is_local_base_url("http://127.evil.example/")                 = true
is_local_base_url("http://localhost-evil.example")            = true
is_local_base_url("https://127.0.0.1.nip.io/")                = true
is_local_base_url("http://user@localhost:8888")               = false
is_local_base_url("http://user:pass@[::1]:8888")              = false
is_local_base_url("http://127.0.0.2:8888")                    = true
is_local_base_url("http://127.0.0.1:8888")                    = true
is_local_base_url("https://searx.example.com")                = false

Every one of those is inverted correctly on this branch, and the two controls
(127.0.0.1:8888 local, searx.example.com remote) are unchanged. The
pre-existing local_base_url_detection_allows_loopback test still passes
unmodified.

Full suite: 1005 lib tests + all integration suites green on default
features and on --no-default-features; clippy clean on both;
cargo fmt --check clean.

Validation gap

I could not verify on Jetson hardware or against a live SearXNG. The equivalent
verification path: the change is confined to a pure &str -> bool predicate and
the visibility of two existing helpers. No I/O, no async, no hardware
dependency, no model involvement. The predicate's entire contract is the
classification it returns for a base URL, which the tests assert directly, and
the call site it feeds (search_searxng's anyhow::bail!) is untouched — a URL
that classifies remote is refused exactly as it is today.

I did not attempt to resolve any of the adversarial hostnames; they are inputs
to a string predicate here, and no network call is made in any test.

Test plan

  1. Cherry-pick the two new tests onto main alone — both fail.
  2. Check out this branch, rerun — both pass.
  3. With web_search.provider = "searxng" and allow_remote_base_url = false,
    set base_url = "http://localhost.attacker.example" and confirm the search
    is now refused with "base URL must be local", then set
    base_url = "http://user@localhost:8888" and confirm it is accepted.

Notes for reviewers

  • local_base_url_accepts_loopback_behind_userinfo_and_the_whole_127_range is
    worth a look alongside the rejection test: tightening a guard is only safe if
    it does not also start blocking valid on-device configs, and the prefix
    version was blocking two of them.
  • No prompt growth, no new dependencies. The 4096-token Jetson context contract
    is untouched.

Summary by CodeRabbit

  • Bug Fixes
    • Improved validation of local search service URLs.
    • Prevented deceptive remote hosts from being treated as local.
    • Added support for valid loopback addresses, IPv6 loopback, credentials, varied casing, and URL paths.

…matching it

is_local_base_url gates web_search.allow_remote_base_url: with the opt-in
off, every household search query must stay on-device. It decided that by
prefix-matching the URL text, which was wrong in both directions.

Remote hosts passed as local, so queries left the device with the opt-in
still off:

  http://localhost.attacker.example  -> local
  http://localhost-evil.example      -> local
  http://127.evil.example            -> local
  https://127.0.0.1.nip.io           -> local
  http://127.0.0.1@evil.com          -> local  (userinfo; real host evil.com)

And genuine loopback URLs were rejected, so a legitimate on-device
SearXNG behind credentials could not be used at all — the guard failed
closed on the configuration it exists to permit:

  http://user@localhost:8888   -> remote
  http://user:pass@[::1]:8888  -> remote

security::sandbox already parses hosts correctly for
validate_inference_route, and its helper documents itself as matching "a
literal loopback target (not a hostname that merely starts with a
loopback-looking prefix)" — exactly the property missing here. Make
extract_host/is_loopback_host pub(crate) and delegate, so this is one
fewer independent copy of the decision rather than one more.

That matters because the copies drift: genie-common::config hardened its
own duplicate after this class regressed once already (GeniePod#327, the
127.0.0.0/8 range), and its test comment names the genie-core check as
the drifted sibling. This was the remaining weak copy.

Behavior for the configurations already covered by tests is unchanged:
127.0.0.1, localhost, and [::1] stay local, searx.example.com stays
remote. Newly correct: the whole 127.0.0.0/8 range, userinfo handling,
and mixed-case hosts.

Closes GeniePod#929
@github-actions github-actions Bot added the bug Something isn't working label Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 60f7863f-2073-4993-aad3-d685494d3306

📥 Commits

Reviewing files that changed from the base of the PR and between 4fd9151 and ce6b175.

📒 Files selected for processing (2)
  • crates/genie-core/src/security/sandbox.rs
  • crates/genie-core/src/tools/web_search.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/genie-core/src/tools/web_search.rs

📝 Walkthrough

Walkthrough

The change makes sandbox URL helpers crate-visible and updates is_local_base_url to validate parsed hosts. Regression tests cover deceptive remote hosts, credentials, IPv4 and IPv6 loopback addresses, casing, queries, fragments, and paths.

Changes

Loopback URL guard

Layer / File(s) Summary
Expose sandbox URL helpers
crates/genie-core/src/security/sandbox.rs
extract_host and is_loopback_host are now pub(crate). Host extraction stops at URL authority terminators, including /, ?, #, and \. Tests cover userinfo and deceptive host forms.
Validate web search base URLs
crates/genie-core/src/tools/web_search.rs
is_local_base_url validates the extracted host with sandbox helpers. Tests cover deceptive remote hosts and authenticated, IPv4, IPv6, mixed-case, and path-bearing loopback URLs.

Estimated code review effort: 2 (Simple) | ~15 minutes

🚥 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 clearly describes the primary fix: host resolution replaces prefix matching for SearXNG base URLs.
Linked Issues check ✅ Passed The changes satisfy issue [#929] by resolving hosts, reusing sandbox helpers, and adding regression tests for loopback and deceptive URLs.
Out of Scope Changes check ✅ Passed The changes remain within the linked issue scope and support the URL host classification fix and its security regression tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@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

🤖 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 `@crates/genie-core/src/security/sandbox.rs`:
- Around line 229-236: The URL host extraction used by the egress guard must use
standards-compliant parsing instead of manual authority parsing. In
crates/genie-core/src/security/sandbox.rs:229-236, update extract_host to parse
with reqwest::Url, return host_str() in the existing normalized form, and fail
closed on parse errors; add regressions for query-, fragment-, and
backslash-delimited authority cases. In
crates/genie-core/src/tools/web_search.rs:424-426 and 744-779, preserve the
existing call sites and ensure their egress decisions use the corrected
extract_host behavior; no separate parsing or prefix matching should remain
there.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e02a7e63-454f-48ee-8ebb-815b808a579e

📥 Commits

Reviewing files that changed from the base of the PR and between 02a577d and 4fd9151.

📒 Files selected for processing (2)
  • crates/genie-core/src/security/sandbox.rs
  • crates/genie-core/src/tools/web_search.rs

Comment thread crates/genie-core/src/security/sandbox.rs
Follow-up to the review on GeniePod#931.

extract_host split the authority on '/' only, so a query or fragment
stayed attached and the userinfo strip below then read *into* it. The
'@' inside the query/fragment made the trailing text look like the host:

  extract_host("http://evil.com#@127.0.0.1") -> "127.0.0.1"  (loopback)
  extract_host("http://evil.com?@127.0.0.1") -> "127.0.0.1"  (loopback)
  extract_host("http://evil.com\@127.0.0.1") -> "127.0.0.1"  (loopback)

That is fail-open, and it is not confined to the web-search guard this
PR touches: extract_host also backs validate_inference_route, the SSRF
check on LLM endpoints. Both accepted those URLs.

End the authority at the first '/', '?', '#', or '\'. The backslash
belongs in that set rather than being left to the userinfo strip: WHATWG
treats '\' as a path separator for http(s), which is how reqwest's URL
parser resolves it, so "http://127.0.0.1\@evil.com" really does connect
to 127.0.0.1 — the old split reported "evil.com" and rejected a valid
local endpoint.

Kept the hand-rolled parse rather than switching to reqwest::Url as the
review suggested. validate_inference_route is also fed scheme-less
authorities, and Url::parse reads "127.0.0.1:8080" as scheme
"127.0.0.1" — adopting it would need a synthesized scheme and a
fail-closed error path, which is a larger behavioral change than this
bug warrants. The delimiter set is what was actually wrong.

Genuine userinfo is unaffected: "http://user:pass@[::1]:8080" and
"http://user@localhost" stay local, "http://localhost:1@evil.com/v1"
stays remote.
@joaovictor91123

Copy link
Copy Markdown
Author

Good catch — the underlying finding is real, and it is worse than a hardening nit. Fixed in the latest commit.

extract_host split the authority on / only, so a query or fragment stayed attached and the userinfo strip below then read into it. The @ inside the query/fragment made the trailing text look like the host:

extract_host("http://evil.com#@127.0.0.1") -> "127.0.0.1"   (loopback)
extract_host("http://evil.com?@127.0.0.1") -> "127.0.0.1"   (loopback)
extract_host("http://evil.com\@127.0.0.1") -> "127.0.0.1"   (loopback)

That is fail-open, and it is not confined to the web-search guard this PR touches — extract_host also backs validate_inference_route, the SSRF check on LLM endpoints. Both accepted those URLs. Confirmed by calling the function directly, not inferred.

The fix ends the authority at the first /, ?, #, or \. The backslash belongs in that set rather than being left to the userinfo strip: WHATWG treats \ as a path separator for http(s), which is how reqwest's URL parser resolves it, so http://127.0.0.1\@evil.com really does connect to 127.0.0.1 — the old split reported evil.com and rejected a valid local endpoint. Regression tests cover all four cases in both directions, plus genuine userinfo (http://user:pass@[::1]:8080 local, http://localhost:1@evil.com/v1 remote).

On the reqwest::Url suggestion — I did not take it, deliberately. validate_inference_route is also fed scheme-less authorities, and Url::parse("127.0.0.1:8080") reads 127.0.0.1 as the scheme rather than the host. Adopting it would need a synthesized scheme plus a fail-closed error path, which is a larger behavioral change to an SSRF guard than this bug warrants and would risk breaking existing local endpoints. The delimiter set was what was actually wrong; happy to revisit standards-compliant parsing as its own change if you would prefer it.

One related item I left alone to keep this PR focused: genie-common::config::is_remote_url is an independent copy of this logic and has the same split('/') authority parse, so the same query/fragment bypass applies there. It is a different crate and a different guard (privacy_proxy / optional_ai_provider endpoints), so it seems worth its own issue rather than widening this one — say the word if you would rather have it here.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] web search: is_local_base_url prefix-matches, so a host that merely starts with localhost passes the on-device guard

1 participant