Recipes

Loopback-only DNS enforcement for a local agent harness (bind + LLM clients)

Discovered from README cross-links · yesterday

Security, Science & HardwareSoftware & DevOps

Make localhost mean loopback by address, not by name, in a Rust agent harness.

What they did

The author ran a wiring review of TLS, port binds and loopback in a Rust agent harness (cg-agent-harness) after a prior audit, and found one gap expressed in two places: loopback trust was decided by the hostname `localhost`, and the system resolver then picked the address. They fixed it in three spots. First, `serve_blocking` in `src/server/mod.rs` still accepts `--host`/`CGAGENTHARNESS_HOST` only from a loopback allowlist, but now calls `anyhow::ensure!(listener.local_addr()?.ip().is_loopback(), ...)` right after `TcpListener::bind`, so the process refuses to start if `localhost` resolved off-loopback. Second, the local model clients (`src/llm/backend.rs`, `openai_chat.rs`, `ollama.rs`, `src/agentic/proposer.rs`) previously matched hostname only in `is_loopback_url`; they added `llm::backend::LoopbackOnlyDns`, a reqwest resolver that accepts a lookup only when the answer is non-empty, at most 32 addresses, and all loopback, then installs it on the three local-endpoint builders (`ChatClient::new`, `ollama::http_client`, `LocalProposerClient::new`). IP-literal URLs never hit a resolver and are unchanged. Third, they added a 43-word note to `docs/SECURE_RESEARCH.md` about browsers not isolating cookies by port, so other services on the same loopback address can receive the session cookie on a shared host. They verified with a positive run (`serve --host localhost` against a Python mock model server, logging `/api/tags`, `/api/show`, `/api/ps` and warmup `/api/generate` calls, plus a probe returning `[127.0.0.1:0]`), a negative run (temporarily rewriting `/etc/hosts` so `localhost` → `192.0.2.2`, with a shell `trap` restoring the backup, producing the new fail-closed errors), and a unit test pinning the filter. They also answered the commit hook's new-egress prompt, noting no new destination is added.

How it went

Both failure modes now fail closed with clear errors, and the positive path still works end to end against a mock local model. `cargo fmt --check` and `cargo clippy --all-targets --all-features -D warnings` exit 0, and the invariant-guard doc-budget test passes 21/21. The PR is still a draft, blocked on merge order with four sibling PRs, and the full `cargo test --all-targets` was left to CI.

Worth knowing

Hosts where `localhost` resolves to both loopback and non-loopback addresses now refuse to start instead of picking one — intended, but a new error users may hit; the fix is to map `localhost` to loopback only or use `127.0.0.1`. The blocking reqwest client's resolver path was compiled but not exercised end to end. `cargo deny` was not run (not installed, no dependency change), and macOS/Windows compiles were skipped. Exploitability before the fix required control of name resolution for `localhost`, normally root. The shared-host cookie limitation has no clean code fix and is documented only.

Wiring

Order: (1) add `LoopbackOnlyDns` in `src/llm/backend.rs` implementing reqwest's resolver trait — run the system lookup, reject empty answers, reject more than 32 addresses, reject any non-loopback address, return the surviving addresses so reqwest connects to exactly those; (2) wire it into the three local-endpoint client builders (`ChatClient::new`, `ollama::http_client` shared by inventory/profile/warmup/pull, `LocalProposerClient::new`), keeping `.no_proxy()` and `Policy::none()` unchanged; (3) add the post-bind `ensure!` in `serve_blocking` in `src/server/mod.rs` after `TcpListener::bind((host, port))`; (4) add the cookie/port note to `docs/SECURE_RESEARCH.md` rather than `INVARIANTS.md`, because the latter feeds `assets/self/MANUAL.md` and would conflict with sibling PRs; (5) add the unit test for the resolver filter. Config surface is unchanged: `--host`/`CGAGENTHARNESS_HOST`, `models.local_llm.base_url`, the fallback URL, and `agentic.deepagent_github.base_url`. Shipped defaults use `127.0.0.1` and bypass the resolver entirely.

Try it yourself

Audit my repo for places where 'loopback' is decided by hostname instead of by resolved address. Specifically: (1) find every server bind that accepts a host string like `localhost` and add a post-bind assertion that `listener.local_addr()?.ip().is_loopback()` holds, failing closed with a clear error; (2) find every HTTP client pointed at a local model or local service via a `localhost` URL and install a custom resolver that accepts a lookup only when the answer is non-empty, bounded in count, and entirely loopback, so there is no gap between check and connect; (3) leave IP-literal URLs untouched since they never reach a resolver. Then verify with a positive run against a mock local server and a negative run that temporarily points `localhost` at a non-loopback address (restore it with a trap), and add a unit test pinning the resolver filter for empty, mixed, and oversized answers.

Source

Tools in this stack