diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index 9e907bbf1..87c8355ee 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -145,7 +145,16 @@ impl Default for CodexApiConfig { impl CodexApiConfig { fn validate(&self) -> Result<(), OpenAiConfigError> { - if !crate::transport::valid_upstream_base_url(&self.base_url) { + let url = crate::transport::parse_upstream_base_url(&self.base_url) + .ok_or(OpenAiConfigError::InvalidField("openai.api.base_url"))?; + // OAuth 启动地址的 HTTP 例外仅用于本机联调,不沿用 API Key 中转账号的传输选择 + let loopback = match url.host() { + Some(url::Host::Domain("localhost")) => true, + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + _ => false, + }; + if url.scheme() == "http" && !loopback { return Err(OpenAiConfigError::InvalidField("openai.api.base_url")); } Ok(()) diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index b13abf925..7ff697fb3 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -1484,7 +1484,7 @@ fn parse_api_key_import(value: &Value) -> Result bool { self.base_url.len() <= 2048 && !self.base_url.chars().any(char::is_control) - && crate::transport::valid_upstream_base_url(&self.base_url) + && crate::transport::parse_upstream_base_url(&self.base_url).is_some() } } diff --git a/backend/crates/providers/openai/src/transport/endpoints.rs b/backend/crates/providers/openai/src/transport/endpoints.rs index a307f241c..a36cb154a 100644 --- a/backend/crates/providers/openai/src/transport/endpoints.rs +++ b/backend/crates/providers/openai/src/transport/endpoints.rs @@ -31,22 +31,16 @@ pub fn endpoint_url(base_url: &str, endpoint_path: &str) -> String { ) } -/// 上游启动配置与账号地址共用同一校验,HTTP 仅允许本机联调 -pub(crate) fn valid_upstream_base_url(value: &str) -> bool { - let Ok(url) = url::Url::parse(value) else { - return false; - }; - let loopback = match url.host() { - Some(url::Host::Domain("localhost")) => true, - Some(url::Host::Ipv4(address)) => address.is_loopback(), - Some(url::Host::Ipv6(address)) => address.is_loopback(), - _ => false, - }; - ((url.scheme() == "https" && url.host_str().is_some()) || (url.scheme() == "http" && loopback)) +/// 共用上游地址格式校验;启动配置可在解析结果上施加更严格的传输约束 +pub(crate) fn parse_upstream_base_url(value: &str) -> Option { + let url = url::Url::parse(value).ok()?; + (matches!(url.scheme(), "https" | "http") + && url.host_str().is_some() && url.username().is_empty() && url.password().is_none() && url.query().is_none() - && url.fragment().is_none() + && url.fragment().is_none()) + .then_some(url) } /// 返回与 base path 对应的唯一 usage endpoint diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index 5aec44d08..cfe57ea97 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -21,7 +21,7 @@ mod response_meta; pub(crate) mod session; pub mod subscription; pub(crate) use downstream::normalize_selected_codex_downstream_body; -pub(crate) use endpoints::valid_upstream_base_url; +pub(crate) use endpoints::parse_upstream_base_url; pub mod tls; pub mod usage; pub mod websocket; diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index c63a666f2..ddd7ba99a 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -2600,30 +2600,34 @@ async fn api_key_admin_exposes_only_configuration_and_preserves_key_when_rotatin Some(&json!("https://first.example/v1")) ); assert!(!configuration.contains_key("api_key")); - let prepared = admin - .prepare_rotation(PrepareCredentialRotation { - account: account_record(&account), - provider_material: ProviderDocument::new(OpaqueProviderData::new( - json!({"base_url":"https://second.example/root", "transport":"prefer_websocket"}) - .as_object() - .unwrap() - .clone(), - )), - }) - .await - .unwrap(); - let material = prepared - .facts() - .provider_material - .expose_to_provider() - .expose_to_provider(); - assert_eq!(material.get("api_key"), Some(&json!("sk-api-test-only"))); - assert_eq!( - material.get("base_url"), - Some(&json!("https://second.example/root")) - ); - assert!(!prepared.facts().has_refresh_token); - assert_eq!(prepared.facts().account_id, *account.id()); + for base_url in [ + "https://second.example/root", + "http://10.0.0.7:8080/v1", + "http://[fd00::7]:8080/v1", + "http://relay:8080/v1", + ] { + let prepared = admin + .prepare_rotation(PrepareCredentialRotation { + account: account_record(&account), + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"base_url":base_url, "transport":"prefer_websocket"}) + .as_object() + .unwrap() + .clone(), + )), + }) + .await + .unwrap(); + let material = prepared + .facts() + .provider_material + .expose_to_provider() + .expose_to_provider(); + assert_eq!(material.get("api_key"), Some(&json!("sk-api-test-only"))); + assert_eq!(material.get("base_url"), Some(&json!(base_url))); + assert!(!prepared.facts().has_refresh_token); + assert_eq!(prepared.facts().account_id, *account.id()); + } assert_eq!( admin .prepare_refresh(PrepareCredentialRefresh { diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 67c7714d4..e280441d3 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -943,50 +943,73 @@ async fn api_key_import_export_preserves_target_without_oauth_exchange() { runtime_policy(), Arc::new(crate::RecordingDiagnostics::default()), ); - let imported = service.prepare_import_document_with_proxy(serde_json::json!({ - "provider": "openai", "authentication_kind": "api_key", "name": "relay", "base_url": "https://relay.example/custom/v2", "api_key": "sk-test-only" - }), None).await.expect("import API account").into_accounts().pop().expect("one account"); - assert_eq!(imported.account.authentication_kind(), "api_key"); - assert!(!imported.account.has_refresh_token()); - assert_eq!(imported.account.upstream_account_id(), None); - assert!( - !format!( - "{:?}", - CodexCredentialCodec::decode(&imported.credential).unwrap() - ) - .contains("sk-test-only") - ); - let now = Utc::now(); - let document = CodexCredentialAdmin - .format_cpr_export(vec![ExportManagedCodexCredential { - current: LoadedCredential { - account: imported.account, - credential: imported.credential, - }, - added_at: now, - updated_at: now, - }]) - .expect("export") - .into_json() - .expect("JSON"); - let restored = service - .prepare_import_document_with_proxy(document, None) - .await - .expect("reimport") - .into_accounts() - .pop() - .unwrap(); - let CodexCredentialData::ApiKey(data) = - CodexCredentialCodec::decode_complete(&restored.credential).unwrap() - else { - panic!("API credential") - }; - assert_eq!(data.base_url, "https://relay.example/custom/v2"); - assert_eq!(data.api_key, "sk-test-only"); - assert_eq!( - data.transport, - provider_openai::credential::ResponsesTransport::Http - ); + for base_url in [ + "https://relay.example/custom/v2", + "http://10.0.0.7:8080/v1", + "http://172.18.0.2:8080/v1", + "http://192.168.1.10:8080/v1", + "http://[fd00::7]:8080/v1", + "http://relay:8080/v1", + "http://remote.example/v1", + ] { + let imported = service + .prepare_import_document_with_proxy( + serde_json::json!({ + "provider": "openai", + "authentication_kind": "api_key", + "name": "relay", + "base_url": base_url, + "api_key": "sk-test-only" + }), + None, + ) + .await + .expect("import API account") + .into_accounts() + .pop() + .expect("one account"); + assert_eq!(imported.account.authentication_kind(), "api_key"); + assert!(!imported.account.has_refresh_token()); + assert_eq!(imported.account.upstream_account_id(), None); + assert!( + !format!( + "{:?}", + CodexCredentialCodec::decode(&imported.credential).unwrap() + ) + .contains("sk-test-only") + ); + let now = Utc::now(); + let document = CodexCredentialAdmin + .format_cpr_export(vec![ExportManagedCodexCredential { + current: LoadedCredential { + account: imported.account, + credential: imported.credential, + }, + added_at: now, + updated_at: now, + }]) + .expect("export") + .into_json() + .expect("JSON"); + let restored = service + .prepare_import_document_with_proxy(document, None) + .await + .expect("reimport") + .into_accounts() + .pop() + .unwrap(); + let CodexCredentialData::ApiKey(data) = + CodexCredentialCodec::decode_complete(&restored.credential).unwrap() + else { + panic!("API credential") + }; + assert_eq!(data.base_url, base_url); + assert_eq!(data.api_key, "sk-test-only"); + assert_eq!( + data.transport, + provider_openai::credential::ResponsesTransport::Http + ); + } } #[tokio::test] @@ -998,7 +1021,10 @@ async fn api_key_import_rejects_unsafe_urls_and_empty_keys() { Arc::new(crate::RecordingDiagnostics::default()), ); for (url, key) in [ - ("http://remote.example/v1", "sk-test"), + ("ftp://remote.example/v1", "sk-test"), + ("http://user:password@relay:8080/v1", "sk-test"), + ("http://relay:8080/v1?token=secret", "sk-test"), + ("http://relay:8080/v1#fragment", "sk-test"), ("https://user:password@example.com", "sk-test"), ("https://example.com?token=secret", "sk-test"), ("https://example.com/#fragment", "sk-test"), @@ -1025,6 +1051,9 @@ async fn sub2api_api_key_import_preserves_versioned_and_explicit_responses_paths ); for (base, expected) in [ ("https://example.com", "https://example.com/v1"), + ("http://relay:8080", "http://relay:8080/v1"), + ("http://10.0.0.7:8080/v1", "http://10.0.0.7:8080/v1"), + ("http://relay:8080/v1/responses", "http://relay:8080/v1"), ( "https://example.com/custom/v4", "https://example.com/custom/v4", diff --git a/docs/api.md b/docs/api.md index ba202f8c4..9d23782d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -887,7 +887,8 @@ API Key 账号使用以下独立凭据形态: `base_url` 是 API 前缀,追加 `responses`、`models`、`images/generations`、`images/edits` 或 `alpha/search`, 不会自动补 `/v1`;支持根路径和自定义前缀。 -地址仅允许 HTTPS,HTTP 只允许本机回环;拒绝 URL 中的认证信息、查询串和 fragment。 +地址支持 HTTP 和 HTTPS,包括内网 IP 与容器名,拒绝 URL 中的认证信息、查询串和 fragment。 +HTTP 会明文传输 API Key 和请求内容,仅用于可信网络。 `transport` 可省略(默认 `http`)或设为 `prefer_websocket`。API Key 使用 Bearer 认证与普通 JSON, 不携带 OAuth Cookie 或 ChatGPT 身份。上游模型列表使用标准 `/models` 格式,按账号和凭据版本隔离; 目录用于模型发现,不作为能力白名单,未列出的模型仍交由上游判断。 diff --git a/frontend/src/views/accounts/utils/upstreamApiKey.ts b/frontend/src/views/accounts/utils/upstreamApiKey.ts index 3eb9f2686..89ec320b0 100644 --- a/frontend/src/views/accounts/utils/upstreamApiKey.ts +++ b/frontend/src/views/accounts/utils/upstreamApiKey.ts @@ -30,9 +30,8 @@ export function apiKeyAccountError(form: ApiKeyAccountForm, editing = false): st return '上游 API 地址不能超过 2048 个字符' try { const url = new URL(form.base_url) - const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(url.hostname) - if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash) - return '请输入不含认证、查询参数或片段的 HTTPS 地址' + if (!['https:', 'http:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) + return '请输入不含认证、查询参数或片段的 HTTP 或 HTTPS 地址' } catch { return '请输入完整的上游 API 地址'