Skip to content

Commit 795c44f

Browse files
authored
agent: Sandbox DNS rebinding and redirect cleanup (#60941)
Tidies up the host resolution code a bit and closes some small gaps --- Release Notes: - N/A or Added/Fixed/Improved ...
1 parent af7de9a commit 795c44f

4 files changed

Lines changed: 404 additions & 100 deletions

File tree

crates/agent/src/tools/fetch_tool.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,31 @@ impl FetchTool {
166166
}
167167
}
168168

169+
/// Resolve the host of `url` and confirm it doesn't point into loopback /
170+
/// private / link-local space, applying the same forbidden-IP policy the
171+
/// terminal sandbox's proxy uses. Returns an error (including "resolves only to
172+
/// forbidden addresses") that aborts the fetch.
173+
///
174+
/// DNS resolution blocks, so callers should run this off the foreground thread.
175+
/// See the caller for why this is a gate rather than a full resolve-to-connect
176+
/// pin.
177+
fn verify_host_not_forbidden(url: &str) -> Result<()> {
178+
let normalized = normalize_url(url);
179+
let parsed =
180+
url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?;
181+
let host = parsed
182+
.host_str()
183+
.with_context(|| format!("URL {url:?} has no host to reach"))?;
184+
// Default to the scheme's port when the URL omits one; resolution needs a
185+
// port but the value doesn't affect which IPs a host resolves to.
186+
let port = parsed
187+
.port_or_known_default()
188+
.unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 });
189+
190+
http_proxy::PinnedHost::resolve(host, port).map(|_pinned| ())?;
191+
Ok(())
192+
}
193+
169194
/// Extracts the host from a fetch URL as a [`http_proxy::HostPattern`] so it can
170195
/// be matched against the shared network grants. Mirrors the scheme handling in
171196
/// [`normalize_url`] (defaulting to `https://` when none is given).
@@ -276,6 +301,32 @@ impl AgentTool for FetchTool {
276301
return Err("Fetch cancelled by user".to_string());
277302
}
278303
};
304+
305+
// Authorizing the *hostname* is not enough: a granted host
306+
// (or a redirect to one) whose DNS points into loopback /
307+
// private / link-local space would otherwise let the model
308+
// reach the local machine or LAN (SSRF / DNS rebinding).
309+
// Resolve and vet the host now, applying the same
310+
// forbidden-IP policy the terminal sandbox's proxy enforces.
311+
//
312+
// NOTE: this is a gate, not a full pin. `HttpClientWithUrl`
313+
// resolves the hostname again when it connects, so a DNS
314+
// answer that flips between this check and that connect could
315+
// still slip through. Closing that residual window would
316+
// require the HTTP client to connect to a pre-vetted IP
317+
// (`PinnedHost::socket_addrs`) rather than re-resolving;
318+
// until then this blocks the realistic case of a stably
319+
// resolving host that points at forbidden space.
320+
let verify_task = cx.background_spawn({
321+
let url = current_url.clone();
322+
async move { verify_host_not_forbidden(&url) }
323+
});
324+
futures::select! {
325+
result = verify_task.fuse() => result.map_err(|e| e.to_string())?,
326+
_ = event_stream.cancelled_by_user().fuse() => {
327+
return Err("Fetch cancelled by user".to_string());
328+
}
329+
};
279330
}
280331

281332
let fetch_task = cx.background_spawn({
@@ -312,3 +363,45 @@ impl AgentTool for FetchTool {
312363
})
313364
}
314365
}
366+
367+
#[cfg(test)]
368+
mod tests {
369+
use super::*;
370+
371+
// These use IP-literal URLs, which "resolve" to themselves, so the SSRF gate
372+
// is exercised without depending on real DNS. IP literals can't be *granted*
373+
// network access (that's a separate, earlier check), but they can be the
374+
// target a granted hostname redirects to or resolves into — which is exactly
375+
// the case this gate defends.
376+
377+
#[test]
378+
fn verify_host_rejects_loopback_literal() {
379+
let error = verify_host_not_forbidden("http://127.0.0.1/internal")
380+
.expect_err("loopback must be refused");
381+
assert!(
382+
error.to_string().contains("loopback"),
383+
"error should explain the forbidden range, got: {error}"
384+
);
385+
}
386+
387+
#[test]
388+
fn verify_host_rejects_private_and_metadata_literals() {
389+
for url in [
390+
"http://10.0.0.5/",
391+
"https://192.168.1.1/",
392+
"http://169.254.169.254/latest/meta-data/", // cloud metadata
393+
"http://[::1]/",
394+
] {
395+
assert!(
396+
verify_host_not_forbidden(url).is_err(),
397+
"expected {url} to be refused as a forbidden destination"
398+
);
399+
}
400+
}
401+
402+
#[test]
403+
fn verify_host_allows_public_literal() {
404+
verify_host_not_forbidden("https://93.184.215.14/")
405+
.expect("a public address must be allowed through the gate");
406+
}
407+
}

crates/http_proxy/src/http_proxy.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@
4747
//! would see from a direct network failure, no proxy fingerprint.
4848
4949
mod allowlist;
50+
mod pinned_host;
5051
mod proxy;
5152

5253
pub use allowlist::{Allowlist, HostPattern, HostPatternError};
54+
pub use pinned_host::{PinnedHost, PinnedHostError, is_forbidden_ip};
5355
pub use proxy::{
5456
DenyReason, ProxyConfig, ProxyEvent, ProxyHandle, RequestMethod, RequestOutcome, UpstreamProxy,
5557
};

0 commit comments

Comments
 (0)