Skip to content

Commit 977eea5

Browse files
committed
Harden MCP app proxy bridge
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
1 parent 10cf2c7 commit 977eea5

4 files changed

Lines changed: 129 additions & 66 deletions

File tree

crates/goose/src/acp/mcp_app_proxy.rs

Lines changed: 110 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
11
use axum::{
2-
extract::Query,
3-
http::{header, StatusCode},
2+
extract::{Query, State},
3+
http::{header, HeaderValue, StatusCode},
44
response::{Html, IntoResponse, Response},
55
routing::{get, post},
66
Json, Router,
77
};
8-
use serde::Deserialize;
8+
use serde::{Deserialize, Serialize};
99
use std::collections::HashMap;
1010
use std::sync::Arc;
11+
use std::time::Duration;
1112
use std::time::Instant;
1213
use tokio::sync::RwLock;
14+
use url::Url;
1315
use uuid::Uuid;
1416

1517
const GUEST_HTML_TTL_SECS: u64 = 300;
1618
const GUEST_HTML_MAX_ENTRIES: usize = 64;
1719
const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html");
1820

19-
type GuestHtmlStore = Arc<RwLock<HashMap<String, (String, String, Instant)>>>;
21+
type GuestHtmlStore = Arc<RwLock<HashMap<String, GuestHtmlEntry>>>;
22+
23+
#[derive(Clone)]
24+
struct GuestHtmlEntry {
25+
html: String,
26+
csp: String,
27+
created: Instant,
28+
}
2029

2130
#[derive(Deserialize)]
2231
struct ProxyQuery {
@@ -30,7 +39,6 @@ struct ProxyQuery {
3039

3140
#[derive(Deserialize)]
3241
struct GuestQuery {
33-
secret: String,
3442
nonce: String,
3543
}
3644

@@ -41,19 +49,49 @@ struct StoreGuestBody {
4149
csp: Option<String>,
4250
}
4351

52+
#[derive(Serialize)]
53+
#[serde(rename_all = "camelCase")]
54+
struct StoreGuestResponse {
55+
nonce: String,
56+
guest_url: String,
57+
}
58+
4459
#[derive(Clone)]
4560
struct AppState {
4661
secret_key: String,
4762
guest_store: GuestHtmlStore,
63+
guest_base_url: String,
64+
}
65+
66+
#[derive(Clone)]
67+
struct GuestState {
68+
guest_store: GuestHtmlStore,
69+
}
70+
71+
fn normalize_csp_source(source: &str) -> Option<String> {
72+
let source = source.trim();
73+
if source.is_empty()
74+
|| source
75+
.chars()
76+
.any(|c| c.is_ascii_whitespace() || matches!(c, ';' | ',' | '"' | '\''))
77+
{
78+
return None;
79+
}
80+
81+
let url = Url::parse(source).ok()?;
82+
if !matches!(url.scheme(), "http" | "https" | "ws" | "wss") {
83+
return None;
84+
}
85+
url.host_str()?;
86+
Some(url.origin().ascii_serialization())
4887
}
4988

5089
fn parse_domains(domains: Option<&String>) -> Vec<String> {
5190
domains
5291
.map(|domains| {
5392
domains
5493
.split(',')
55-
.map(|domain| domain.trim().to_string())
56-
.filter(|domain| !domain.is_empty())
94+
.filter_map(normalize_csp_source)
5795
.collect()
5896
})
5997
.unwrap_or_default()
@@ -65,6 +103,7 @@ fn build_outer_csp(
65103
frame_domains: &[String],
66104
base_uri_domains: &[String],
67105
script_domains: &[String],
106+
guest_origin: &str,
68107
) -> String {
69108
let resources = if resource_domains.is_empty() {
70109
String::new()
@@ -85,9 +124,12 @@ fn build_outer_csp(
85124
};
86125

87126
let frame_src = if frame_domains.is_empty() {
88-
"frame-src 'self'".to_string()
127+
format!("frame-src 'self' {guest_origin}")
89128
} else {
90-
format!("frame-src 'self' {}", frame_domains.join(" "))
129+
format!(
130+
"frame-src 'self' {guest_origin} {}",
131+
frame_domains.join(" ")
132+
)
91133
};
92134

93135
let base_uris = if base_uri_domains.is_empty() {
@@ -113,7 +155,7 @@ fn build_outer_csp(
113155
}
114156

115157
async fn mcp_app_proxy(
116-
axum::extract::State(state): axum::extract::State<AppState>,
158+
State(state): State<AppState>,
117159
Query(params): Query<ProxyQuery>,
118160
) -> Response {
119161
if params.secret != state.secret_key {
@@ -128,6 +170,7 @@ async fn mcp_app_proxy(
128170
&parse_domains(params.frame_domains.as_ref()),
129171
&parse_domains(params.base_uri_domains.as_ref()),
130172
&parse_domains(params.script_domains.as_ref()),
173+
&state.guest_base_url,
131174
),
132175
);
133176

@@ -145,7 +188,7 @@ async fn mcp_app_proxy(
145188
}
146189

147190
async fn store_guest_html(
148-
axum::extract::State(state): axum::extract::State<AppState>,
191+
State(state): State<AppState>,
149192
Json(body): Json<StoreGuestBody>,
150193
) -> Response {
151194
if body.secret != state.secret_key {
@@ -154,76 +197,109 @@ async fn store_guest_html(
154197

155198
let nonce = Uuid::new_v4().to_string();
156199
let csp = body.csp.unwrap_or_default();
200+
let guest_url = format!("{}/mcp-app-guest?nonce={}", state.guest_base_url, nonce);
157201

158202
{
159203
let mut store = state.guest_store.write().await;
160-
let cutoff = Instant::now() - std::time::Duration::from_secs(GUEST_HTML_TTL_SECS);
161-
store.retain(|_, (_, _, created)| *created > cutoff);
204+
let cutoff = Instant::now() - Duration::from_secs(GUEST_HTML_TTL_SECS);
205+
store.retain(|_, entry| entry.created > cutoff);
162206

163207
if store.len() >= GUEST_HTML_MAX_ENTRIES {
164208
if let Some(oldest_key) = store
165209
.iter()
166-
.min_by_key(|(_, (_, _, created))| *created)
210+
.min_by_key(|(_, entry)| entry.created)
167211
.map(|(key, _)| key.clone())
168212
{
169213
store.remove(&oldest_key);
170214
}
171215
}
172216

173-
store.insert(nonce.clone(), (body.html, csp, Instant::now()));
217+
store.insert(
218+
nonce.clone(),
219+
GuestHtmlEntry {
220+
html: body.html,
221+
csp,
222+
created: Instant::now(),
223+
},
224+
);
174225
}
175226

176227
(
177228
StatusCode::OK,
178-
[(header::CONTENT_TYPE, "application/json")],
179-
format!(r#"{{"nonce":"{}"}}"#, nonce),
229+
Json(StoreGuestResponse { nonce, guest_url }),
180230
)
181231
.into_response()
182232
}
183233

184234
async fn serve_guest_html(
185-
axum::extract::State(state): axum::extract::State<AppState>,
235+
State(state): State<GuestState>,
186236
Query(params): Query<GuestQuery>,
187237
) -> Response {
188-
if params.secret != state.secret_key {
189-
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
190-
}
191-
192238
let entry = {
193239
let mut store = state.guest_store.write().await;
194-
store.remove(&params.nonce)
240+
let cutoff = Instant::now() - Duration::from_secs(GUEST_HTML_TTL_SECS);
241+
store.retain(|_, entry| entry.created > cutoff);
242+
store.get(&params.nonce).cloned()
195243
};
196244

197245
match entry {
198-
Some((html, csp, _created)) => {
199-
let mut response = Html(html).into_response();
246+
Some(entry) => {
247+
let mut response = Html(entry.html).into_response();
200248
let headers = response.headers_mut();
201249
headers.insert(
202250
header::HeaderName::from_static("referrer-policy"),
203251
"strict-origin".parse().unwrap(),
204252
);
205-
if !csp.is_empty() {
206-
headers.insert(header::CONTENT_SECURITY_POLICY, csp.parse().unwrap());
253+
if !entry.csp.is_empty() {
254+
match HeaderValue::from_str(&entry.csp) {
255+
Ok(csp) => {
256+
headers.insert(header::CONTENT_SECURITY_POLICY, csp);
257+
}
258+
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid CSP").into_response(),
259+
}
207260
}
208261
response
209262
}
210-
None => (
211-
StatusCode::NOT_FOUND,
212-
"Guest content not found or already consumed",
213-
)
214-
.into_response(),
263+
None => (StatusCode::NOT_FOUND, "Guest content not found").into_response(),
215264
}
216265
}
217266

267+
fn spawn_guest_server(guest_store: GuestHtmlStore) -> String {
268+
let listener =
269+
std::net::TcpListener::bind(("127.0.0.1", 0)).expect("failed to bind MCP app guest server");
270+
let addr = listener
271+
.local_addr()
272+
.expect("failed to read MCP app guest server address");
273+
listener
274+
.set_nonblocking(true)
275+
.expect("failed to configure MCP app guest server");
276+
let listener = tokio::net::TcpListener::from_std(listener)
277+
.expect("failed to create MCP app guest listener");
278+
279+
let app = Router::new()
280+
.route("/mcp-app-guest", get(serve_guest_html))
281+
.with_state(GuestState { guest_store });
282+
283+
tokio::spawn(async move {
284+
if let Err(error) = axum::serve(listener, app).await {
285+
tracing::error!(%error, "MCP app guest server stopped");
286+
}
287+
});
288+
289+
format!("http://{addr}")
290+
}
291+
218292
pub(crate) fn routes(secret_key: String) -> Router {
293+
let guest_store = Arc::new(RwLock::new(HashMap::new()));
294+
let guest_base_url = spawn_guest_server(guest_store.clone());
219295
let state = AppState {
220296
secret_key,
221-
guest_store: Arc::new(RwLock::new(HashMap::new())),
297+
guest_store,
298+
guest_base_url,
222299
};
223300

224301
Router::new()
225302
.route("/mcp-app-proxy", get(mcp_app_proxy))
226-
.route("/mcp-app-guest", get(serve_guest_html))
227303
.route("/mcp-app-guest", post(store_guest_html))
228304
.with_state(state)
229305
}

crates/goose/src/acp/server.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3011,17 +3011,20 @@ impl GooseAcpAgent {
30113011
let agent = self.get_session_agent(&req.session_id, None).await?;
30123012
let tools = agent.list_tools(&internal_id, None).await;
30133013

3014-
if let Some(tool) = tools.iter().find(|t| *t.name == req.name) {
3015-
if !is_tool_visible_to_app(tool) {
3016-
return Err(
3017-
sacp::Error::invalid_params().data("tool is not visible to app clients")
3018-
);
3019-
}
3014+
let Some(tool) = tools.iter().find(|t| *t.name == req.name) else {
3015+
return Err(sacp::Error::invalid_params().data("tool not found"));
3016+
};
3017+
3018+
if !is_tool_visible_to_app(tool) {
3019+
return Err(sacp::Error::invalid_params().data("tool is not visible to app clients"));
30203020
}
30213021

30223022
let arguments = match req.arguments {
30233023
serde_json::Value::Object(map) => Some(map),
3024-
_ => None,
3024+
serde_json::Value::Null => None,
3025+
_ => {
3026+
return Err(sacp::Error::invalid_params().data("tool arguments must be an object"));
3027+
}
30253028
};
30263029

30273030
let tool_call = {

crates/goose/src/acp/templates/mcp_app_proxy.html

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,14 @@
9393

9494
async function storeGuestHtml(html) {
9595
var proxyParams = getProxyParams();
96+
var cspMeta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
9697
var response = await fetch(proxyParams.baseUrl + '/mcp-app-guest', {
9798
method: 'POST',
9899
headers: { 'Content-Type': 'application/json' },
99100
body: JSON.stringify({
100101
secret: proxyParams.secret,
101-
html: html
102+
html: html,
103+
csp: cspMeta ? cspMeta.content : ''
102104
})
103105
});
104106

@@ -107,7 +109,10 @@
107109
}
108110

109111
var data = await response.json();
110-
return data.nonce;
112+
if (typeof data.guestUrl !== 'string') {
113+
throw new Error('Guest URL missing from store response');
114+
}
115+
return data.guestUrl;
111116
}
112117

113118
async function createGuestIframe(html, permissions) {
@@ -117,6 +122,7 @@
117122

118123
guestIframe = document.createElement('iframe');
119124
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
125+
guestIframe.referrerPolicy = 'no-referrer';
120126

121127
var allowList = [];
122128
if (permissions && permissions.camera) allowList.push('camera');
@@ -133,8 +139,7 @@
133139
var guestHtml = injectGuestColorScheme(html, proxyParams.colorScheme);
134140

135141
try {
136-
var nonce = await storeGuestHtml(guestHtml);
137-
guestIframe.src = proxyParams.baseUrl + '/mcp-app-guest?secret=' + encodeURIComponent(proxyParams.secret) + '&nonce=' + encodeURIComponent(nonce);
142+
guestIframe.src = await storeGuestHtml(guestHtml);
138143
} catch (e) {
139144
console.warn('Failed to use /mcp-app-guest endpoint, falling back to srcdoc:', e);
140145
guestIframe.srcdoc = guestHtml;

ui/goose2/src/shared/api/gooseServeHost.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,3 @@ export interface GooseServeHostInfo {
99
export async function getGooseServeHostInfo(): Promise<GooseServeHostInfo> {
1010
return invoke<GooseServeHostInfo>("get_goose_serve_host_info");
1111
}
12-
13-
export async function postGooseServeJson<TResponse>(
14-
path: string,
15-
body: unknown,
16-
): Promise<TResponse> {
17-
const { httpBaseUrl, secretKey } = await getGooseServeHostInfo();
18-
const response = await fetch(`${httpBaseUrl}${path}`, {
19-
method: "POST",
20-
headers: {
21-
"Content-Type": "application/json",
22-
"X-Secret-Key": secretKey,
23-
},
24-
body: JSON.stringify(body),
25-
});
26-
27-
if (!response.ok) {
28-
throw new Error(`Goose serve request failed (${response.status})`);
29-
}
30-
31-
return (await response.json()) as TResponse;
32-
}

0 commit comments

Comments
 (0)