From c5df5994939bc16e36b836a8e9d684ef56ce04d3 Mon Sep 17 00:00:00 2001
From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com>
Date: Tue, 28 Jul 2026 05:39:03 +0000
Subject: [PATCH] Harden Local Proxy browser origin boundary
---
frontend/src-tauri/Cargo.lock | 1 +
frontend/src-tauri/Cargo.toml | 1 +
frontend/src-tauri/src/proxy.rs | 217 +++++++++++++++++-
.../components/apikeys/ProxyClientGuides.tsx | 4 +-
.../components/apikeys/ProxyConfigSection.tsx | 19 +-
frontend/src/services/proxyService.test.ts | 15 ++
frontend/src/services/proxyService.ts | 5 +-
7 files changed, 240 insertions(+), 22 deletions(-)
diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock
index 6f1060678..855202171 100644
--- a/frontend/src-tauri/Cargo.lock
+++ b/frontend/src-tauri/Cargo.lock
@@ -4636,6 +4636,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-util",
+ "tower-http 0.6.8",
"unicode-normalization",
"webpki-roots",
"windows 0.62.2",
diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml
index beeb73a49..3f9e3974d 100644
--- a/frontend/src-tauri/Cargo.toml
+++ b/frontend/src-tauri/Cargo.toml
@@ -40,6 +40,7 @@ maple-proxy = "0.2.0"
tauri-plugin-fs = "2.5.1"
anyhow = "1.0"
axum = "0.8"
+tower-http = { version = "0.6", features = ["cors"] }
pdf_oxide = { version = "=0.3.74", git = "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/OpenSecretCloud/pdf_oxide.git", rev = "f24b43ba997dd91ce60839640a8ce3ac92a87a5d", features = ["ocr-ort", "rendering"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
base64 = "0.22"
diff --git a/frontend/src-tauri/src/proxy.rs b/frontend/src-tauri/src/proxy.rs
index e0748ac05..9afd83cc3 100644
--- a/frontend/src-tauri/src/proxy.rs
+++ b/frontend/src-tauri/src/proxy.rs
@@ -1,4 +1,11 @@
use anyhow::{anyhow, Result};
+use axum::{
+ body::Body,
+ http::{header::ORIGIN, Method, Request, StatusCode},
+ middleware::{self, Next},
+ response::{IntoResponse, Response},
+ Router,
+};
use maple_proxy::{create_app, Config};
use serde::{Deserialize, Serialize};
#[cfg(any(target_os = "macos", target_os = "linux"))]
@@ -12,6 +19,7 @@ use tauri::{AppHandle, Emitter, Manager, State};
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
+use tower_http::cors::{AllowHeaders, Any, CorsLayer};
#[cfg(any(target_os = "macos", target_os = "linux"))]
const MAPLE_APP_IDENTIFIER: &str = "cloud.opensecret.maple";
@@ -33,7 +41,7 @@ pub struct ProxyConfig {
}
fn default_cors() -> bool {
- true
+ false
}
impl Default for ProxyConfig {
@@ -43,7 +51,7 @@ impl Default for ProxyConfig {
port: 8080,
api_key: String::new(),
enabled: false,
- enable_cors: true,
+ enable_cors: false,
backend_url: None,
auto_start: false,
}
@@ -177,10 +185,11 @@ async fn start_proxy_inner(
config: ProxyConfig,
) -> Result {
log::info!(
- "Starting proxy on {}:{} (cors={}, auto_start={})",
+ "Starting proxy on {}:{} (cors={}, saved_credential_fallback={}, auto_start={})",
config.host,
config.port,
config.enable_cors,
+ !config.enable_cors,
config.auto_start
);
@@ -197,11 +206,7 @@ async fn start_proxy_inner(
.clone()
.unwrap_or_else(|| "https://enclave.trymaple.ai".to_string());
- // Create maple-proxy config
- let proxy_config = Config::new(config.host.clone(), config.port, backend_url)
- .with_api_key(config.api_key.clone())
- .with_debug(false)
- .with_cors(config.enable_cors);
+ let proxy_config = build_proxy_server_config(&config, backend_url);
// Try to bind to the address first to check if port is available
let addr = proxy_config
@@ -229,7 +234,7 @@ async fn start_proxy_inner(
// maple-proxy owns the OpenAI-compatible transport, including the shared
// 50 MiB request limit needed by Goose's image tool. Provider responses are
// passed through unchanged.
- let app = create_app(proxy_config);
+ let app = apply_proxy_access_policy(proxy_config, config.enable_cors);
// Spawn the proxy server
let handle = tokio::spawn(async move {
@@ -253,6 +258,54 @@ async fn start_proxy_inner(
})
}
+fn build_proxy_server_config(config: &ProxyConfig, backend_url: String) -> Config {
+ let proxy_config = Config::new(config.host.clone(), config.port, backend_url)
+ .with_debug(false)
+ // Maple owns the browser boundary below so it can both list the
+ // non-wildcard Authorization header and reject browser origins when
+ // CORS is disabled. Do not install maple-proxy's permissive layer.
+ .with_cors(false);
+
+ // CORS deliberately allows every browser origin for compatibility. Never
+ // combine that reachability with Maple's saved credential: browser-enabled
+ // clients must provide their own Authorization header on every request.
+ if config.enable_cors {
+ proxy_config
+ } else {
+ proxy_config.with_api_key(config.api_key.clone())
+ }
+}
+
+fn apply_proxy_access_policy(proxy_config: Config, enable_cors: bool) -> Router {
+ let app = create_app(proxy_config);
+
+ if enable_cors {
+ app.layer(
+ CorsLayer::new()
+ .allow_origin(Any)
+ .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
+ // Authorization is not covered by `*` under Fetch. Mirroring
+ // the preflight list supports it plus SDK-specific headers
+ // without trusting a request that lacks a valid bearer key.
+ .allow_headers(AllowHeaders::mirror_request()),
+ )
+ } else {
+ app.layer(middleware::from_fn(reject_browser_request))
+ }
+}
+
+async fn reject_browser_request(request: Request, next: Next) -> Response {
+ // Disabling CORS alone only hides responses. A no-cors browser POST can
+ // still reach loopback and spend the saved credential, so fail closed on
+ // forbidden browser headers before maple-proxy sees the body. Fetch omits
+ // Origin on some no-cors GET/HEAD requests but still sends Sec-Fetch-Site.
+ if request.headers().contains_key(ORIGIN) || request.headers().contains_key("sec-fetch-site") {
+ return StatusCode::FORBIDDEN.into_response();
+ }
+
+ next.run(request).await
+}
+
#[tauri::command]
pub async fn stop_proxy(state: State<'_, ProxyState>) -> Result {
let _lifecycle_guard = state.lifecycle.lock().await;
@@ -379,6 +432,152 @@ pub async fn test_proxy_port(host: String, port: u16) -> Result {
#[cfg(test)]
mod tests {
use super::*;
+ use axum::http::header::CONTENT_TYPE;
+
+ #[test]
+ fn new_and_legacy_unspecified_configs_disable_cors() {
+ assert!(!ProxyConfig::default().enable_cors);
+
+ let config: ProxyConfig = serde_json::from_value(serde_json::json!({
+ "host": "127.0.0.1",
+ "port": 8080,
+ "api_key": "saved-key",
+ "enabled": false
+ }))
+ .unwrap();
+
+ assert!(!config.enable_cors);
+ }
+
+ #[test]
+ fn explicit_cors_keeps_browser_access_but_removes_saved_credential_fallback() {
+ let config = ProxyConfig {
+ api_key: "saved-key".to_string(),
+ enable_cors: true,
+ ..ProxyConfig::default()
+ };
+
+ let server_config =
+ build_proxy_server_config(&config, "https://example.invalid".to_string());
+
+ assert!(!server_config.enable_cors);
+ assert!(server_config.default_api_key.is_none());
+ }
+
+ #[test]
+ fn cors_disabled_keeps_saved_credential_fallback_for_local_clients() {
+ let config = ProxyConfig {
+ api_key: "saved-key".to_string(),
+ ..ProxyConfig::default()
+ };
+
+ let server_config =
+ build_proxy_server_config(&config, "https://example.invalid".to_string());
+
+ assert!(!server_config.enable_cors);
+ assert_eq!(server_config.default_api_key.as_deref(), Some("saved-key"));
+ }
+
+ async fn serve_test_app(app: Router) -> (String, tokio::task::JoinHandle<()>) {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+ (format!("http://{addr}"), server)
+ }
+
+ #[tokio::test]
+ async fn cors_disabled_rejects_preflight_free_browser_posts_before_using_saved_key() {
+ let config = ProxyConfig {
+ api_key: "saved-key".to_string(),
+ ..ProxyConfig::default()
+ };
+ let server_config =
+ build_proxy_server_config(&config, "https://example.invalid".to_string());
+ let app = apply_proxy_access_policy(server_config, config.enable_cors);
+ let (base_url, server) = serve_test_app(app).await;
+
+ let response = reqwest::Client::new()
+ .post(format!("{base_url}/v1/chat/completions"))
+ .header(ORIGIN, "https://attacker.example")
+ .header(CONTENT_TYPE, "text/plain")
+ .body(r#"{"model":"test","messages":[]}"#)
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
+ server.abort();
+ }
+
+ #[tokio::test]
+ async fn cors_disabled_rejects_originless_browser_gets_using_fetch_metadata() {
+ let config = ProxyConfig {
+ api_key: "saved-key".to_string(),
+ ..ProxyConfig::default()
+ };
+ let server_config =
+ build_proxy_server_config(&config, "https://example.invalid".to_string());
+ let app = apply_proxy_access_policy(server_config, config.enable_cors);
+ let (base_url, server) = serve_test_app(app).await;
+
+ let response = reqwest::Client::new()
+ .get(format!("{base_url}/v1/models"))
+ .header("sec-fetch-site", "cross-site")
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
+ server.abort();
+ }
+
+ #[tokio::test]
+ async fn cors_enabled_preflight_explicitly_allows_bearer_authentication() {
+ let config = ProxyConfig {
+ api_key: "saved-key".to_string(),
+ enable_cors: true,
+ ..ProxyConfig::default()
+ };
+ let server_config =
+ build_proxy_server_config(&config, "https://example.invalid".to_string());
+ let app = apply_proxy_access_policy(server_config, config.enable_cors);
+ let (base_url, server) = serve_test_app(app).await;
+
+ let response = reqwest::Client::new()
+ .request(
+ reqwest::Method::OPTIONS,
+ format!("{base_url}/v1/chat/completions"),
+ )
+ .header(ORIGIN, "https://browser.example")
+ .header("access-control-request-method", "POST")
+ .header(
+ "access-control-request-headers",
+ "authorization,content-type,x-stainless-retry-count,x-stainless-lang",
+ )
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response
+ .headers()
+ .get("access-control-allow-origin")
+ .unwrap(),
+ "*"
+ );
+ let allowed_headers = response
+ .headers()
+ .get("access-control-allow-headers")
+ .unwrap()
+ .to_str()
+ .unwrap();
+ assert!(allowed_headers.contains("authorization"));
+ assert!(allowed_headers.contains("content-type"));
+ assert!(allowed_headers.contains("x-stainless-retry-count"));
+ assert!(allowed_headers.contains("x-stainless-lang"));
+ server.abort();
+ }
#[tokio::test]
async fn stop_waits_for_aborted_server_task() {
diff --git a/frontend/src/components/apikeys/ProxyClientGuides.tsx b/frontend/src/components/apikeys/ProxyClientGuides.tsx
index a9fb33398..f242f6784 100644
--- a/frontend/src/components/apikeys/ProxyClientGuides.tsx
+++ b/frontend/src/components/apikeys/ProxyClientGuides.tsx
@@ -292,8 +292,8 @@ export function ProxyClientGuides({
- A 401 usually means an invalid or stale key; a 400 usually means an old model ID. A 5xx
- can be a temporary model-provider outage even when this setup is correct.
+ A 401 usually means a missing, invalid, or stale key; a 400 usually means an old model
+ ID. A 5xx can be a temporary model-provider outage even when this setup is correct.
diff --git a/frontend/src/components/apikeys/ProxyConfigSection.tsx b/frontend/src/components/apikeys/ProxyConfigSection.tsx
index ca2192dc3..348386fa1 100644
--- a/frontend/src/components/apikeys/ProxyConfigSection.tsx
+++ b/frontend/src/components/apikeys/ProxyConfigSection.tsx
@@ -50,7 +50,7 @@ export function ProxyConfigSection({
port: 8080,
api_key: "",
enabled: false,
- enable_cors: true,
+ enable_cors: false,
auto_start: false
});
const [isLoading, setIsLoading] = useState(false);
@@ -304,9 +304,9 @@ export function ProxyConfigSection({
- Local processes can use Maple's saved proxy credential without supplying their
- own key. Keep the proxy on loopback, run only trusted clients, and remember that proxy
- usage counts toward your Maple account.
+ {config.enable_cors
+ ? "Browser inference never uses Maple's saved proxy credential. Every inference client must supply its own valid Maple API key."
+ : "Browser-origin requests are rejected. Local processes can use Maple's saved proxy credential without supplying their own key. Keep the proxy on loopback and run only trusted clients."}
@@ -314,8 +314,8 @@ export function ProxyConfigSection({
- CORS is enabled, so browser pages may be able to reach this proxy while it is
- running. Turn CORS off unless a browser client specifically requires it.
+ CORS is enabled for all browser origins. Each inference request must include its own
+ valid Maple API key; the credential saved by Maple Desktop is not used.
)}
@@ -378,13 +378,14 @@ export function ProxyConfigSection({
id="enable-cors-description"
className="mt-1 text-xs leading-relaxed text-muted-foreground"
>
- Desktop clients such as OpenCode do not need CORS. Turn this off unless a
- browser app specifically requires it.
+ Allows browser apps at any origin to call and read from the proxy. Every
+ inference request must include its own valid Maple API key. Desktop clients such
+ as OpenCode do not need CORS.
handleConfigChange("enable_cors", checked)}
disabled={isRunning}
aria-describedby="enable-cors-description"
diff --git a/frontend/src/services/proxyService.test.ts b/frontend/src/services/proxyService.test.ts
index c68d67231..6727ced9c 100644
--- a/frontend/src/services/proxyService.test.ts
+++ b/frontend/src/services/proxyService.test.ts
@@ -45,6 +45,21 @@ describe("manualProxyConfigsMatch", () => {
)
).toBe(false);
});
+
+ it("treats an omitted CORS setting as the secure disabled default", () => {
+ expect(
+ manualProxyConfigsMatch(
+ { ...desiredConfig, enable_cors: undefined },
+ { ...desiredConfig, enable_cors: false }
+ )
+ ).toBe(true);
+ expect(
+ manualProxyConfigsMatch(
+ { ...desiredConfig, enable_cors: undefined },
+ { ...desiredConfig, enable_cors: true }
+ )
+ ).toBe(false);
+ });
});
describe("Agent proxy key registry", () => {
diff --git a/frontend/src/services/proxyService.ts b/frontend/src/services/proxyService.ts
index ee71dc314..03927734c 100644
--- a/frontend/src/services/proxyService.ts
+++ b/frontend/src/services/proxyService.ts
@@ -54,7 +54,7 @@ export function manualProxyConfigsMatch(active: ProxyConfig, desired: ProxyConfi
active.port === desired.port &&
active.api_key.trim() === desired.api_key.trim() &&
active.enabled === desired.enabled &&
- (active.enable_cors ?? true) === (desired.enable_cors ?? true) &&
+ (active.enable_cors ?? false) === (desired.enable_cors ?? false) &&
normalizeBackendUrl(active.backend_url) === normalizeBackendUrl(desired.backend_url) &&
(active.auto_start ?? false) === (desired.auto_start ?? false)
);
@@ -107,7 +107,8 @@ class ProxyService {
host: "127.0.0.1",
port: 8080,
api_key: "",
- enabled: false
+ enabled: false,
+ enable_cors: false
};
}
}