Recipes

로컬 에이전트 하네스를 위한 루프백 전용 DNS 적용 (bind + LLM 클라이언트)

README 링크에서 발견 · yesterday

Security, Science & HardwareSoftware & DevOps

# Rust 에이전트 하네스에서 `localhost`를 이름이 아닌 주소 기준 루프백으로 처리하기 `localhost`를 호스트 이름 해석에 맡기면 `/etc/hosts`나 DNS 설정에 따라 `::1`, `127.0.0.1`, 심지어 외부 주소로도 풀릴 수 있습니다. 에이전트 하네스에서는 이게 보안·일관성 문제가 됩니다. 이름 기반 해석을 우회하고 주소 기준으로 루프백을 판정하는 방법을 정리합니다. ## 문제 지점 ```rust // 이름 기반: /etc/hosts, resolver, search domain에 좌우됨 let addrs = tokio::net::lookup_host(("localhost", port)).await?; ``` 이 방식은 다음을 보장하지 못합니다. - `localhost`가 `127.0.0.1`로만 풀린다는 보장 없음 - IPv6 우선 환경에서 `::1`과 `127.0.0.1` 중 무엇이 선택될지 불확실 - 해석 결과가 루프백이 아닌 주소일 가능성 배제 불가 ## 해법: 리터럴 주소로 정규화 `localhost`(및 그 변형)를 만나면 해석기를 거치지 않고 리터럴 루프백 주소로 치환합니다. ```rust use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; fn normalize_host(host: &str) -> Option<IpAddr> { match host.to_ascii_lowercase().as_str() { "localhost" | "localhost." => Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), _ => host.parse::<IpAddr>().ok(), } } ``` IPv6를 선호한다면 `Ipv6Addr::LOCALHOST`(`::1`)를 반환하고, 듀얼 스택이 필요하면 두 주소를 모두 시도하는 전략을 씁니다. ## 루프백 판정은 주소로 연결 허용 여부를 판단할 때도 이름이 아니라 `IpAddr` 기준으로 검사합니다. ```rust fn is_loopback(addr: &IpAddr) -> bool { match addr { IpAddr::V4(v4) => v4.is_loopback(), // 127.0.0.0/8 IpAddr::V6(v6) => v6.is_loopback(), // ::1 } } ``` `Ipv4Addr::is_loopback`은 `127.0.0.0/8` 전체를, `Ipv6Addr::is_loopback`은 `::1`만 참으로 봅니다. IPv4-mapped IPv6(`::ffff:127.0.0.1`)는 별도로 풀어서 판정해야 합니다. ```rust fn is_loopback_any(addr: &IpAddr) -> bool { match addr { IpAddr::V6(v6) => { if let Some(v4) = v6.to_ipv4_mapped() { v4.is_loopback() } else { v6.is_loopback() } } IpAddr::V4(v4) => v4.is_loopback(), } } ``` ## 연결 단계에 적용 ```rust async fn connect(host: &str, port: u16) -> std::io::Result<tokio::net::TcpStream> { let addr = match normalize_host(host) { Some(ip) => SocketAddr::new(ip, port), None => { // 이름 해석이 필요한 경우에만 resolver 사용 let mut addrs = tokio::net::lookup_host((host, port)).await?; addrs.next().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, "no address") })? } }; tokio::net::TcpStream::connect(addr).await } ``` 핵심은 `localhost`가 절대 `lookup_host`로 흘러가지 않게 하는 것입니다. 해석 경로를 타는 순간 이름 기반 동작이 다시 들어옵니다. ## 정책 검사에 적용 에이전트가 임의 URL로 요청을 보낼 수 있는 하네스라면, SSRF 방지용 루프백 차단도 주소 기준이어야 합니다. ```rust fn deny_loopback(addr: &SocketAddr) -> Result<(), &'static str> { if is_loopback_any(&addr.ip()) { return Err("loopback address blocked"); } Ok(()) } ``` 이름을 문자열로 비교하는 방식(`host == "localhost"`)은 `127.0.0.1`, `[::1]`, `127.1`, `0x7f.0.0.1` 같은 우회를 막지 못합니다. 반드시 파싱된 `IpAddr`로 판정하세요. ## 정리 - `localhost`는 해석기에 넘기지 말고 리터럴 루프백 주소로 정규화한다. - 루프백 여부는 `IpAddr::is_loopback`으로 판정하고, IPv4-mapped IPv6는 명시적으로 풀어 처리한다. - 정책 검사(허용/차단)는 문자열이 아니라 파싱된 주소를 기준으로 한다. 이렇게 하면 `/etc/hosts`나 resolver 설정이 바뀌어도 하네스의 루프백 동작이 흔들리지 않습니다.

What they did

저자는 이전 감사 이후 Rust 에이전트 하네스(cg-agent-harness)에서 TLS, 포트 바인드, 루프백에 대한 배선 검토를 수행했고, 두 곳에서 표현된 하나의 결함을 발견했다. 루프백 신뢰가 호스트명 `localhost`로 결정되고 있었고, 그다음 시스템 리졸버가 주소를 선택했다. 그들은 세 군데에서 이를 수정했다. 첫째, `src/server/mod.rs`의 `serve_blocking`은 여전히 `--host`/`CGAGENTHARNESS_HOST`를 루프백 허용 목록에서만 받지만, 이제 `TcpListener::bind` 직후에 `anyhow::ensure!(listener.local_addr()?.ip().is_loopback(), ...)`를 호출하여, `localhost`가 루프백 밖으로 해석되면 프로세스가 시작을 거부한다. 둘째, 로컬 모델 클라이언트들(`src/llm/backend.rs`, `openai_chat.rs`, `ollama.rs`, `src/agentic/proposer.rs`)은 이전에 `is_loopback_url`에서 호스트명만 일치시켰는데, 이제 `llm::backend::LoopbackOnlyDns`를 추가했다. 이는 응답이 비어 있지 않고, 최대 32개 주소이며, 모두 루프백일 때만 조회를 허용하는 reqwest 리졸버이며, 세 개의 로컬 엔드포인트 빌더(`ChatClient::new`, `ollama::http_client`, `LocalProposerClient::new`)에 설치된다. IP 리터럴 URL은 리졸버를 거치지 않으며 변경되지 않는다. 셋째, 브라우저가 포트별로 쿠키를 격리하지 않으므로 공유 호스트에서 동일한 루프백 주소의 다른 서비스가 세션 쿠키를 받을 수 있다는 43단어 분량의 주석을 `docs/SECURE_RESEARCH.md`에 추가했다. 그들은 긍정 실행(Python 목 모델 서버에 대해 `serve --host localhost`를 실행하여 `/api/tags`, `/api/show`, `/api/ps` 및 워밍업 `/api/generate` 호출을 로깅하고, `[127.0.0.1:0]`을 반환하는 프로브 포함), 부정 실행(`localhost` → `192.0.2.2`가 되도록 `/etc/hosts`를 임시로 재작성하고, 셸 `trap`으로 백업을 복원하여 새로운 fail-closed 오류를 생성), 그리고 필터를 고정하는 단위 테스트로 검증했다. 그들은 또한 커밋 훅의 새로운 이그레스 프롬프트에 답변하여 새로운 목적지가 추가되지 않았음을 명시했다.

How it went

이제 두 실패 모드 모두 명확한 오류와 함께 fail closed로 동작하며, 정상 경로는 mock 로컬 모델에 대해 여전히 end to end로 작동합니다. `cargo fmt --check`와 `cargo clippy --all-targets --all-features -D warnings`는 exit 0으로 종료되고, invariant-guard doc-budget 테스트는 21/21 통과합니다. PR은 여전히 draft 상태이며, 네 개의 형제 PR과의 merge 순서에 막혀 있고, 전체 `cargo test --all-targets`는 CI에 맡겨 두었습니다.

Worth knowing

`localhost`가 loopback과 non-loopback 주소 모두로 resolve되는 호스트는 이제 하나를 선택하는 대신 시작을 거부합니다 — 의도된 동작이지만 사용자가 마주칠 수 있는 새로운 에러입니다; 해결 방법은 `localhost`를 loopback에만 매핑하거나 `127.0.0.1`을 사용하는 것입니다. blocking reqwest 클라이언트의 resolver 경로는 컴파일되었지만 end to end로 실행되지는 않았습니다. `cargo deny`는 실행되지 않았고(미설치, 의존성 변경 없음), macOS/Windows 컴파일은 건너뛰었습니다. 수정 전 익스플로잇 가능성은 `localhost`에 대한 name resolution 제어를 요구했으며, 이는 보통 root 권한입니다. shared-host cookie 제한은 깔끔한 코드 수정 방법이 없어 문서화만 되어 있습니다.

Wiring

순서: (1) reqwest의 resolver 트레이트를 구현하는 `LoopbackOnlyDns`를 `src/llm/backend.rs`에 추가 — 시스템 lookup을 실행하고, 빈 응답을 거부하고, 32개를 초과하는 주소를 거부하고, non-loopback 주소를 거부하고, 살아남은 주소를 반환하여 reqwest가 정확히 그 주소들에 연결하도록 한다; (2) 세 개의 로컬 엔드포인트 클라이언트 빌더(`ChatClient::new`, inventory/profile/warmup/pull이 공유하는 `ollama::http_client`, `LocalProposerClient::new`)에 이를 연결하되, `.no_proxy()`와 `Policy::none()`은 변경하지 않고 유지한다; (3) `src/server/mod.rs`의 `serve_blocking`에서 `TcpListener::bind((host, port))` 이후에 post-bind `ensure!`를 추가한다; (4) cookie/port 관련 주석은 `INVARIANTS.md`가 아니라 `docs/SECURE_RESEARCH.md`에 추가한다. 후자는 `assets/self/MANUAL.md`에 공급되므로 형제 PR들과 충돌할 수 있기 때문이다; (5) resolver 필터에 대한 단위 테스트를 추가한다. 설정 표면은 변경되지 않는다: `--host`/`CGAGENTHARNESS_HOST`, `models.local_llm.base_url`, fallback URL, 그리고 `agentic.deepagent_github.base_url`. 배포되는 기본값은 `127.0.0.1`을 사용하며 resolver를 완전히 우회한다.

Try it yourself

I can't audit your repo directly since I don't have access to it, but here's a concrete plan and reference implementation you can apply. I'll structure it as (1) bind-side assertions, (2) client-side resolver, (3) verification, and (4) the unit test.

## 1. Server binds: post-bind loopback assertion

For every `TcpListener::bind(host)` where `host` is a string like `"localhost"` or `"127.0.0.1"`, add an assertion immediately after bind. The key point: `local_addr()` reflects the *resolved* address the kernel actually bound to, so this closes the hostname-vs-address gap.

```rust
use std::io;
use std::net::TcpListener;

fn bind_loopback(host: &str, port: u16) -> io::Result<TcpListener> {
    let listener = TcpListener::bind((host, port))?;
    let addr = listener.local_addr()?;
    if !addr.ip().is_loopback() {
        return Err(io::Error::new(
            io::ErrorKind::AddrNotAvailable,
            format!(
                "refusing to serve on non-loopback address {addr} (requested host={host:?}); \
                 this endpoint is intended for local use only"
            ),
        ));
    }
    Ok(listener)
}
```

Apply the same pattern to every bind site. Grep for candidates:

```bash
rg -n 'TcpListener::bind|\.bind\(|bind_addr|listen\(' --type rust
rg -n 'localhost|127\.0\.0\.1|::1' --type rust
```

For async stacks (tokio, axum, hyper), the shape is the same:

```rust
let listener = tokio::net::TcpListener::bind((host, port)).await?;
let addr = listener.local_addr()?;
if !addr.ip().is_loopback() {
    return Err(anyhow::anyhow!(
        "refusing to bind non-loopback address {addr} (host={host:?})"
    ));
}
```

Notes:
- Fail *closed*: return an error, don't log-and-continue.
- Do this even when `host` is `"127.0.0.1"` — it's cheap and catches config drift (e.g. someone swaps in `0.0.0.0`).
- If you have a config layer that accepts `host: String`, put the assertion in the single function that constructs the listener so every caller inherits it.

## 2. HTTP clients: custom resolver for local services

For clients pointed at `http://localhost:PORT` (local model servers, sidecars, etc.), install a resolver that validates the *answer*, not the hostname. This eliminates the check-then-connect gap because the resolver's output is what the connector dials.

```rust
use std::io;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};

const MAX_ADDRS: usize = 8;

/// Filter a resolved address list: non-empty, bounded, entirely loopback.
pub fn filter_loopback_addrs(addrs: Vec<SocketAddr>) -> io::Result<Vec<SocketAddr>> {
    if addrs.is_empty() {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            "resolver returned no addresses",
        ));
    }
    if addrs.len() > MAX_ADDRS {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("resolver returned {} addresses (max {MAX_ADDRS})", addrs.len()),
        ));
    }
    if let Some(bad) = addrs.iter().find(|a| !a.ip().is_loopback()) {
        return Err(io::Error::new(
            io::ErrorKind::PermissionDenied,
            format!("resolver returned non-loopback address {bad}; refusing to connect"),
        ));
    }
    Ok(addrs)
}

/// Resolver used by the HTTP client for local-service URLs.
pub fn resolve_loopback(host: &str, port: u16) -> io::Result<Vec<SocketAddr>> {
    let addrs: Vec<SocketAddr> = (host, port).to_socket_addrs()?.collect();
    filter_loopback_addrs(addrs)
}
```

Wire it into the client. With `reqwest`:

```rust
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use std::sync::Arc;

#[derive(Debug)]
struct LoopbackResolver;

impl Resolve for LoopbackResolver {
    fn resolve(&self, name: Name) -> Resolving {
        let host = name.as_str().to_owned();
        Box::pin(async move {
            let addrs = tokio::task::spawn_blocking(move || {
                let resolved: Vec<SocketAddr> =
                    (host.as_str(), 0u16).to_socket_addrs()?.collect();
                filter_loopback_addrs(resolved)
            })
            .await
            .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?
            .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
            let iter: Addrs = Box::new(addrs.into_iter());
            Ok(iter)
        })
    }
}

let client = reqwest::Client::builder()
    .dns_resolver(Arc::new(LoopbackResolver))
    .build()?;
```

With `hyper`/`hyper-util`, use `HttpConnector::set_resolver` with an equivalent `Resolve` impl. With `ureq`, use `AgentBuilder::resolver(...)`.

Important: the resolver must be the *only* path to a socket for these clients. If any code path calls `TcpStream::connect("localhost:…")` directly, it bypasses the filter — route those through the same helper.

## 3. Leave IP-literal URLs untouched

`http://127.0.0.1:PORT` and `http://[::1]:PORT` never invoke a resolver — the connector parses the literal and dials it. That's fine and desirable: no DNS, no gap. Just make sure your code doesn't accidentally rewrite literals into hostnames (e.g. via URL normalization) and that the resolver isn't installed on clients that only ever see literals (harmless, but noise).

A quick audit grep:

```bash
rg -n 'http://localhost|https://localhost|localhost:' --type rust --type ts --type py
rg -n 'http://127\.0\.0\.1|http://\[::1\]' --type rust --type ts --type py
```

## 4. Verification

### Positive run

```bash
# Start a mock local server on loopback
python3 -m http.server 18080 --bind 127.0.0.1 &
MOCK_PID=$!
trap 'kill $MOCK_PID 2>/dev/null' EXIT

# Point the client at localhost and confirm it connects
cargo run --bin probe -- --url http://localhost:18080/health
# expect: success
```

### Negative run

Temporarily map `localhost` to a non-loopback address, then restore with a trap. Use a private `/etc/hosts` override via a container or a `HOSTALIASES`-style shim; the simplest portable approach is a temporary hosts file plus `LD_PRELOAD`-free override through `nsswitch` is fragile, so prefer a container or a bind-mount:

```bash
set -euo pipefail
HOSTS_BAK=$(mktemp)
cp /etc/hosts "$HOSTS_BAK"
restore() { cp "$HOSTS_BAK" /etc/hosts; rm -f "$HOSTS_BAK"; }
trap restore EXIT

# Point localhost at a non-loopback address (use a real non-loopback IP on this host)
NONLOOP=$(ip -4 addr show scope global | awk '/inet /{print $2}' | cut -d/ -f1 | head -n1)
printf '127.0.0.1 localhost\n%s localhost\n' "$NONLOOP" > /etc/hosts

# Expect the client to fail closed with a clear error
if cargo run --bin probe -- --url http://localhost:18080/health; then
  echo "FAIL: client connected to non-loopback localhost" >&2
  exit 1
else
  echo "OK: client refused non-loopback localhost"
fi
```

If you can't touch `/etc/hosts` (CI sandboxes), run the negative case in a container with a custom `/etc/hosts`, or inject a test resolver that returns a non-loopback address and assert `filter_loopback_addrs` rejects it — that's the unit test below.

Also add a bind-side negative: start the server with `--host 0.0.0.0` and assert it exits non-zero with the "refusing to serve on non-loopback address" message.

## 5. Unit test pinning the resolver filter

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};

    fn sa(ip: IpAddr, port: u16) -> SocketAddr { SocketAddr::new(ip, port) }

    #[test]
    fn empty_answer_is_rejected() {
        let err = filter_loopback_addrs(vec![]).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::NotFound);
    }

    #[test]
    fn mixed_answer_is_rejected() {
        let addrs = vec![
            sa(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
            sa(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8080),
        ];
        let err = filter_loopback_addrs(addrs).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
        assert!(err.to_string().contains("non-loopback"));
    }

    #[test]
    fn oversized_answer_is_rejected() {
        let addrs: Vec<_> = (0..=MAX_ADDRS)
            .map(|_| sa(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080))
            .collect();
        let err = filter_loopback_addrs(addrs).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
    }

    #[test]
    fn all_loopback_v4_and_v6_is_accepted() {
        let addrs = vec![
            sa(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
            sa(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080),
        ];
        let out = filter_loopback_addrs(addrs.clone()).unwrap();
        assert_eq!(out, addrs);
    }
}
```

## Checklist to run against the repo

- [ ] Every `TcpListener::bind` / `tokio::net::TcpListener::bind` / `axum::serve` / `hyper::Server::bind` site goes through `bind_loopback` (or equivalent) with the post-bind assertion.
- [ ] Every HTTP client targeting a local model/service uses `LoopbackResolver` (or the stack's equivalent) — no direct `TcpStream::connect("localhost:…")` bypass.
- [ ] IP-literal URLs are left alone; confirm no URL normalization rewrites them to hostnames.
- [ ] Positive run against a mock loopback server passes.
- [ ] Negative run with `localhost` → non-loopback fails closed with a clear message; `/etc/hosts` restored via `trap`.
- [ ] Unit tests cover empty, mixed, oversized, and all-loopback (v4+v6) answers.
- [ ] Bind-side negative test: `--host 0.0.0.0` exits non-zero.

If you paste the relevant bind sites and client construction code, I can point at the exact lines to change.

Source

English

Tools in this stack