mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
fix(gateway): restore HTTP and WS upstream support
This commit is contained in:
@@ -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://[email protected]/v1/responses",
|
||||
"ws://[email protected]/v1/responses",
|
||||
"ftp://example.test/v1/responses",
|
||||
] {
|
||||
assert!(
|
||||
websocket_upstream_url(rejected, "invalid").is_err(),
|
||||
|
||||
@@ -129,9 +129,6 @@ pub(crate) fn normalize_admin_base_url(base_url: &str) -> Result<String, String>
|
||||
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:[email protected]/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!(
|
||||
|
||||
@@ -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:[email protected]/v1/chat",
|
||||
"https://example.com/v1/chat#fragment",
|
||||
"http://user:[email protected]/v1/chat",
|
||||
"http://example.com/v1/chat#fragment",
|
||||
"ftp://example.com/v1/chat",
|
||||
] {
|
||||
assert!(
|
||||
resolve_test_connection_target(raw_url, false)
|
||||
|
||||
Reference in New Issue
Block a user