@@ -1174,6 +1174,9 @@ fn is_internal_ip(ip: IpAddr) -> bool {
11741174///
11751175/// Returns the resolved `SocketAddr` list on success. Returns an error string
11761176/// if any resolved IP is in an internal range or if DNS resolution fails.
1177+ ///
1178+ /// Special case: `host.docker.internal` is allowed to resolve to internal IPs
1179+ /// (e.g., Docker bridge 172.17.0.1) to enable local inference scenarios.
11771180async fn resolve_and_reject_internal (
11781181 host : & str ,
11791182 port : u16 ,
@@ -1189,6 +1192,21 @@ async fn resolve_and_reject_internal(
11891192 ) ) ;
11901193 }
11911194
1195+ // Special case: allow host.docker.internal to resolve to internal IPs
1196+ // This enables local vLLM/Ollama inference from within the sandbox
1197+ if host == "host.docker.internal" {
1198+ // Still block loopback and link-local for security
1199+ for addr in & addrs {
1200+ if is_always_blocked_ip ( addr. ip ( ) ) {
1201+ return Err ( format ! (
1202+ "{host} resolves to always-blocked address {}, connection rejected" ,
1203+ addr. ip( )
1204+ ) ) ;
1205+ }
1206+ }
1207+ return Ok ( addrs) ;
1208+ }
1209+
11921210 for addr in & addrs {
11931211 if is_internal_ip ( addr. ip ( ) ) {
11941212 return Err ( format ! (
@@ -1759,11 +1777,21 @@ async fn handle_forward_proxy(
17591777 match resolve_and_reject_internal ( & host, port) . await {
17601778 Ok ( addrs) => addrs,
17611779 Err ( reason) => {
1780+ // Provide helpful hint for common local inference scenarios
1781+ let hint = if host_lc. contains ( "localhost" ) || host_lc. contains ( "127.0.0.1" ) {
1782+ " For local inference, use host.docker.internal instead of localhost."
1783+ } else if host_lc. contains ( "host.docker.internal" ) {
1784+ " This should work - please report this issue."
1785+ } else {
1786+ " To allow internal endpoints, add 'allowed_ips' to your network policy."
1787+ } ;
1788+
17621789 warn ! (
17631790 dst_host = %host_lc,
17641791 dst_port = port,
17651792 reason = %reason,
1766- "FORWARD blocked: internal IP without allowed_ips"
1793+ "FORWARD blocked: internal IP without allowed_ips.{}" ,
1794+ hint
17671795 ) ;
17681796 emit_denial_simple (
17691797 denial_tx,
@@ -2595,4 +2623,54 @@ mod tests {
25952623 "expected 'always-blocked' in error: {err}"
25962624 ) ;
25972625 }
2626+
2627+ // --- host.docker.internal special case tests ---
2628+
2629+ #[ test]
2630+ fn test_host_docker_internal_logic_allows_private_ranges ( ) {
2631+ // Test that the special-case logic for host.docker.internal would allow
2632+ // Docker bridge IPs (172.17.x.x, 172.18.x.x, etc.)
2633+ // We test the is_internal_ip function directly since we can't resolve
2634+ // host.docker.internal outside of Docker
2635+ let docker_bridge = IpAddr :: V4 ( Ipv4Addr :: new ( 172 , 17 , 0 , 1 ) ) ;
2636+ assert ! (
2637+ is_internal_ip( docker_bridge) ,
2638+ "Docker bridge is considered internal"
2639+ ) ;
2640+
2641+ // The fix allows host.docker.internal to bypass is_internal_ip check
2642+ // This test documents that the IP ranges are correct
2643+ let docker_bridge_2 = IpAddr :: V4 ( Ipv4Addr :: new ( 172 , 18 , 0 , 1 ) ) ;
2644+ assert ! ( is_internal_ip( docker_bridge_2) ) ;
2645+
2646+ let local_network = IpAddr :: V4 ( Ipv4Addr :: new ( 192 , 168 , 1 , 1 ) ) ;
2647+ assert ! ( is_internal_ip( local_network) ) ;
2648+ }
2649+
2650+ #[ tokio:: test]
2651+ async fn test_host_docker_internal_blocks_loopback ( ) {
2652+ // Even host.docker.internal should block loopback addresses
2653+ // This test documents the security boundary
2654+ let result = resolve_and_reject_internal ( "localhost" , 8000 ) . await ;
2655+ assert ! (
2656+ result. is_err( ) ,
2657+ "localhost should be rejected even for local inference"
2658+ ) ;
2659+ let err = result. unwrap_err ( ) ;
2660+ assert ! (
2661+ err. contains( "internal address" ) || err. contains( "always-blocked" ) ,
2662+ "expected internal/always-blocked in error: {err}"
2663+ ) ;
2664+ }
2665+
2666+ #[ tokio:: test]
2667+ async fn test_public_ip_allowed_without_allowed_ips ( ) {
2668+ // Public IPs should be allowed without needing allowed_ips
2669+ // Use a well-known public DNS that should always resolve
2670+ let result = resolve_and_reject_internal ( "8.8.8.8" , 53 ) . await ;
2671+ assert ! (
2672+ result. is_ok( ) ,
2673+ "Public IP (8.8.8.8) should be allowed: {result:?}"
2674+ ) ;
2675+ }
25982676}
0 commit comments