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
2 changes: 1 addition & 1 deletion crates/goose-server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub async fn check_token(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
if request.uri().path() == "/status" {
if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" {
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bypassing authentication for /mcp-ui-proxy removes the security protections from the original implementation (token validation, origin checks, WebContents whitelisting). This allows unauthenticated access to MCP UI resources. Consider requiring the X-Secret-Key header or document why this endpoint must be public.

Suggested change
if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" {
if request.uri().path() == "/status" {

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I support simplifying the complexity of security protections in the Node implementation. I think our goal should be to prevent access to the route outside of Goose (like directly in-browser and/or via CURL).

Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication bypass: /mcp-ui-proxy is excluded from the global auth middleware check (line 13), but performs its own authentication using query parameters. This creates an inconsistent security model. If the query parameter check fails or is bypassed, the endpoint becomes publicly accessible. Consider either: (1) keeping /mcp-ui-proxy in the middleware and moving secret validation there, or (2) ensuring the endpoint's authentication is robust enough to stand alone.

Suggested change
if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" {
if request.uri().path() == "/status" {

Copilot uses AI. Check for mistakes.
return Ok(next.run(request).await);
}
let secret_key = request
Expand Down
2 changes: 1 addition & 1 deletion crates/goose-server/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub async fn run() -> Result<()> {
.allow_methods(Any)
.allow_headers(Any);

let app = crate::routes::configure(app_state)
let app = crate::routes::configure(app_state, secret_key.clone())
.layer(middleware::from_fn_with_state(
secret_key.clone(),
check_token,
Expand Down
1 change: 1 addition & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ derive_utoipa!(Icon as IconSchema);
paths(
super::routes::status::status,
super::routes::status::diagnostics,
super::routes::mcp_ui_proxy::mcp_ui_proxy,
super::routes::config_management::backup_config,
super::routes::config_management::recover_config,
super::routes::config_management::validate_config,
Expand Down
47 changes: 47 additions & 0 deletions crates/goose-server/src/routes/mcp_ui_proxy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use axum::{
extract::Query,
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use serde::Deserialize;

#[derive(Deserialize)]
struct ProxyQuery {
secret: String,
}

const MCP_UI_PROXY_HTML: &str = include_str!("templates/mcp_ui_proxy.html");

#[utoipa::path(
get,
path = "/mcp-ui-proxy",
params(
("secret" = String, Query, description = "Secret key for authentication")
),
responses(
(status = 200, description = "MCP UI proxy HTML page", content_type = "text/html"),
(status = 401, description = "Unauthorized - invalid or missing secret"),
)
)]
async fn mcp_ui_proxy(
axum::extract::State(secret_key): axum::extract::State<String>,
Query(params): Query<ProxyQuery>,
) -> Response {
if params.secret != secret_key {
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timing attack vulnerability: string comparison using != operator is not constant-time. An attacker could use timing differences to guess the secret key character by character. Use a constant-time comparison function instead, such as subtle::ConstantTimeEq from the subtle crate.

Copilot uses AI. Check for mistakes.
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}

(
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
Html(MCP_UI_PROXY_HTML),
)
.into_response()
}

pub fn routes(secret_key: String) -> Router {
Router::new()
.route("/mcp-ui-proxy", get(mcp_ui_proxy))
.with_state(secret_key)
}
4 changes: 3 additions & 1 deletion crates/goose-server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod agent;
pub mod audio;
pub mod config_management;
pub mod errors;
pub mod mcp_ui_proxy;
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mcp_ui_proxy.rs module file doesn't exist yet. This will cause a compilation error.

Copilot uses AI. Check for mistakes.
pub mod recipe;
pub mod recipe_utils;
pub mod reply;
Expand All @@ -16,7 +17,7 @@ use std::sync::Arc;
use axum::Router;

// Function to configure all routes
pub fn configure(state: Arc<crate::state::AppState>) -> Router {
pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Router {
Router::new()
.merge(status::routes())
.merge(reply::routes(state.clone()))
Expand All @@ -27,4 +28,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
.merge(session::routes(state.clone()))
.merge(schedule::routes(state.clone()))
.merge(setup::routes(state.clone()))
.merge(mcp_ui_proxy::routes(secret_key))
}
141 changes: 141 additions & 0 deletions crates/goose-server/src/routes/templates/mcp_ui_proxy.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<!--
Permissive CSP so nested content is not constrained by top-level Goose Desktop CSP
- default-src: Fallback for other directives (allows same-origin)
- script-src: Allow scripts from any origin, inline, eval, wasm, and blob URLs
- style-src: Allow styles from any origin and inline styles
- font-src: Allow fonts from any origin
- connect-src: Allow network requests to any origin
- frame-src: Allow embedding iframes from any origin (required for proxy functionality)
- media-src: Allow audio/video media from any origin
- base-uri: Restrict <base> tag to same-origin only
- upgrade-insecure-requests: Automatically upgrade HTTP to HTTPS
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src * 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' blob:; style-src * 'unsafe-inline'; font-src *; connect-src *; frame-src *; media-src *; base-uri 'self'; upgrade-insecure-requests"/>
<title>MCP-UI Proxy</title>
<style>
body,
html {
margin: 0;
height: 100vh;
width: 100vw;
}
body {
display: flex;
flex-direction: column;
}
* {
box-sizing: border-box;
}
iframe {
background-color: transparent;
border: 0 none transparent;
padding: 0;
overflow: hidden;
flex-grow: 1;
}
</style>
</head>
<body>
<script>
const params = new URLSearchParams(location.search);
const contentType = params.get('contentType');
const target = params.get('url');

// Validate that the URL is a valid HTTP or HTTPS URL
function isValidHttpUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (error) {
return false;
}
}

if (contentType === 'rawhtml') {
// Double-iframe raw HTML mode (HTML sent via postMessage)
const inner = document.createElement('iframe');
inner.style = 'width:100%; height:100%; border:none;';
// sandbox will be set from postMessage payload; default minimal before html arrives
inner.setAttribute('sandbox', 'allow-scripts');
document
.body
.appendChild(inner);

// Wait for HTML content from parent
window.addEventListener('message', (event) => {
if (event.source === window.parent) {
if (event.data && event.data.type === 'ui-html-content') {
const payload = event.data.payload || {};
const html = payload.html;
const sandbox = payload.sandbox;
if (typeof sandbox === 'string') {
inner.setAttribute('sandbox', sandbox);
}
if (typeof html === 'string') {
inner.srcdoc = html;
}
} else {
if (inner && inner.contentWindow) {
inner
.contentWindow
.postMessage(event.data, '*');
}
}
} else if (event.source === inner.contentWindow) {
// Relay messages from inner to parent
window
.parent
.postMessage(event.data, '*');
}
});

// Notify parent that proxy is ready to receive HTML (distinct event)
window
.parent
.postMessage({
type: 'ui-proxy-iframe-ready'
}, '*');
} else if (target) {
if (!isValidHttpUrl(target)) {
document.body.textContent = 'Error: invalid URL. Only HTTP and HTTPS URLs are allowed.';
} else {
const inner = document.createElement('iframe');
inner.src = target;
inner.style = 'width:100%; height:100%; border:none;';
// Default external URL sandbox; can be adjusted later by protocol if needed
inner.setAttribute('sandbox', 'allow-same-origin allow-scripts');
document
.body
.appendChild(inner);
const urlOrigin = new URL(target).origin;

window.addEventListener('message', (event) => {
if (event.source === window.parent) {
// listen for messages from the parent and send them to the iframe
if (inner.contentWindow) {
inner
.contentWindow
.postMessage(event.data, urlOrigin);
} else {
console.warn('[MCP-UI Proxy] iframe contentWindow is not available; message not sent');
}
} else if (event.source === inner.contentWindow) {
// listen for messages from the iframe and send them to the parent
window
.parent
.postMessage(event.data, '*');
}
});
}
} else {
document.body.textContent = 'Error: missing url or html parameter';
}
</script>
</body>
</html>
27 changes: 27 additions & 0 deletions ui/desktop/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,33 @@
}
}
},
"/mcp-ui-proxy": {
"get": {
"tags": [
"super::routes::mcp_ui_proxy"
],
"operationId": "mcp_ui_proxy",
"parameters": [
{
"name": "secret",
"in": "query",
"description": "Secret key for authentication",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "MCP UI proxy HTML page"
},
"401": {
"description": "Unauthorized - invalid or missing secret"
}
}
}
},
"/recipes/create": {
"post": {
"tags": [
Expand Down
9 changes: 8 additions & 1 deletion ui/desktop/src/api/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';

export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
Expand Down Expand Up @@ -310,6 +310,13 @@ export const startTetrateSetup = <ThrowOnError extends boolean = false>(options?
});
};

export const mcpUiProxy = <ThrowOnError extends boolean = false>(options: Options<McpUiProxyData, ThrowOnError>) => {
return (options.client ?? client).get<McpUiProxyResponses, McpUiProxyErrors, ThrowOnError>({
url: '/mcp-ui-proxy',
...options
});
};

export const createRecipe = <ThrowOnError extends boolean = false>(options: Options<CreateRecipeData, ThrowOnError>) => {
return (options.client ?? client).post<CreateRecipeResponses, CreateRecipeErrors, ThrowOnError>({
url: '/recipes/create',
Expand Down
26 changes: 26 additions & 0 deletions ui/desktop/src/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1722,6 +1722,32 @@ export type StartTetrateSetupResponses = {

export type StartTetrateSetupResponse = StartTetrateSetupResponses[keyof StartTetrateSetupResponses];

export type McpUiProxyData = {
body?: never;
path?: never;
query: {
/**
* Secret key for authentication
*/
secret: string;
};
url: '/mcp-ui-proxy';
};

export type McpUiProxyErrors = {
/**
* Unauthorized - invalid or missing secret
*/
401: unknown;
};

export type McpUiProxyResponses = {
/**
* MCP UI proxy HTML page
*/
200: unknown;
};

export type CreateRecipeData = {
body: CreateRecipeRequest;
path?: never;
Expand Down
10 changes: 5 additions & 5 deletions ui/desktop/src/components/MCPUIResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ export default function MCPUIResourceRenderer({
const theme = localStorage.getItem('theme') || 'light';
setCurrentThemeValue(theme);

// Fetch the MCP-UI proxy URL from the main process
const fetchProxyUrl = async () => {
try {
const url = await window.electron.getMcpUIProxyUrl();
if (url) {
setProxyUrl(url);
const baseUrl = await window.electron.getGoosedHostPort();
const secretKey = await window.electron.getSecretKey();
if (baseUrl && secretKey) {
setProxyUrl(`${baseUrl}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`);
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security exposure: the secret key is exposed in the URL query parameter, which can be logged in browser history, server logs, and referrer headers. Consider using a POST request with the secret in the request body, or implementing a session-based approach where the authentication happens separately from the proxy URL.

Suggested change
setProxyUrl(`${baseUrl}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`);
setProxyUrl(`${baseUrl}/mcp-ui-proxy`);

Copilot uses AI. Check for mistakes.
} else {
console.error('Failed to get MCP-UI Proxy URL');
console.error('Failed to get goosed host/port or secret key');
}
} catch (error) {
console.error('Error fetching MCP-UI Proxy URL:', error);
Expand Down
3 changes: 0 additions & 3 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ import { Recipe } from './recipe';
import './utils/recipeHash';
import { Client, createClient, createConfig } from './api/client';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { initMcpUIProxy } from './proxy';

// Updater functions (moved here to keep updates.ts minimal for release replacement)
function shouldSetupUpdater(): boolean {
Expand Down Expand Up @@ -1718,8 +1717,6 @@ async function appMain() {
// Ensure Windows shims are available before any MCP processes are spawned
await ensureWinShims();

await initMcpUIProxy(MAIN_WINDOW_VITE_DEV_SERVER_URL);

registerUpdateIpcHandlers();

// Handle microphone permission requests
Expand Down
Loading