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
1 change: 1 addition & 0 deletions frontend/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
217 changes: 208 additions & 9 deletions frontend/src-tauri/src/proxy.rs
Original file line number Diff line number Diff line change
@@ -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"))]
Expand All @@ -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";
Expand All @@ -33,7 +41,7 @@ pub struct ProxyConfig {
}

fn default_cors() -> bool {
true
false
}

impl Default for ProxyConfig {
Expand All @@ -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,
}
Expand Down Expand Up @@ -177,10 +185,11 @@ async fn start_proxy_inner(
config: ProxyConfig,
) -> Result<ProxyStatus, String> {
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
);

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Body>, 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<ProxyStatus, String> {
let _lifecycle_guard = state.lifecycle.lock().await;
Expand Down Expand Up @@ -379,6 +432,152 @@ pub async fn test_proxy_port(host: String, port: u16) -> Result<bool, String> {
#[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() {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/apikeys/ProxyClientGuides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ export function ProxyClientGuides({
<div className="flex gap-2 text-xs leading-relaxed text-muted-foreground">
<TerminalSquare className="mt-0.5 h-4 w-4 shrink-0" />
<p>
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.
</p>
</div>
</Card>
Expand Down
19 changes: 10 additions & 9 deletions frontend/src/components/apikeys/ProxyConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -304,18 +304,18 @@ export function ProxyConfigSection({
<Alert role="note">
<ShieldCheck className="h-4 w-4" />
<AlertDescription>
Local processes can use Maple&apos;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."}
</AlertDescription>
</Alert>

{config.enable_cors && (
<Alert role="note" className="border-maple-warning/40 bg-maple-warning/10">
<AlertCircle className="h-4 w-4 text-maple-warning" />
<AlertDescription>
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.
</AlertDescription>
</Alert>
)}
Expand Down Expand Up @@ -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.
</p>
</div>
<Switch
id="enable-cors"
checked={config.enable_cors ?? true}
checked={config.enable_cors ?? false}
onCheckedChange={(checked) => handleConfigChange("enable_cors", checked)}
disabled={isRunning}
aria-describedby="enable-cors-description"
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/services/proxyService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/services/proxyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down Expand Up @@ -107,7 +107,8 @@ class ProxyService {
host: "127.0.0.1",
port: 8080,
api_key: "",
enabled: false
enabled: false,
enable_cors: false
};
}
}
Expand Down
Loading