Skip to content

Commit 0a91482

Browse files
committed
Address MCP app safety review feedback
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
1 parent cb452d8 commit 0a91482

6 files changed

Lines changed: 200 additions & 52 deletions

File tree

crates/goose-cli/src/cli.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,11 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) ->
11031103
info!("Starting ACP server on {}", addr);
11041104

11051105
let listener = tokio::net::TcpListener::bind(addr).await?;
1106-
axum::serve(listener, router).await?;
1106+
axum::serve(
1107+
listener,
1108+
router.into_make_service_with_connect_info::<SocketAddr>(),
1109+
)
1110+
.await?;
11071111

11081112
Ok(())
11091113
}

crates/goose/src/acp/mcp_app_proxy.rs

Lines changed: 23 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
use axum::{
2-
extract::{Query, State},
3-
http::{header, HeaderMap, HeaderValue, StatusCode},
2+
extract::{ConnectInfo, Query, State},
3+
http::{header, HeaderValue, StatusCode},
44
response::{Html, IntoResponse, Response},
55
routing::{get, post},
66
Json, Router,
77
};
88
use serde::{Deserialize, Serialize};
99
use std::collections::HashMap;
10+
use std::net::SocketAddr;
1011
use std::sync::Arc;
1112
use std::time::Duration;
1213
use std::time::Instant;
@@ -147,35 +148,8 @@ fn is_valid_dns_label(label: &str) -> bool {
147148
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
148149
}
149150

150-
fn request_host_is_loopback(headers: &HeaderMap) -> bool {
151-
headers
152-
.get(header::HOST)
153-
.and_then(|host| host.to_str().ok())
154-
.is_some_and(authority_is_loopback)
155-
}
156-
157-
fn authority_is_loopback(authority: &str) -> bool {
158-
if authority.contains('@') {
159-
return false;
160-
}
161-
162-
let host = if let Some(remainder) = authority.strip_prefix('[') {
163-
remainder
164-
.split_once(']')
165-
.map(|(host, _)| host)
166-
.unwrap_or(remainder)
167-
} else {
168-
authority.split(':').next().unwrap_or(authority)
169-
};
170-
171-
let host = host.to_ascii_lowercase();
172-
host == "localhost"
173-
|| host
174-
.parse::<std::net::Ipv4Addr>()
175-
.is_ok_and(|addr| addr.is_loopback())
176-
|| host
177-
.parse::<std::net::Ipv6Addr>()
178-
.is_ok_and(|addr| addr.is_loopback())
151+
fn peer_addr_is_loopback(peer_addr: &SocketAddr) -> bool {
152+
peer_addr.ip().is_loopback()
179153
}
180154

181155
fn parse_domains(domains: Option<&String>) -> Vec<String> {
@@ -248,16 +222,16 @@ fn build_outer_csp(
248222

249223
async fn mcp_app_proxy(
250224
State(state): State<AppState>,
251-
headers: HeaderMap,
225+
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
252226
Query(params): Query<ProxyQuery>,
253227
) -> Response {
254228
if params.secret != state.secret_key {
255229
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
256230
}
257-
if !request_host_is_loopback(&headers) {
231+
if !peer_addr_is_loopback(&peer_addr) {
258232
return (
259233
StatusCode::BAD_REQUEST,
260-
"MCP app proxy is only available from a loopback host",
234+
"MCP app proxy is only available to loopback clients",
261235
)
262236
.into_response();
263237
}
@@ -289,16 +263,16 @@ async fn mcp_app_proxy(
289263

290264
async fn store_guest_html(
291265
State(state): State<AppState>,
292-
headers: HeaderMap,
266+
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
293267
Json(body): Json<StoreGuestBody>,
294268
) -> Response {
295269
if body.secret != state.secret_key {
296270
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
297271
}
298-
if !request_host_is_loopback(&headers) {
272+
if !peer_addr_is_loopback(&peer_addr) {
299273
return (
300274
StatusCode::BAD_REQUEST,
301-
"MCP app guest storage is only available from a loopback host",
275+
"MCP app guest storage is only available to loopback clients",
302276
)
303277
.into_response();
304278
}
@@ -414,7 +388,8 @@ pub(crate) fn routes(secret_key: String) -> Router {
414388

415389
#[cfg(test)]
416390
mod tests {
417-
use super::{authority_is_loopback, normalize_csp_source, parse_domains};
391+
use super::{normalize_csp_source, parse_domains, peer_addr_is_loopback};
392+
use std::net::SocketAddr;
418393

419394
#[test]
420395
fn normalizes_url_sources_to_origins() {
@@ -470,10 +445,15 @@ mod tests {
470445
}
471446

472447
#[test]
473-
fn detects_loopback_authorities() {
474-
assert!(authority_is_loopback("127.0.0.1:12345"));
475-
assert!(authority_is_loopback("localhost:12345"));
476-
assert!(authority_is_loopback("[::1]:12345"));
477-
assert!(!authority_is_loopback("example.test"));
448+
fn detects_loopback_peer_addresses() {
449+
assert!(peer_addr_is_loopback(
450+
&"127.0.0.1:12345".parse::<SocketAddr>().unwrap()
451+
));
452+
assert!(peer_addr_is_loopback(
453+
&"[::1]:12345".parse::<SocketAddr>().unwrap()
454+
));
455+
assert!(!peer_addr_is_loopback(
456+
&"192.168.1.10:12345".parse::<SocketAddr>().unwrap()
457+
));
478458
}
479459
}

ui/goose2/src/features/chat/ui/McpAppView.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
type McpUiHostContext,
55
} from "@mcp-ui/client";
66
import type { GooseToolCallResponse } from "@aaif/goose-sdk";
7-
import { openUrl } from "@tauri-apps/plugin-opener";
87
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
98
import { useTranslation } from "react-i18next";
109
import packageJson from "../../../../package.json";
@@ -15,9 +14,11 @@ import type {
1514
McpAppPayload,
1615
ToolResponseContent,
1716
} from "@/shared/types/messages";
17+
import { LinkSafetyModal } from "@/shared/ui/ai-elements/link-safety-modal";
1818
import type { McpAppMessageHandler } from "./mcpAppTypes";
1919
import { extractRenderableMcpAppDocument } from "./mcpAppPayload";
2020
import { useIframeColorScheme } from "./useIframeColorScheme";
21+
import { useMcpAppOpenLink } from "./useMcpAppOpenLink";
2122
import { useMcpAppSandbox } from "./useMcpAppSandbox";
2223

2324
interface McpAppViewProps {
@@ -128,6 +129,12 @@ export function McpAppView({
128129
const [containerWidth, setContainerWidth] = useState<number | null>(null);
129130
const autoScrollTimersRef = useRef<number[]>([]);
130131
const rootRef = useRef<HTMLDivElement>(null);
132+
const {
133+
handleConfirmOpenLink,
134+
handleOpenLink,
135+
handleOpenLinkModalClose,
136+
pendingOpenLinkUrl,
137+
} = useMcpAppOpenLink();
131138
useIframeColorScheme(rootRef, resolvedTheme);
132139

133140
const renderableDocument = useMemo(
@@ -281,11 +288,6 @@ export function McpAppView({
281288
[containerWidth, inlineHeight, payload, resolvedTheme],
282289
);
283290

284-
const handleOpenLink = useCallback(async ({ url }: { url: string }) => {
285-
await openUrl(url);
286-
return { status: "success" as const };
287-
}, []);
288-
289291
const handleMessage = useCallback(
290292
async ({ role, content }: MessageParams) => {
291293
if (role !== "user" || !onSendMessage) {
@@ -447,6 +449,12 @@ export function McpAppView({
447449
)}
448450
</div>
449451
)}
452+
<LinkSafetyModal
453+
isOpen={pendingOpenLinkUrl !== null}
454+
onClose={handleOpenLinkModalClose}
455+
onOpenLink={handleConfirmOpenLink}
456+
url={pendingOpenLinkUrl ?? ""}
457+
/>
450458
</div>
451459
);
452460
}

ui/goose2/src/features/chat/ui/__tests__/McpAppView.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AppRendererProps, RequestHandlerExtra } from "@mcp-ui/client";
2+
import { openUrl } from "@tauri-apps/plugin-opener";
23
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
34
import { beforeEach, describe, expect, it, vi } from "vitest";
45
import { McpAppView } from "../McpAppView";
@@ -136,6 +137,8 @@ describe("McpAppView nested tool calls", () => {
136137
mocks.extMethod.mockReset();
137138
mocks.getClient.mockReset();
138139
mocks.resolvedTheme = "dark";
140+
vi.mocked(openUrl).mockReset();
141+
vi.mocked(openUrl).mockResolvedValue(undefined);
139142
mocks.getClient.mockResolvedValue({
140143
extMethod: mocks.extMethod,
141144
});
@@ -271,6 +274,69 @@ describe("McpAppView nested tool calls", () => {
271274
expect(getLatestAppRendererProps().onFallbackRequest).toBeUndefined();
272275
});
273276

277+
it("confirms safe app open-link requests before opening the URL", async () => {
278+
render(
279+
<McpAppView
280+
payload={createPayload()}
281+
toolResponse={createToolResponse()}
282+
/>,
283+
);
284+
285+
await waitFor(() => {
286+
expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument();
287+
});
288+
289+
const promise = getLatestAppRendererProps().onOpenLink?.(
290+
{ url: "https://example.com" },
291+
{} as RequestHandlerExtra,
292+
);
293+
294+
if (!promise) {
295+
throw new Error("Expected onOpenLink to be registered");
296+
}
297+
298+
expect(openUrl).not.toHaveBeenCalled();
299+
300+
await waitFor(() => {
301+
expect(screen.getByText("https://example.com/")).toBeInTheDocument();
302+
});
303+
304+
fireEvent.click(screen.getByRole("button", { name: "Open link" }));
305+
306+
await waitFor(() => {
307+
expect(openUrl).toHaveBeenCalledWith("https://example.com/");
308+
});
309+
await expect(promise).resolves.toEqual({});
310+
});
311+
312+
it("blocks app open-link requests for non-web URL schemes", async () => {
313+
render(
314+
<McpAppView
315+
payload={createPayload()}
316+
toolResponse={createToolResponse()}
317+
/>,
318+
);
319+
320+
await waitFor(() => {
321+
expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument();
322+
});
323+
324+
const result = await getLatestAppRendererProps().onOpenLink?.(
325+
{ url: "file:///private/tmp/secrets.txt" },
326+
{} as RequestHandlerExtra,
327+
);
328+
329+
expect(result).toEqual(
330+
expect.objectContaining({
331+
isError: true,
332+
}),
333+
);
334+
expect(openUrl).not.toHaveBeenCalled();
335+
expect(
336+
screen.queryByText("file:///private/tmp/secrets.txt"),
337+
).not.toBeInTheDocument();
338+
});
339+
274340
it("keeps the iframe color scheme aligned with the host theme", async () => {
275341
mocks.resolvedTheme = "light";
276342
const payload = createPayload();
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { AppRendererProps } from "@mcp-ui/client";
2+
import { openUrl } from "@tauri-apps/plugin-opener";
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
5+
type OpenLinkParams = Parameters<
6+
NonNullable<AppRendererProps["onOpenLink"]>
7+
>[0];
8+
type OpenLinkResult = Awaited<
9+
ReturnType<NonNullable<AppRendererProps["onOpenLink"]>>
10+
>;
11+
12+
function normalizeOpenLinkUrl(url: string): string | null {
13+
try {
14+
const parsed = new URL(url);
15+
return parsed.protocol === "http:" || parsed.protocol === "https:"
16+
? parsed.href
17+
: null;
18+
} catch {
19+
return null;
20+
}
21+
}
22+
23+
export function useMcpAppOpenLink() {
24+
const [pendingOpenLinkUrl, setPendingOpenLinkUrl] = useState<string | null>(
25+
null,
26+
);
27+
const pendingOpenLinkResolverRef = useRef<
28+
((result: OpenLinkResult) => void) | null
29+
>(null);
30+
31+
useEffect(
32+
() => () => {
33+
pendingOpenLinkResolverRef.current?.({ isError: true });
34+
pendingOpenLinkResolverRef.current = null;
35+
},
36+
[],
37+
);
38+
39+
const finishOpenLinkRequest = useCallback((result: OpenLinkResult) => {
40+
pendingOpenLinkResolverRef.current?.(result);
41+
pendingOpenLinkResolverRef.current = null;
42+
setPendingOpenLinkUrl(null);
43+
}, []);
44+
45+
const handleOpenLink = useCallback(async ({ url }: OpenLinkParams) => {
46+
const safeUrl = normalizeOpenLinkUrl(url);
47+
if (!safeUrl) {
48+
return {
49+
isError: true,
50+
message: "Only http and https links can be opened.",
51+
};
52+
}
53+
54+
pendingOpenLinkResolverRef.current?.({
55+
isError: true,
56+
message: "Another open-link request replaced this request.",
57+
});
58+
59+
return new Promise<OpenLinkResult>((resolve) => {
60+
pendingOpenLinkResolverRef.current = resolve;
61+
setPendingOpenLinkUrl(safeUrl);
62+
});
63+
}, []);
64+
65+
const handleOpenLinkModalClose = useCallback(() => {
66+
finishOpenLinkRequest({ isError: true });
67+
}, [finishOpenLinkRequest]);
68+
69+
const handleConfirmOpenLink = useCallback(
70+
async (url: string) => {
71+
try {
72+
await openUrl(url);
73+
finishOpenLinkRequest({});
74+
} catch (error) {
75+
finishOpenLinkRequest({ isError: true });
76+
throw error;
77+
}
78+
},
79+
[finishOpenLinkRequest],
80+
);
81+
82+
return {
83+
handleConfirmOpenLink,
84+
handleOpenLink,
85+
handleOpenLinkModalClose,
86+
pendingOpenLinkUrl,
87+
};
88+
}

ui/goose2/src/shared/ui/ai-elements/link-safety-modal.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import {
1414
interface LinkSafetyModalProps {
1515
isOpen: boolean;
1616
onClose: () => void;
17+
onOpenLink?: (url: string) => Promise<void>;
1718
url: string;
1819
}
1920

2021
export function LinkSafetyModal({
2122
isOpen,
2223
onClose,
24+
onOpenLink,
2325
url,
2426
}: LinkSafetyModalProps) {
2527
const { t } = useTranslation("common");
@@ -39,12 +41,12 @@ export function LinkSafetyModal({
3941

4042
const handleOpen = useCallback(async () => {
4143
try {
42-
await openUrl(url);
44+
await (onOpenLink ?? openUrl)(url);
4345
} catch (e: unknown) {
4446
console.error("[linkSafety] openUrl failed:", e);
4547
}
4648
onClose();
47-
}, [url, onClose]);
49+
}, [url, onClose, onOpenLink]);
4850

4951
const handleCopy = useCallback(() => {
5052
if (isCopied) return;

0 commit comments

Comments
 (0)