diff --git a/apps/desktop/src/demo/api.ts b/apps/desktop/src/demo/api.ts
index 5577482..270081a 100644
--- a/apps/desktop/src/demo/api.ts
+++ b/apps/desktop/src/demo/api.ts
@@ -86,7 +86,7 @@ const harnessStatus: CursorHarnessStatus = {
let detailed = true;
let portSettings = { proxy_port: 0, service_port: 0 };
let proxySettings: ProxySettings = {
- mode: "system",
+ mode: "default",
address: "",
auth_enabled: false,
username: "",
diff --git a/apps/desktop/src/features/settings/ProxySettingsCard.tsx b/apps/desktop/src/features/settings/ProxySettingsCard.tsx
index a58e043..ce8ab58 100644
--- a/apps/desktop/src/features/settings/ProxySettingsCard.tsx
+++ b/apps/desktop/src/features/settings/ProxySettingsCard.tsx
@@ -26,7 +26,7 @@ export function ProxySettingsCard({
onSave: () => void;
}) {
const custom = draft.mode === "custom";
- const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
+ const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "default" ? t("默认") : t("自定义");
const action = editing ? (
@@ -43,7 +43,7 @@ export function ProxySettingsCard({
{editing ? <>
{t("代理方式")}
-
+
{custom &&
diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx
index 307a687..4f05143 100644
--- a/apps/desktop/src/features/settings/SettingsPage.tsx
+++ b/apps/desktop/src/features/settings/SettingsPage.tsx
@@ -31,7 +31,7 @@ export function SettingsPage() {
const [clearScope, setClearScope] = useState
("details");
const [clearing, setClearing] = useState(false);
const [outboundProxy, setOutboundProxy] = useState(null);
- const [proxyDraft, setProxyDraft] = useState({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
+ const [proxyDraft, setProxyDraft] = useState({ mode: "default", address: "", auth_enabled: false, username: "", password: "" });
const [editingProxy, setEditingProxy] = useState(false);
const [savingProxy, setSavingProxy] = useState(false);
const [tabSettings, setTabSettings] = useState(null);
diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json
index 3447646..2b53197 100644
--- a/apps/desktop/src/i18n/generated/catalog.json
+++ b/apps/desktop/src/i18n/generated/catalog.json
@@ -1944,12 +1944,12 @@
{
"file": "features/settings/ProxySettingsCard.tsx",
"line": 29,
- "column": 93
+ "column": 90
},
{
"file": "features/settings/ProxySettingsCard.tsx",
"line": 46,
- "column": 169
+ "column": 166
},
{
"file": "features/settings/TabSettingsCard.tsx",
@@ -2956,6 +2956,23 @@
}
]
},
+ "844b8cc8dff7c1d8": {
+ "source": "默认",
+ "kind": "text",
+ "placeholders": [],
+ "refs": [
+ {
+ "file": "features/settings/ProxySettingsCard.tsx",
+ "line": 29,
+ "column": 80
+ },
+ {
+ "file": "features/settings/ProxySettingsCard.tsx",
+ "line": 46,
+ "column": 129
+ }
+ ]
+ },
"864597982c308d72": {
"source": "已开启静默启动",
"kind": "text",
@@ -4808,23 +4825,6 @@
}
]
},
- "d86fa42c3848c680": {
- "source": "使用系统代理",
- "kind": "text",
- "placeholders": [],
- "refs": [
- {
- "file": "features/settings/ProxySettingsCard.tsx",
- "line": 29,
- "column": 79
- },
- {
- "file": "features/settings/ProxySettingsCard.tsx",
- "line": 46,
- "column": 128
- }
- ]
- },
"d8c47e9776cf1082": {
"source": "主菜单",
"kind": "text",
diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json
index da87697..67f09cc 100644
--- a/apps/desktop/src/i18n/locales/en-US.json
+++ b/apps/desktop/src/i18n/locales/en-US.json
@@ -203,6 +203,7 @@
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
+ "844b8cc8dff7c1d8": "Default",
"864597982c308d72": "Silent start enabled",
"86de7c4ee8fa7689": "Sync models",
"8716e1344b0daddb": "Cursor official",
@@ -332,7 +333,6 @@
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
"d7e266bdc8064193": "Group name",
- "d86fa42c3848c680": "Use system proxy",
"d8c47e9776cf1082": "Main menu",
"d8c589c455675b46": "Prompt settings saved",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json
index f5357a3..9e6ccc7 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.json
+++ b/apps/desktop/src/i18n/locales/zh-CN.json
@@ -203,6 +203,7 @@
"83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置",
+ "844b8cc8dff7c1d8": "默认",
"864597982c308d72": "已开启静默启动",
"86de7c4ee8fa7689": "同步模型",
"8716e1344b0daddb": "Cursor 官方",
@@ -332,7 +333,6 @@
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
"d7e266bdc8064193": "分组名称",
- "d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单",
"d8c589c455675b46": "提示词设置已保存",
"da521d1c1cbd36af": "需要授权安装证书",
diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts
index 2710754..03acece 100644
--- a/apps/desktop/src/shared/api.ts
+++ b/apps/desktop/src/shared/api.ts
@@ -120,7 +120,7 @@ export interface StatisticsStorage {
export type StatisticsStorageScope = "details" | "all";
-export type ProxyMode = "system" | "custom";
+export type ProxyMode = "default" | "custom";
export interface ProxySettings {
mode: ProxyMode;
diff --git a/server/src/control/service.rs b/server/src/control/service.rs
index 707ffe0..5cfc15d 100644
--- a/server/src/control/service.rs
+++ b/server/src/control/service.rs
@@ -683,6 +683,13 @@ impl ControlService {
}
pub async fn set_proxy_settings(&self, settings: ProxySettingsInput) -> Result {
+ if settings.mode.is_custom() {
+ let local_proxy_port = match self.cursor_harness.proxy_port().await {
+ Some(port) => port,
+ None => self.store.port_settings().await?.proxy_port,
+ };
+ crate::network::reject_self_proxy(&settings.address, local_proxy_port)?;
+ }
let settings = self.store.set_proxy_settings(settings).await?;
self.clients.invalidate().await;
Ok(settings)
diff --git a/server/src/local_app/mod.rs b/server/src/local_app/mod.rs
index e55f30b..7b45db6 100644
--- a/server/src/local_app/mod.rs
+++ b/server/src/local_app/mod.rs
@@ -90,6 +90,10 @@ impl CursorHarness {
*self.inner.backend_addr.write() = Some(addr);
}
+ pub async fn proxy_port(&self) -> Option {
+ self.inner.proxy.lock().await.port()
+ }
+
pub async fn cleanup_stale_settings(&self) -> Result<()> {
settings::clear_stale_managed_settings()
}
diff --git a/server/src/local_app/proxy.rs b/server/src/local_app/proxy.rs
index 70ba5fc..5cfce3a 100644
--- a/server/src/local_app/proxy.rs
+++ b/server/src/local_app/proxy.rs
@@ -33,6 +33,13 @@ impl ProxyRuntime {
pub fn url(&self) -> Option {
self.running().then(|| self.url.clone()).flatten()
}
+ pub fn port(&self) -> Option {
+ if self.running() {
+ self.port
+ } else {
+ None
+ }
+ }
pub async fn start(
&mut self,
diff --git a/server/src/network.rs b/server/src/network.rs
index b790dae..0c02fac 100644
--- a/server/src/network.rs
+++ b/server/src/network.rs
@@ -4,7 +4,12 @@ use std::{sync::Arc, time::Duration};
use tokio::sync::RwLock;
-use crate::{store::Store, Result};
+use crate::{
+ store::{ProxySettingsSecret, Store},
+ Error, Result,
+};
+
+const LOCAL_NO_PROXY: &str = "localhost,127.0.0.0/8,::1";
#[derive(Clone)]
pub struct NetworkClients {
@@ -94,11 +99,7 @@ pub async fn client_builder(store: &Store) -> Result {
// only offer legacy TLS 1.2 cipher suites unsupported by rustls.
let mut builder = reqwest::Client::builder().use_native_tls();
if settings.mode.is_custom() {
- let mut proxy = reqwest::Proxy::all(&settings.address)?;
- if settings.auth_enabled {
- proxy = proxy.basic_auth(&settings.username, &settings.password);
- }
- builder = builder.no_proxy().proxy(proxy);
+ builder = builder.proxy(custom_proxy(&settings)?);
}
Ok(builder)
}
@@ -111,11 +112,114 @@ pub async fn blocking_client_builder(store: &Store) -> Result Result {
+ let mut proxy = reqwest::Proxy::all(&settings.address)?
+ .no_proxy(reqwest::NoProxy::from_string(LOCAL_NO_PROXY));
+ if settings.auth_enabled {
+ proxy = proxy.basic_auth(&settings.username, &settings.password);
+ }
+ Ok(proxy)
+}
+
+pub fn reject_self_proxy(address: &str, local_proxy_port: u16) -> Result<()> {
+ if local_proxy_port == 0 {
+ return Ok(());
+ }
+ let url = url::Url::parse(address)
+ .map_err(|error| Error::Config(format!("invalid proxy address: {error}")))?;
+ if url.port_or_known_default() == Some(local_proxy_port) && url_host_is_loopback(&url) {
+ return Err(Error::Config(
+ "proxy address cannot point to the Cursor BYOK local proxy".into(),
+ ));
+ }
+ Ok(())
+}
+
+fn url_host_is_loopback(url: &url::Url) -> bool {
+ match url.host() {
+ Some(url::Host::Domain(host)) => {
+ host.trim_end_matches('.').eq_ignore_ascii_case("localhost")
+ }
+ Some(url::Host::Ipv4(address)) => address.is_loopback(),
+ Some(url::Host::Ipv6(address)) => address.is_loopback(),
+ None => false,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+ use super::*;
+ use crate::store::ProxyMode;
+
+ fn custom_settings(address: String) -> ProxySettingsSecret {
+ ProxySettingsSecret {
+ mode: ProxyMode::Custom,
+ address,
+ auth_enabled: false,
+ username: String::new(),
+ password: String::new(),
+ }
+ }
+
+ #[test]
+ fn rejects_only_own_loopback_proxy_port() {
+ for address in [
+ "http://localhost:15721",
+ "http://localhost.:15721",
+ "http://127.0.0.2:15721",
+ "http://[::1]:15721",
+ ] {
+ assert!(reject_self_proxy(address, 15721).is_err(), "{address}");
+ }
+ assert!(reject_self_proxy("http://127.0.0.1:7890", 15721).is_ok());
+ assert!(reject_self_proxy("http://192.168.1.2:15721", 15721).is_ok());
+ assert!(reject_self_proxy("http://127.0.0.1:15721", 0).is_ok());
+ }
+
+ #[tokio::test]
+ async fn custom_proxy_bypasses_loopback_destinations() {
+ let proxy_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let proxy_address = proxy_listener.local_addr().unwrap();
+ let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let target_address = target_listener.local_addr().unwrap();
+ let target = tokio::spawn(async move {
+ let (mut socket, _) = target_listener.accept().await.unwrap();
+ let mut request = [0_u8; 1024];
+ let _ = socket.read(&mut request).await.unwrap();
+ socket
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
+ .await
+ .unwrap();
+ });
+ let settings = custom_settings(format!("http://{proxy_address}"));
+ let client = reqwest::Client::builder()
+ .proxy(custom_proxy(&settings).unwrap())
+ .build()
+ .unwrap();
+
+ let body = client
+ .get(format!("http://{target_address}"))
+ .send()
+ .await
+ .unwrap()
+ .text()
+ .await
+ .unwrap();
+
+ assert_eq!(body, "ok");
+ target.await.unwrap();
+ assert!(
+ tokio::time::timeout(Duration::from_millis(50), proxy_listener.accept())
+ .await
+ .is_err(),
+ "loopback destination unexpectedly reached the configured proxy"
+ );
+ }
+}
diff --git a/server/src/store/settings.rs b/server/src/store/settings.rs
index 73278e7..2ac8954 100644
--- a/server/src/store/settings.rs
+++ b/server/src/store/settings.rs
@@ -27,7 +27,7 @@ pub struct PortSettings {
#[serde(rename_all = "snake_case")]
pub enum ProxyMode {
#[default]
- System,
+ Default,
Custom,
}
@@ -362,3 +362,22 @@ impl Store {
Ok(settings)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::ProxyMode;
+
+ #[test]
+ fn default_proxy_mode_uses_the_default_wire_value() {
+ assert_eq!(ProxyMode::default(), ProxyMode::Default);
+ assert_eq!(
+ serde_json::to_string(&ProxyMode::default()).unwrap(),
+ "\"default\""
+ );
+ assert_eq!(
+ serde_json::from_str::("\"default\"").unwrap(),
+ ProxyMode::Default
+ );
+ assert!(serde_json::from_str::("\"system\"").is_err());
+ }
+}