Skip to content

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

Description

@joaovictor91123

Summary

web_search::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 decides
that by prefix-matching the URL text, so it is wrong in both directions.

fn is_local_base_url(base_url: &str) -> bool {
    let lower = base_url.trim().to_lowercase();
    lower.starts_with("http://127.")
        || lower.starts_with("http://localhost")
        || ...
}

Repro (on main)

Remote hosts classified as local — queries leave the device with the opt-in
still off:

base_url is_local_base_url actual host
http://localhost.attacker.example/search true localhost.attacker.example
http://localhost-evil.example true localhost-evil.example
http://127.evil.example/ true 127.evil.example
https://127.0.0.1.nip.io/ true 127.0.0.1.nip.io
http://127.0.0.1@evil.com/search true evil.com — the loopback text is userinfo

Genuine loopback URLs classified as remote — a legitimate on-device SearXNG
behind credentials cannot be used at all:

base_url is_local_base_url
http://user@localhost:8888 false
http://user:pass@[::1]:8888 false

All of the above are observed output from calling the function directly.

Why it matters

This is the guard that keeps household search queries on-device when the user
has not opted into a remote SearXNG. A base_url that merely starts with
loopback-looking text sends every query to a third party, silently, with
allow_remote_base_url = false. The userinfo case is the sharp one: the string
looks unambiguously local and the connection goes to evil.com.

Root cause

Prefix matching on the URL string instead of resolving the host. The same
codebase already knows this:

  • security::sandbox::is_loopback_host (same crate) documents itself as
    matching "a literal loopback target (not a hostname that merely starts with
    a loopback-looking prefix
    )", and extract_host beside it strips userinfo
    and handles bracketed IPv6.
  • genie-common::config::is_remote_url hardened its own copy of this decision
    after the class regressed once already ([bug] llm/provider: remote_url() blocks 127.0.0.0/8 except 127.0.0.1 #327, 127.0.0.0/8), and its test
    comment explicitly names the genie-core check as the drifted sibling.

is_local_base_url is the remaining weak copy.

Proposed fix

Make security::sandbox::extract_host / is_loopback_host pub(crate) and
have is_local_base_url delegate, so this becomes one fewer independent copy
of the decision rather than one more. Behavior for the configurations already
covered by tests is unchanged (127.0.0.1, localhost, [::1] local;
searx.example.com remote).

Acceptance

  • Hosts that merely look loopback are classified remote and blocked
  • Userinfo-masked remote hosts are classified remote
  • Loopback behind userinfo, the whole 127.0.0.0/8 range, and mixed-case hosts
    are classified local
  • Focused regression tests fail on main, pass with the fix

Labels / scope

Privacy / egress guard (web search).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions