diff --git a/Cargo.lock b/Cargo.lock index ed1fdf66dff7..e843af9f305b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -836,6 +845,28 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "rustls 0.23.36", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + [[package]] name = "az" version = "1.3.0" @@ -3751,6 +3782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", + "tokio", ] [[package]] @@ -4488,6 +4520,7 @@ version = "1.23.0" dependencies = [ "anyhow", "axum 0.8.8", + "axum-server", "base64 0.22.1", "bytes", "chrono", @@ -4503,6 +4536,7 @@ dependencies = [ "http 1.4.0", "once_cell", "rand 0.9.2", + "rcgen", "reqwest 0.12.28", "rmcp 0.15.0", "rustls 0.23.36", @@ -7907,6 +7941,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "realfft" version = "3.5.0" @@ -12607,6 +12654,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 575d5467f16b..ce72eec456e1 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -51,6 +51,8 @@ rustls = { version = "0.23", features = ["ring"] } uuid = { workspace = true } once_cell = { workspace = true } dirs = { workspace = true } +rcgen = "0.13" +axum-server = { version = "0.8.0", features = ["tls-rustls"] } [target.'cfg(windows)'.dependencies] winreg = { version = "0.55.0" } diff --git a/crates/goose-server/src/auth.rs b/crates/goose-server/src/auth.rs index f902f21167fa..41790a9351bf 100644 --- a/crates/goose-server/src/auth.rs +++ b/crates/goose-server/src/auth.rs @@ -13,6 +13,7 @@ pub async fn check_token( if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" || request.uri().path() == "/mcp-app-proxy" + || request.uri().path() == "/mcp-app-guest" { return Ok(next.run(request).await); } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index a68d56eeae6d..d2078c5f028d 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -2,11 +2,12 @@ use crate::configuration; use crate::state; use anyhow::Result; use axum::middleware; +use axum_server::Handle; use goose_server::auth::check_token; +use goose_server::tls::self_signed_config; use tower_http::cors::{Any, CorsLayer}; use tracing::info; -// Graceful shutdown signal #[cfg(unix)] async fn shutdown_signal() { use tokio::signal::unix::{signal, SignalKind}; @@ -47,17 +48,28 @@ pub async fn run() -> Result<()> { )) .layer(cors); - let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?; - info!("listening on {}", listener.local_addr()?); + let addr = settings.socket_addr(); + let tls_config = self_signed_config().await?; + + let handle = Handle::new(); + let shutdown_handle = handle.clone(); + tokio::spawn(async move { + shutdown_signal().await; + shutdown_handle.graceful_shutdown(None); + }); + + info!("listening on https://{}", addr); let tunnel_manager = app_state.tunnel_manager.clone(); tokio::spawn(async move { tunnel_manager.check_auto_start().await; }); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) + axum_server::bind_rustls(addr, tls_config) + .handle(handle) + .serve(app.into_make_service()) .await?; + info!("server shutdown complete"); Ok(()) } diff --git a/crates/goose-server/src/lib.rs b/crates/goose-server/src/lib.rs index aa5ae94f4114..4403ef7f858f 100644 --- a/crates/goose-server/src/lib.rs +++ b/crates/goose-server/src/lib.rs @@ -4,6 +4,7 @@ pub mod error; pub mod openapi; pub mod routes; pub mod state; +pub mod tls; pub mod tunnel; // Re-export commonly used items diff --git a/crates/goose-server/src/routes/mcp_app_proxy.rs b/crates/goose-server/src/routes/mcp_app_proxy.rs index 7ff045c20555..934e237d8519 100644 --- a/crates/goose-server/src/routes/mcp_app_proxy.rs +++ b/crates/goose-server/src/routes/mcp_app_proxy.rs @@ -2,10 +2,19 @@ use axum::{ extract::Query, http::{header, StatusCode}, response::{Html, IntoResponse, Response}, - routing::get, - Router, + routing::{get, post}, + Json, Router, }; use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// In-memory store for guest HTML content. +/// Maps nonce -> (html_content, csp_string) +/// Entries are consumed on first read (one-time use). +type GuestHtmlStore = Arc>>; #[derive(Deserialize)] struct ProxyQuery { @@ -18,6 +27,22 @@ struct ProxyQuery { frame_domains: Option, /// Comma-separated list of allowed base URIs (base-uri) base_uri_domains: Option, + /// Comma-separated list of domains for script-src (external scripts like SDKs) + script_domains: Option, +} + +#[derive(Deserialize)] +struct GuestQuery { + secret: String, + nonce: String, +} + +#[derive(Deserialize)] +struct StoreGuestBody { + secret: String, + html: String, + /// CSP string to apply to the guest page + csp: Option, } const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html"); @@ -35,6 +60,7 @@ fn build_outer_csp( resource_domains: &[String], frame_domains: &[String], base_uri_domains: &[String], + script_domains: &[String], ) -> String { let resources = if resource_domains.is_empty() { String::new() @@ -42,16 +68,23 @@ fn build_outer_csp( format!(" {}", resource_domains.join(" ")) }; + let scripts = if script_domains.is_empty() { + String::new() + } else { + format!(" {}", script_domains.join(" ")) + }; + let connections = if connect_domains.is_empty() { String::new() } else { format!(" {}", connect_domains.join(" ")) }; + // frame-src needs 'self' so the proxy can load the guest iframe from /mcp-app-guest let frame_src = if frame_domains.is_empty() { - "frame-src 'none'".to_string() + "frame-src 'self'".to_string() } else { - format!("frame-src {}", frame_domains.join(" ")) + format!("frame-src 'self' {}", frame_domains.join(" ")) }; let base_uris = if base_uri_domains.is_empty() { @@ -62,8 +95,8 @@ fn build_outer_csp( format!( "default-src 'none'; \ - script-src 'self' 'unsafe-inline'{resources}; \ - script-src-elem 'self' 'unsafe-inline'{resources}; \ + script-src 'self' 'unsafe-inline'{resources}{scripts}; \ + script-src-elem 'self' 'unsafe-inline'{resources}{scripts}; \ style-src 'self' 'unsafe-inline'{resources}; \ style-src-elem 'self' 'unsafe-inline'{resources}; \ connect-src 'self'{connections}; \ @@ -88,6 +121,12 @@ fn parse_domains(domains: Option<&String>) -> Vec { .unwrap_or_default() } +#[derive(Clone)] +struct AppState { + secret_key: String, + guest_store: GuestHtmlStore, +} + #[utoipa::path( get, path = "/mcp-app-proxy", @@ -96,7 +135,8 @@ fn parse_domains(domains: Option<&String>) -> Vec { ("connect_domains" = Option, Query, description = "Comma-separated domains for connect-src"), ("resource_domains" = Option, Query, description = "Comma-separated domains for resource loading"), ("frame_domains" = Option, Query, description = "Comma-separated origins for nested iframes (frame-src)"), - ("base_uri_domains" = Option, Query, description = "Comma-separated allowed base URIs (base-uri)") + ("base_uri_domains" = Option, Query, description = "Comma-separated allowed base URIs (base-uri)"), + ("script_domains" = Option, Query, description = "Comma-separated domains for script-src") ), responses( (status = 200, description = "MCP App proxy HTML page", content_type = "text/html"), @@ -104,10 +144,10 @@ fn parse_domains(domains: Option<&String>) -> Vec { ) )] async fn mcp_app_proxy( - axum::extract::State(secret_key): axum::extract::State, + axum::extract::State(state): axum::extract::State, Query(params): Query, ) -> Response { - if params.secret != secret_key { + if params.secret != state.secret_key { return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } @@ -116,6 +156,7 @@ async fn mcp_app_proxy( let resource_domains = parse_domains(params.resource_domains.as_ref()); let frame_domains = parse_domains(params.frame_domains.as_ref()); let base_uri_domains = parse_domains(params.base_uri_domains.as_ref()); + let script_domains = parse_domains(params.script_domains.as_ref()); // Build the outer CSP based on declared domains let csp = build_outer_csp( @@ -123,6 +164,7 @@ async fn mcp_app_proxy( &resource_domains, &frame_domains, &base_uri_domains, + &script_domains, ); // Replace the CSP placeholder in the HTML template @@ -141,8 +183,81 @@ async fn mcp_app_proxy( .into_response() } +/// Store guest HTML and return a nonce for retrieval. +/// The proxy page calls this via fetch, then sets the guest iframe src to /mcp-app-guest?nonce=... +async fn store_guest_html( + axum::extract::State(state): axum::extract::State, + Json(body): Json, +) -> Response { + if body.secret != state.secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + let nonce = Uuid::new_v4().to_string(); + let csp = body.csp.unwrap_or_default(); + + { + let mut store = state.guest_store.write().await; + store.insert(nonce.clone(), (body.html, csp)); + } + + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/json")], + format!(r#"{{"nonce":"{}"}}"#, nonce), + ) + .into_response() +} + +/// Serve stored guest HTML with a real HTTPS URL. +/// This gives the guest iframe `window.location.protocol === "https:"`, +/// which is required by SDKs like Square Web Payments that check for secure context. +async fn serve_guest_html( + axum::extract::State(state): axum::extract::State, + Query(params): Query, +) -> Response { + if params.secret != state.secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + // Consume the entry (one-time use) + let entry = { + let mut store = state.guest_store.write().await; + store.remove(¶ms.nonce) + }; + + match entry { + Some((html, csp)) => { + let mut response = Html(html).into_response(); + let headers = response.headers_mut(); + // Use strict-origin so third-party SDKs (e.g. Square Web Payments) + // receive the origin in their requests, which they need for auth. + // no-referrer would cause 401s from SDK servers. + headers.insert( + header::HeaderName::from_static("referrer-policy"), + "strict-origin".parse().unwrap(), + ); + if !csp.is_empty() { + headers.insert( + header::CONTENT_SECURITY_POLICY, + csp.parse().unwrap(), + ); + } + response + } + None => (StatusCode::NOT_FOUND, "Guest content not found or already consumed").into_response(), + } +} + pub fn routes(secret_key: String) -> Router { + let state = AppState { + secret_key, + guest_store: Arc::new(RwLock::new(HashMap::new())), + }; + Router::new() .route("/mcp-app-proxy", get(mcp_app_proxy)) - .with_state(secret_key) + .route("/mcp-app-guest", get(serve_guest_html)) + .route("/mcp-app-guest", post(store_guest_html)) + .with_state(state) } diff --git a/crates/goose-server/src/routes/templates/mcp_app_proxy.html b/crates/goose-server/src/routes/templates/mcp_app_proxy.html index 28fc99194cf0..638bb6d818a9 100644 --- a/crates/goose-server/src/routes/templates/mcp_app_proxy.html +++ b/crates/goose-server/src/routes/templates/mcp_app_proxy.html @@ -33,7 +33,42 @@ let guestIframe = null; - function createGuestIframe(html, permissions) { + /** + * Extract the secret and base URL from the current page URL. + */ + function getProxyParams() { + var params = new URLSearchParams(window.location.search); + return { + secret: params.get('secret') || '', + baseUrl: window.location.origin + }; + } + + /** + * Store guest HTML on the server and get a nonce for retrieval. + * This allows the guest iframe to load from a real HTTPS URL + * instead of srcdoc (which has about: protocol). + */ + async function storeGuestHtml(html) { + var proxyParams = getProxyParams(); + var response = await fetch(proxyParams.baseUrl + '/mcp-app-guest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + secret: proxyParams.secret, + html: html + }) + }); + + if (!response.ok) { + throw new Error('Failed to store guest HTML: ' + response.status); + } + + var data = await response.json(); + return data.nonce; + } + + async function createGuestIframe(html, permissions) { if (guestIframe) { guestIframe.remove(); } @@ -42,7 +77,7 @@ // Sandbox permissions for the Guest UI // allow-scripts: needed for the app to run - // allow-same-origin: needed for localStorage, cookies, etc. + // allow-same-origin: needed for localStorage, cookies, and real URL context // allow-forms: needed if the app has forms guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms'); @@ -57,9 +92,22 @@ guestIframe.setAttribute('allow', allowList.join('; ')); } - guestIframe.srcdoc = html; guestIframe.style.cssText = 'width:100%; height:100%; border:none;'; + // Store the HTML server-side and load via real URL. + // This gives the guest iframe a real https:// URL instead of about:srcdoc, + // which is required by SDKs (like Square Web Payments) that check + // window.location.protocol for secure context verification. + try { + var nonce = await storeGuestHtml(html); + var proxyParams = getProxyParams(); + guestIframe.src = proxyParams.baseUrl + '/mcp-app-guest?secret=' + encodeURIComponent(proxyParams.secret) + '&nonce=' + encodeURIComponent(nonce); + } catch (e) { + // Fallback to srcdoc if the store endpoint is not available + console.warn('Failed to use /mcp-app-guest endpoint, falling back to srcdoc:', e); + guestIframe.srcdoc = html; + } + document .body .appendChild(guestIframe); diff --git a/crates/goose-server/src/tls.rs b/crates/goose-server/src/tls.rs new file mode 100644 index 000000000000..bd214aa574a9 --- /dev/null +++ b/crates/goose-server/src/tls.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use axum_server::tls_rustls::RustlsConfig; +use rcgen::{CertificateParams, DnType, KeyPair, SanType}; + +/// Generate a self-signed TLS certificate for localhost (127.0.0.1) and +/// return an `axum_server::tls_rustls::RustlsConfig` ready to use. +pub async fn self_signed_config() -> Result { + // rustls 0.23+ requires an explicit crypto provider installation. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::CommonName, "goosed localhost"); + params.subject_alt_names = vec![ + SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), + SanType::DnsName("localhost".try_into()?), + ]; + + let key_pair = KeyPair::generate()?; + let cert = params.self_signed(&key_pair)?; + + let cert_pem = cert.pem(); + let key_pem = key_pair.serialize_pem(); + + let config = RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes()).await?; + + Ok(config) +} diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index 61a6309873a8..86c941adb9de 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -15,7 +15,7 @@ * - "standalone" — Goose-specific mode for dedicated Electron windows */ -import { AppRenderer } from '@mcp-ui/client'; +import { AppRenderer, RequestHandlerExtra } from '@mcp-ui/client'; import type { McpUiDisplayMode, McpUiHostContext, @@ -23,7 +23,7 @@ import type { McpUiResourcePermissions, McpUiSizeChangedNotification, } from '@modelcontextprotocol/ext-apps/app-bridge'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { callTool, readResource } from '../../api'; import { AppEvents } from '../../constants/events'; @@ -383,6 +383,15 @@ export default function McpAppRenderer({ [] ); + const handleFallbackRequest = useCallback( + async (request: JSONRPCRequest, _extra: RequestHandlerExtra) => { + console.log('Fallback request:', request.method); + // todo: add `sampling/createMessage` per https://github.com/block/goose/pull/7039 + return { status: 'success' as const }; + }, + [] + ); + /** * Height: non-positive values are ignored (keeps previous height). * Width: if provided, container uses that width (capped at 100%); @@ -407,7 +416,6 @@ export default function McpAppRenderer({ const meta = getMeta(state); const html = getHtml(state); - const readyCsp = state.status === 'ready' ? state.sandboxCsp : null; const mcpUiCsp = useMemo((): McpUiResourceCsp | undefined => { if (!readyCsp) return undefined; @@ -517,6 +525,7 @@ export default function McpAppRenderer({ onLoggingMessage={handleLoggingMessage} onSizeChanged={handleSizeChanged} onError={handleError} + onFallbackRequest={handleFallbackRequest} /> ); }; diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 2528a816e833..1f1bc7d5bfb7 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -215,7 +215,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { + const parsed = new URL(url); + if (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost') { + event.preventDefault(); + callback(true); + } else { + callback(false); + } +}); + +app.whenReady().then(() => { + session.defaultSession.setCertificateVerifyProc((request, callback) => { + if (request.hostname === '127.0.0.1' || request.hostname === 'localhost') { + callback(0); // Accept + } else { + callback(-3); // Use default verification + } + }); +}); + if (process.env.ENABLE_PLAYWRIGHT) { const debugPort = process.env.PLAYWRIGHT_DEBUG_PORT || '9222'; console.log(`[Main] Enabling Playwright remote debugging on port ${debugPort}`); @@ -454,7 +480,7 @@ let appConfig = { GOOSE_DEFAULT_PROVIDER: defaultProvider, GOOSE_DEFAULT_MODEL: defaultModel, GOOSE_PREDEFINED_MODELS: predefinedModels, - GOOSE_API_HOST: 'http://127.0.0.1', + GOOSE_API_HOST: 'http://localhost', GOOSE_WORKING_DIR: '', // If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode) GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true', @@ -546,6 +572,7 @@ const createChat = async ( const goosedClient = createClient( createConfig({ baseUrl, + fetch: net.fetch as unknown as typeof globalThis.fetch, headers: { 'Content-Type': 'application/json', 'X-Secret-Key': serverSecret, @@ -1648,6 +1675,9 @@ async function appMain() { const sources = [ "'self'", 'http://127.0.0.1:*', + 'https://127.0.0.1:*', + 'http://localhost:*', + 'https://localhost:*', 'https://api.github.com', 'https://github.com', 'https://objects.githubusercontent.com',