diff --git a/docs/MCP.md b/docs/MCP.md index 24ba65531..b0ef1bddd 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -99,6 +99,21 @@ authorization; users should not copy or expose that credential. Independent/netw accessible Servers remain off by default and must not use this as a substitute for OAuth. + +When a loopback-only Desktop Server is behind a local HTTP proxy and OpenAI +attachment domains resolve to a proxy-managed fake IP, direct TLS connections +may fail even after the trusted tunnel's fake-IP admission succeeds. Operators +may opt in to the **OpenAI host-file import only** proxy by setting +`WEBCODEX_OPENAI_FILE_IMPORT_PROXY=http://127.0.0.1:PORT` in the Server +environment and restarting the Server. IPv6 `http://[::1]:PORT` is also +accepted. The setting defaults to off and is ignored for ordinary trusted MCP +URLs and remote authenticated imports; it requires an explicitly trusted +loopback MCP source and an allowlisted OpenAI attachment hostname. TLS server +certificate verification and redirect rejection remain enabled. The HTTP +CONNECT proxy chooses the upstream address instead of WebCodex's directly +pinned DNS addresses, so use only a proxy you control and trust; no remote +proxy endpoints, credentials, or hostnames are accepted as proxy settings. + For a regular independent Windows Server + Runner reached through OpenAI Tunnel, or to troubleshoot a case where local MCP is healthy but ChatGPT Connector creation still fails, see the [Windows + OpenAI Secure MCP Tunnel deep dive](WINDOWS_OPENAI_TUNNEL.md). It is advanced setup/troubleshooting material, not required reading for a first-time user. ## Result cards diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index 15570e4d1..7b1325cce 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -296,6 +296,17 @@ credential 认证的 ChatGPT host-file rewrite。WebCodex Desktop 自 v0.4.2 起 该 credential,并只把它注入原生 Tunnel 的固定本地 Authorization;用户不应复制或暴露该 credential。独立/network-accessible Server 仍默认关闭,不应使用它替代 OAuth。 + +如果仅监听 loopback 的 Desktop Server 通过本地 HTTP 代理访问外网,并且 OpenAI +附件域名被代理 DNS 解析成 Fake-IP,可信 tunnel 已通过 Fake-IP 准入检查后, +直接建立 TLS 连接仍可能失败。运维者可以在 Server 环境中显式设置 +`WEBCODEX_OPENAI_FILE_IMPORT_PROXY=http://127.0.0.1:PORT`,然后重启 Server; +也支持 `http://[::1]:PORT`。该配置默认关闭,只作用于可信本机 MCP 来源的 +OpenAI 附件域名,不会代理普通 trusted MCP URL 或远程认证导入。 +TLS 证书验证和禁止重定向仍然生效。不过,HTTP CONNECT 代理会自行选择上游地址, +不会使用 WebCodex 直连模式固定的 DNS 地址,因此只能配置自己控制且信任的本机代理。 +配置不接受远程代理地址、带凭据的 URL 或主机名形式的代理端点。 + 如果在 Windows 上使用普通独立 Server + Runner 并通过 OpenAI Tunnel 接入,或排查“本地 MCP 正常但 ChatGPT Connector 创建失败”的情况,见 [Windows + OpenAI Secure MCP Tunnel 深入实操](WINDOWS_OPENAI_TUNNEL.zh-CN.md)。它是深入配置/排障文档,不是普通用户第一次必须阅读的教程。 ## 结果卡片 diff --git a/src/mcp_tests/file_import.rs b/src/mcp_tests/file_import.rs index a82041928..50aa49aac 100644 --- a/src/mcp_tests/file_import.rs +++ b/src/mcp_tests/file_import.rs @@ -185,6 +185,47 @@ async fn start_mcp_import_mock_server(response: Vec) -> McpImportMockServer } } +#[tokio::test] +async fn mcp_import_loopback_proxy_sends_https_connect() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("local test listener"); + let local_port = listener.local_addr().unwrap().port(); + let client = crate::tool_runtime::conversation_import::build_loopback_proxy_client_for_test( + &format!("http://127.0.0.1:{local_port}"), + ) + .expect("client must accept an explicit loopback proxy"); + let probe = tokio::spawn(async move { + let (mut connection, _) = listener.accept().await.expect("CONNECT should arrive"); + let mut buf = [0u8; 2048]; + let count = connection + .read(&mut buf) + .await + .expect("read CONNECT request"); + let request = String::from_utf8_lossy(&buf[..count]); + assert!( + request.starts_with("CONNECT files.oaiusercontent.com:443 HTTP/1.1"), + "unexpected CONNECT target" + ); + connection + .write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n") + .await + .expect("reply to CONNECT"); + }); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.get("https://files.oaiusercontent.com/file").send(), + ) + .await + .expect("the mocked proxy must respond promptly"); + tokio::time::timeout(std::time::Duration::from_secs(5), probe) + .await + .expect("the local proxy was not contacted") + .expect("proxy probe failed"); +} + fn mcp_import_config(trusted_client_ids: &[&str]) -> Arc { let mut config = (*test_config_oauth2(Some("secret"))).clone(); config.oauth2.trusted_mcp_file_client_ids = trusted_client_ids diff --git a/src/tool_runtime/conversation_import.rs b/src/tool_runtime/conversation_import.rs index 00c21dfcb..b6d0818ed 100644 --- a/src/tool_runtime/conversation_import.rs +++ b/src/tool_runtime/conversation_import.rs @@ -392,13 +392,97 @@ async fn validate_trusted_mcp_download_url( }) } +const LOOPBACK_OPENAI_FILE_PROXY_ENV: &str = "WEBCODEX_OPENAI_FILE_IMPORT_PROXY"; + +fn parse_loopback_openai_file_proxy(raw: &str) -> Result { + let proxy = reqwest::Url::parse(raw) + .map_err(|_| "OpenAI file proxy must be a valid loopback HTTP URL".to_string())?; + let loopback = match proxy.host() { + Some(url::Host::Ipv4(ip)) => ip == Ipv4Addr::LOCALHOST, + Some(url::Host::Ipv6(ip)) => ip == Ipv6Addr::LOCALHOST, + _ => false, + }; + // url::Url normalizes explicit HTTP :80 to no stored port, so inspect + // the original authority to distinguish :80 from an omitted port. + let explicit_port = raw + .split_once("://") + .and_then(|(_, rest)| rest.split(['/', '?', '#']).next()) + .and_then(|authority| authority.rsplit_once(':')) + .and_then(|(_, port)| port.parse::().ok()); + if proxy.scheme() != "http" + || !loopback + || explicit_port.is_none() + || !proxy.username().is_empty() + || proxy.password().is_some() + || proxy.path() != "/" + || proxy.query().is_some() + || proxy.fragment().is_some() + { + return Err( + "OpenAI file proxy must be http://127.0.0.1:PORT or http://[::1]:PORT".to_string(), + ); + } + Ok(proxy) +} + +fn select_loopback_openai_file_proxy( + target: &TrustedDownloadTarget, + policy: ConversationImportDownloadPolicy, + value: Option<&str>, +) -> Result, String> { + if policy != ConversationImportDownloadPolicy::LoopbackTrustedMcpHostFile { + return Ok(None); + } + let Some(value) = value.filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if !target + .resolver_host + .as_deref() + .is_some_and(is_openai_download_host) + { + // Preserve the normal direct-download path for unrelated trusted MCP + // URLs even when the operator enables the OpenAI-specific proxy. + return Ok(None); + } + Ok(Some(parse_loopback_openai_file_proxy(value)?)) +} + +fn configured_loopback_openai_file_proxy( + target: &TrustedDownloadTarget, + policy: ConversationImportDownloadPolicy, +) -> Result, String> { + // Never proxy arbitrary trusted-MCP URLs or remote OAuth imports. The + // explicit local proxy is an opt-in trust boundary for OpenAI attachments + // on the local Desktop/Secure-Tunnel session only. + if policy != ConversationImportDownloadPolicy::LoopbackTrustedMcpHostFile { + return Ok(None); + } + let value = match std::env::var(LOOPBACK_OPENAI_FILE_PROXY_ENV) { + Ok(value) => value, + Err(std::env::VarError::NotPresent) => return Ok(None), + Err(_) => return Err("OpenAI file proxy setting is not valid UTF-8".to_string()), + }; + select_loopback_openai_file_proxy(target, policy, Some(&value)) +} + fn build_download_client( trusted_target: Option<&TrustedDownloadTarget>, + local_proxy: Option<&reqwest::Url>, ) -> Result { let mut builder = reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()); - if let Some(target) = trusted_target { + if let Some(proxy) = local_proxy { + // An HTTP CONNECT proxy cannot guarantee that the proxy-selected + // upstream IP equals the prevalidated DNS answer. Restrict this opt-in + // path to an explicit loopback proxy, authenticated local-session + // provenance, and allowlisted OpenAI hosts; HTTPS verification remains + // enabled. The default direct path retains DNS address pinning. + let proxy = reqwest::Proxy::https(proxy.as_str()) + .map_err(|_| "failed to configure loopback OpenAI file proxy".to_string())?; + builder = builder.proxy(proxy); + } else if let Some(target) = trusted_target { if let Some(host) = target.resolver_host.as_deref() { builder = builder.resolve_to_addrs(host, &target.pinned_addrs); } @@ -408,6 +492,14 @@ fn build_download_client( .map_err(|_| "failed to build bounded import HTTP client".to_string()) } +#[cfg(test)] +pub(crate) fn build_loopback_proxy_client_for_test( + endpoint: &str, +) -> Result { + let proxy = parse_loopback_openai_file_proxy(endpoint)?; + build_download_client(None, Some(&proxy)) +} + async fn prepare_download_request( download_link: &str, policy: ConversationImportDownloadPolicy, @@ -420,7 +512,7 @@ async fn prepare_download_request( TrustedMcpAddressPolicy::PublicOnly, ) .await?; - let client = build_download_client(Some(&target))?; + let client = build_download_client(Some(&target), None)?; Ok((client, request_url_for_download(target.url))) } ConversationImportDownloadPolicy::LoopbackTrustedMcpHostFile => { @@ -429,7 +521,8 @@ async fn prepare_download_request( TrustedMcpAddressPolicy::PublicOrOpenAiRfc2544FakeIp, ) .await?; - let client = build_download_client(Some(&target))?; + let local_proxy = configured_loopback_openai_file_proxy(&target, policy)?; + let client = build_download_client(Some(&target), local_proxy.as_ref())?; Ok((client, request_url_for_download(target.url))) } ConversationImportDownloadPolicy::TrustedMcpHostFile => { @@ -438,7 +531,7 @@ async fn prepare_download_request( TrustedMcpAddressPolicy::PublicOnly, ) .await?; - let client = build_download_client(Some(&target))?; + let client = build_download_client(Some(&target), None)?; Ok((client, request_url_for_download(target.url))) } } @@ -1156,6 +1249,89 @@ mod tests { reset_import_test_dns_resolution_count(); } + #[test] + fn local_openai_file_proxy_accepts_only_explicit_loopback_http_endpoints() { + for (good, port) in [ + ("http://127.0.0.1:10090", 10090), + ("http://[::1]:10090", 10090), + ("http://127.0.0.1:80", 80), + ("http://[::1]:80", 80), + ("http://127.0.0.1:80/", 80), + ] { + let url = parse_loopback_openai_file_proxy(good).expect(good); + assert_eq!(url.scheme(), "http"); + assert_eq!(url.port_or_known_default(), Some(port)); + if port == 80 { + assert_eq!(url.port(), None, "the default port is normalized away"); + } + } + for bad in [ + "https://127.0.0.1:10090", + "http://localhost:10090", + "http://10.0.0.1:10090", + "http://127.0.0.2:10090", + "http://198.18.0.42:10090", + "http://127.0.0.1", + "http://[::1]", + "http://127.0.0.1/", + "http://127.0.0.1:10090/path", + "http://127.0.0.1:10090/?query=1", + "http://user:pass@127.0.0.1:10090", + "http://127.0.0.1:10090#fragment", + ] { + assert!(parse_loopback_openai_file_proxy(bad).is_err(), "{bad}"); + } + let proxy = parse_loopback_openai_file_proxy("http://127.0.0.1:10090").unwrap(); + assert!(build_download_client(None, Some(&proxy)).is_ok()); + assert!(build_download_client(None, None).is_ok()); + } + + #[test] + fn local_openai_file_proxy_remains_host_and_session_scoped() { + let allowed = TrustedDownloadTarget { + url: reqwest::Url::parse("https://files.oaiusercontent.com/file").unwrap(), + resolver_host: Some("files.oaiusercontent.com".to_string()), + pinned_addrs: vec![], + }; + let other = TrustedDownloadTarget { + url: reqwest::Url::parse("https://example.com/file").unwrap(), + resolver_host: Some("example.com".to_string()), + pinned_addrs: vec![], + }; + let endpoint = "http://127.0.0.1:10090"; + for policy in [ + ConversationImportDownloadPolicy::AuthenticatedMcpOpenAiHostFile, + ConversationImportDownloadPolicy::TrustedMcpHostFile, + ] { + assert!( + select_loopback_openai_file_proxy(&allowed, policy, Some(endpoint)) + .unwrap() + .is_none() + ); + } + assert!(select_loopback_openai_file_proxy( + &allowed, + ConversationImportDownloadPolicy::LoopbackTrustedMcpHostFile, + None + ) + .unwrap() + .is_none()); + assert!(select_loopback_openai_file_proxy( + &allowed, + ConversationImportDownloadPolicy::LoopbackTrustedMcpHostFile, + Some(endpoint) + ) + .unwrap() + .is_some()); + assert!(select_loopback_openai_file_proxy( + &other, + ConversationImportDownloadPolicy::LoopbackTrustedMcpHostFile, + Some(endpoint) + ) + .unwrap() + .is_none()); + } + #[test] fn rfc2544_exception_matches_exact_ipv4_benchmark_boundaries() { for ip in ["198.18.0.0", "198.18.0.42", "198.19.255.255"] {