@@ -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+ }
0 commit comments