diff --git a/apps/aether-gateway/src/execution_runtime/transport.rs b/apps/aether-gateway/src/execution_runtime/transport.rs index d4eb01785..d39f3c987 100644 --- a/apps/aether-gateway/src/execution_runtime/transport.rs +++ b/apps/aether-gateway/src/execution_runtime/transport.rs @@ -21,10 +21,7 @@ use aether_contracts::{ TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE, TRANSPORT_HTTP_MODE_HTTP1_ONLY, }; use aether_data::repository::proxy_nodes::ProxyNodeTrafficMutation; -use aether_http::{ - apply_http_client_config, is_https_or_loopback_http_url, is_private_or_reserved_ip, - HttpClientConfig, -}; +use aether_http::{apply_http_client_config, is_private_or_reserved_ip, HttpClientConfig}; use aether_runtime::{MetricKind, MetricSample}; use axum::body::Bytes; use base64::Engine as _; @@ -5173,11 +5170,6 @@ fn validate_execution_upstream_url( "upstream URL must not include a fragment".to_string(), )); } - if !is_https_or_loopback_http_url(&url) { - return Err(ExecutionRuntimeTransportError::UpstreamRequest( - "remote upstream URL must use HTTPS".to_string(), - )); - } let literal_ip = match url.host() { Some(url::Host::Ipv4(address)) => Some(IpAddr::V4(address)), Some(url::Host::Ipv6(address)) => Some(IpAddr::V6(address)), @@ -5389,9 +5381,13 @@ mod tests { const RELAY_TEST_SECRET: &str = "relay-test-secret-at-least-32-bytes"; #[test] - fn execution_upstream_url_requires_https_or_literal_loopback_http() { + fn execution_upstream_url_accepts_http_and_https_with_safe_targets() { for allowed in [ "https://api.example.test/v1/responses?api-version=1", + "http://api.example.test:8080/v1/responses?api-version=1", + "http://8.8.8.8:8080/v1/responses", + "https://8.8.8.8/v1/responses", + "http://[2606:4700:4700::1111]:8080/v1/responses", "http://localhost:8080/v1/responses", "http://127.42.0.1:8080/v1/responses", "http://[::1]:8080/v1/responses", @@ -5403,7 +5399,6 @@ mod tests { } for rejected in [ - "http://api.example.test/v1/responses", "http://10.0.0.1/v1/responses", "http://0.0.0.0:8080/v1/responses", "http://[::ffff:127.0.0.1]:8080/v1/responses", @@ -5411,6 +5406,8 @@ mod tests { "https://10.0.0.1:8443/v1/responses", "https://token@example.test/v1/responses", "https://example.test/v1/responses#secret", + "http://token@example.test/v1/responses", + "http://example.test/v1/responses#secret", "ftp://localhost/resource", ] { assert!( diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/transport.rs b/apps/aether-gateway/src/handlers/proxy/websocket/transport.rs index 4da39352d..8f08b80a9 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/transport.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/transport.rs @@ -157,16 +157,22 @@ pub(crate) fn websocket_upstream_url( return Err(invalid_code); } let websocket_scheme = match url.scheme() { - "https" => "wss", - "http" => "ws", - "wss" => return Ok(url), - "ws" if aether_http::url_has_literal_loopback_host(&url) => return Ok(url), - "ws" => return Err(invalid_code), + "https" | "wss" => "wss", + "http" | "ws" => "ws", _ => return Err(invalid_code), }; url.set_scheme(websocket_scheme).map_err(|_| invalid_code)?; - if url.scheme() == "ws" && !aether_http::url_has_literal_loopback_host(&url) { - return Err(invalid_code); + if url.scheme() == "ws" { + let literal_ip = match url.host() { + Some(url::Host::Ipv4(address)) => Some(std::net::IpAddr::V4(address)), + Some(url::Host::Ipv6(address)) => Some(std::net::IpAddr::V6(address)), + _ => None, + }; + if literal_ip.is_some_and(|address| { + aether_http::is_private_or_reserved_ip(address) && !address.is_loopback() + }) { + return Err(invalid_code); + } } Ok(url) } @@ -844,15 +850,17 @@ mod tests { #[test] fn maps_http_url_to_websocket_url_without_losing_path_or_query() { - let url = websocket_upstream_url( - "https://example.test/backend-api/codex/responses?x=1", - "invalid", - ) - .expect("URL should be converted"); - assert_eq!( - url.as_str(), - "wss://example.test/backend-api/codex/responses?x=1" - ); + for (http_scheme, websocket_scheme) in [("https", "wss"), ("http", "ws")] { + let url = websocket_upstream_url( + &format!("{http_scheme}://example.test:8080/backend-api/codex/responses?x=1"), + "invalid", + ) + .expect("URL should be converted"); + assert_eq!( + url.as_str(), + format!("{websocket_scheme}://example.test:8080/backend-api/codex/responses?x=1") + ); + } } #[test] @@ -861,10 +869,14 @@ mod tests { } #[test] - fn remote_websocket_requires_wss_but_loopback_ws_is_allowed() { + fn websocket_upstream_url_accepts_ws_and_wss_with_safe_targets() { for allowed in [ "wss://example.test/v1/responses", "https://example.test/v1/responses", + "ws://example.test:8080/v1/responses", + "http://example.test:8080/v1/responses", + "http://8.8.8.8:8080/v1/responses", + "ws://[2606:4700:4700::1111]:8080/v1/responses", "ws://localhost:8080/v1/responses", "http://127.42.0.1:8080/v1/responses", "ws://[::1]:8080/v1/responses", @@ -875,11 +887,14 @@ mod tests { ); } for rejected in [ - "ws://example.test/v1/responses", "http://10.0.0.1/v1/responses", "ws://0.0.0.0:8080/v1/responses", "ws://[::ffff:127.0.0.1]:8080/v1/responses", "wss://example.test/v1/responses#secret", + "ws://example.test/v1/responses#secret", + "http://token@example.test/v1/responses", + "ws://token@example.test/v1/responses", + "ftp://example.test/v1/responses", ] { assert!( websocket_upstream_url(rejected, "invalid").is_err(), diff --git a/apps/aether-gateway/src/handlers/public/catalog_helpers.rs b/apps/aether-gateway/src/handlers/public/catalog_helpers.rs index d14722f0b..0a7da3a38 100644 --- a/apps/aether-gateway/src/handlers/public/catalog_helpers.rs +++ b/apps/aether-gateway/src/handlers/public/catalog_helpers.rs @@ -129,9 +129,6 @@ pub(crate) fn normalize_admin_base_url(base_url: &str) -> Result if parsed.host_str().is_none() { return Err("base_url 必须包含有效主机".to_string()); } - if !aether_http::is_https_or_loopback_http_url(&parsed) { - return Err("base_url 必须使用 HTTPS;HTTP 仅允许字面量 loopback 主机".to_string()); - } if !parsed.username().is_empty() || parsed.password().is_some() { return Err("base_url 不允许包含用户名或密码".to_string()); } @@ -154,16 +151,42 @@ mod normalize_admin_base_url_tests { "https://user:password@api.example.test/v1", "https://api.example.test/v1?key=secret", "https://api.example.test/v1#secret", - "http://api.example.test/v1", - "http://10.0.0.1/v1", - "http://[::ffff:127.0.0.1]/v1", + "http://user:password@api.example.test/v1", + "http://api.example.test/v1?key=secret", + "http://api.example.test/v1#secret", + "ftp://api.example.test/v1", + "file:///v1", + "api.example.test/v1", + "", "https://", + "http://", "https://api.example.test:invalid/v1", ] { assert!(normalize_admin_base_url(value).is_err(), "accepted {value}"); } } + #[test] + fn endpoint_base_url_accepts_remote_http_hosts() { + for (raw_url, expected) in [ + ( + " HTTP://API.EXAMPLE.TEST:8080/v1/ ", + "http://api.example.test:8080/v1", + ), + ("http://8.8.8.8:8080/v1/", "http://8.8.8.8:8080/v1"), + ("http://10.0.0.1:8080/v1/", "http://10.0.0.1:8080/v1"), + ( + "http://[2606:4700:4700::1111]:8080/v1/", + "http://[2606:4700:4700::1111]:8080/v1", + ), + ] { + assert_eq!( + normalize_admin_base_url(raw_url).expect("HTTP base URL should be accepted"), + expected, + ); + } + } + #[test] fn endpoint_base_url_is_parsed_and_normalized() { assert_eq!( diff --git a/apps/aether-gateway/src/handlers/public/support/test_connection/route.rs b/apps/aether-gateway/src/handlers/public/support/test_connection/route.rs index 6d2772fe5..1259862c8 100644 --- a/apps/aether-gateway/src/handlers/public/support/test_connection/route.rs +++ b/apps/aether-gateway/src/handlers/public/support/test_connection/route.rs @@ -53,9 +53,6 @@ async fn resolve_test_connection_target( { return Err("provider endpoint must be an HTTP(S) URL without credentials or fragment"); } - if url.scheme() == "http" && !(allow_private_targets && literal_loopback) { - return Err("provider endpoint must use HTTPS"); - } let host = url .host_str() .ok_or("provider endpoint is missing a host")? @@ -574,10 +571,11 @@ mod tests { async fn test_connection_target_rejects_private_addresses_in_production_mode() { for raw_url in [ "http://127.0.0.1:8080/v1/chat/completions", + "http://10.0.0.1/v1/chat/completions", + "http://169.254.169.254/v1/chat/completions", "https://10.0.0.1/v1/chat/completions", "https://[::1]/v1/chat/completions", "https://localhost/v1/chat/completions", - "http://8.8.8.8/v1/chat/completions", ] { assert!( resolve_test_connection_target(raw_url, false) @@ -588,6 +586,26 @@ mod tests { } } + #[tokio::test] + async fn test_connection_target_accepts_public_http_and_https_addresses() { + for allow_private_targets in [false, true] { + for (raw_url, expected_port) in [ + ("http://8.8.8.8/v1/chat", 80), + ("http://8.8.8.8:8080/v1/chat", 8080), + ("https://8.8.8.8/v1/chat", 443), + ] { + let target = resolve_test_connection_target(raw_url, allow_private_targets) + .await + .expect("public HTTP(S) provider target should resolve"); + assert_eq!(target.url.as_str(), raw_url); + assert_eq!(target.host, "8.8.8.8"); + assert_eq!(target.addresses.len(), 1); + assert_eq!(target.addresses[0].ip().to_string(), "8.8.8.8"); + assert_eq!(target.addresses[0].port(), expected_port); + } + } + } + #[tokio::test] async fn test_connection_target_allows_loopback_only_for_test_fixtures() { let target = resolve_test_connection_target("http://127.0.0.1:8080/v1/chat", true) @@ -596,10 +614,10 @@ mod tests { assert_eq!(target.host, "127.0.0.1"); assert_eq!(target.addresses.len(), 1); assert!( - resolve_test_connection_target("http://8.8.8.8/v1/chat", true) + resolve_test_connection_target("http://10.0.0.1/v1/chat", true) .await .is_err(), - "test mode must not make cleartext public endpoints acceptable" + "test mode must not make private non-loopback HTTP endpoints acceptable" ); assert!( resolve_test_connection_target("https://10.0.0.1/v1/chat", true) @@ -620,6 +638,9 @@ mod tests { for raw_url in [ "https://user:pass@example.com/v1/chat", "https://example.com/v1/chat#fragment", + "http://user:pass@example.com/v1/chat", + "http://example.com/v1/chat#fragment", + "ftp://example.com/v1/chat", ] { assert!( resolve_test_connection_target(raw_url, false) diff --git a/apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs b/apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs index a97d67492..bb251794d 100644 --- a/apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs +++ b/apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs @@ -479,7 +479,7 @@ async fn gateway_returns_service_unavailable_for_admin_provider_endpoint_create_ } #[tokio::test] -async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_principal() { +async fn gateway_creates_admin_http_provider_endpoint_locally_with_trusted_admin_principal() { let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits_clone = Arc::clone(&upstream_hits); let upstream = Router::new().route( @@ -522,7 +522,7 @@ async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_prin .json(&json!({ "provider_id": "provider-openai", "api_format": "openai:chat", - "base_url": "https://api.openai.example/", + "base_url": "http://api.openai.example:8080/", "custom_path": "/v1/chat/completions", "max_retries": 5, "config": {"foo": "bar"}, @@ -537,7 +537,7 @@ async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_prin assert_eq!(payload["provider_id"], "provider-openai"); assert_eq!(payload["provider_name"], "openai"); assert_eq!(payload["api_format"], "openai:chat"); - assert_eq!(payload["base_url"], "https://api.openai.example"); + assert_eq!(payload["base_url"], "http://api.openai.example:8080"); assert_eq!(payload["custom_path"], "/v1/chat/completions"); assert_eq!(payload["max_retries"], 5); assert_eq!(payload["total_keys"], 0); @@ -553,7 +553,7 @@ async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_prin assert_eq!(endpoints.len(), 1); assert_eq!(endpoints[0].provider_id, "provider-openai"); assert_eq!(endpoints[0].api_format, "openai:chat"); - assert_eq!(endpoints[0].base_url, "https://api.openai.example"); + assert_eq!(endpoints[0].base_url, "http://api.openai.example:8080"); assert_eq!(endpoints[0].max_retries, Some(5)); gateway_handle.abort(); @@ -658,7 +658,7 @@ async fn gateway_rejects_streaming_policy_for_search_endpoint_before_catalog_wri } #[tokio::test] -async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_principal() { +async fn gateway_updates_admin_http_provider_endpoint_locally_with_trusted_admin_principal() { let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits_clone = Arc::clone(&upstream_hits); let upstream = Router::new().route( @@ -720,7 +720,7 @@ async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_prin .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") .json(&json!({ - "base_url": "https://updated.openai.example/", + "base_url": "http://updated.openai.example:8080/", "custom_path": "/v1/responses", "max_retries": 5, "is_active": false, @@ -736,7 +736,7 @@ async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_prin assert_eq!(payload["id"], "endpoint-openai-chat"); assert_eq!(payload["provider_id"], "provider-openai"); assert_eq!(payload["api_format"], "openai:chat"); - assert_eq!(payload["base_url"], "https://updated.openai.example"); + assert_eq!(payload["base_url"], "http://updated.openai.example:8080"); assert_eq!(payload["custom_path"], "/v1/responses"); assert_eq!(payload["max_retries"], 5); assert_eq!(payload["is_active"], false); @@ -751,7 +751,7 @@ async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_prin .await .expect("endpoints should read"); assert_eq!(endpoints.len(), 1); - assert_eq!(endpoints[0].base_url, "https://updated.openai.example"); + assert_eq!(endpoints[0].base_url, "http://updated.openai.example:8080"); assert_eq!(endpoints[0].custom_path.as_deref(), Some("/v1/responses")); assert_eq!(endpoints[0].max_retries, Some(5)); assert!(!endpoints[0].is_active);