Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/MCP.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。它是深入配置/排障文档,不是普通用户第一次必须阅读的教程。

## 结果卡片
Expand Down
41 changes: 41 additions & 0 deletions src/mcp_tests/file_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,47 @@ async fn start_mcp_import_mock_server(response: Vec<u8>) -> 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<crate::Config> {
let mut config = (*test_config_oauth2(Some("secret"))).clone();
config.oauth2.trusted_mcp_file_client_ids = trusted_client_ids
Expand Down
184 changes: 180 additions & 4 deletions src/tool_runtime/conversation_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<reqwest::Url, String> {
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::<u16>().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<Option<reqwest::Url>, 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<Option<reqwest::Url>, 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<reqwest::Client, String> {
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);
}
Expand All @@ -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<reqwest::Client, String> {
let proxy = parse_loopback_openai_file_proxy(endpoint)?;
build_download_client(None, Some(&proxy))
}

async fn prepare_download_request(
download_link: &str,
policy: ConversationImportDownloadPolicy,
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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)))
}
}
Expand Down Expand Up @@ -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"] {
Expand Down
Loading