mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 03:43:03 +00:00
feat(mcp): dedicated-origin allow-same-origin, live host-context, configurable CSP, theme vars
Grant allow-same-origin to the sandbox inner frame only when the sandbox runs on a dedicated origin (parentOrigin differs from the sandbox origin), matching the spec dedicated-origin model so storage-backed apps work there while same-origin deployments stay isolated from the host. Push host-context updates to a live app: a MutationObserver on the document theme class sends sendHostContextChange with the new theme and derived style tokens when the user toggles light or dark while an app is open. Provide the standardized MCP Apps CSS theme variables (a mapped subset of LibreChat tokens) in the initial hostContext and on theme change. Add an opt-in strict CSP (VITE_MCP_SANDBOX_STRICT_CSP) that drops unsafe-eval, wasm-unsafe-eval, blob:, and data: from the sandbox script-src, threaded to the sandbox via a strictCsp query param.
This commit is contained in:
parent
0f708c2eb8
commit
8f10ef4b1f
3 changed files with 97 additions and 16 deletions
|
|
@ -48,6 +48,13 @@
|
|||
return window.location.origin;
|
||||
})();
|
||||
|
||||
// A dedicated sandbox origin (parentOrigin differs from ours) isolates the inner frame from
|
||||
// the host origin, so allow-same-origin can be granted; same-origin deployments cannot.
|
||||
const dedicatedOrigin = window.location.origin !== trustedOrigin;
|
||||
// Opt-in stricter CSP that drops unsafe-eval/wasm/blob/data from script-src for deployments
|
||||
// that do not need them.
|
||||
const strictCsp = new URLSearchParams(window.location.search).get('strictCsp') === '1';
|
||||
|
||||
function notifyReady() {
|
||||
window.parent.postMessage(
|
||||
{ jsonrpc: '2.0', method: 'ui/notifications/sandbox-proxy-ready', params: {} },
|
||||
|
|
@ -131,15 +138,19 @@
|
|||
innerFrame = null;
|
||||
});
|
||||
|
||||
// Strip allow-same-origin from the inner frame regardless of what the host requested.
|
||||
// Full cross-origin isolation requires the sandbox proxy to be served from a different
|
||||
// origin than the host; stripping allow-same-origin provides partial mitigation when
|
||||
// the proxy runs same-origin.
|
||||
const requestedTokens = (params.sandbox || 'allow-scripts allow-forms')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
innerFrame.sandbox =
|
||||
requestedTokens.filter((t) => t !== 'allow-same-origin').join(' ') || 'allow-scripts';
|
||||
// On a dedicated sandbox origin, grant allow-same-origin so storage-backed apps work
|
||||
// (the spec's dedicated-origin model); the distinct origin keeps it away from the host.
|
||||
// When the sandbox runs same-origin as the host, allow-same-origin would expose the host
|
||||
// origin, so it is stripped regardless of what the host requested.
|
||||
const sandboxTokens = new Set(
|
||||
(params.sandbox || 'allow-scripts allow-forms').split(/\s+/).filter(Boolean),
|
||||
);
|
||||
if (dedicatedOrigin) {
|
||||
sandboxTokens.add('allow-same-origin');
|
||||
} else {
|
||||
sandboxTokens.delete('allow-same-origin');
|
||||
}
|
||||
innerFrame.sandbox = Array.from(sandboxTokens).join(' ') || 'allow-scripts';
|
||||
|
||||
const allowParts = [];
|
||||
if (permissions && typeof permissions === 'object') {
|
||||
|
|
@ -202,12 +213,14 @@
|
|||
const connectDomains = toDomainList(csp.connectDomains) || "'none'";
|
||||
const frameDomains = toDomainList(csp.frameDomains) || "'none'";
|
||||
|
||||
const scriptSrc = strictCsp
|
||||
? "script-src 'unsafe-inline' " + resourceDomains
|
||||
: "script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: data: " +
|
||||
resourceDomains;
|
||||
|
||||
return [
|
||||
"default-src 'none'",
|
||||
(
|
||||
"script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: data: " +
|
||||
resourceDomains
|
||||
).trim(),
|
||||
scriptSrc.trim(),
|
||||
("style-src 'unsafe-inline' " + resourceDomains).trim(),
|
||||
'connect-src ' + connectDomains,
|
||||
// form-action does not fall back to default-src, so with allow-forms a form could post to
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
PostMessageTransport,
|
||||
buildAllowAttribute,
|
||||
} from '@modelcontextprotocol/ext-apps/app-bridge';
|
||||
import type { McpUiStyles } from '@modelcontextprotocol/ext-apps/app-bridge';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import type { AppToolResult } from '~/utils/mcpApps';
|
||||
import {
|
||||
|
|
@ -24,6 +25,41 @@ type MessageContentBlock = { type?: string; text?: string };
|
|||
|
||||
type SizeParams = { width?: number; height?: number };
|
||||
|
||||
/** Maps the MCP Apps standard host style tokens onto LibreChat's theme CSS variables. Apps keep
|
||||
* their own fallbacks for anything omitted, so a partial set is intentional. */
|
||||
const HOST_STYLE_VAR_MAP: Record<string, string> = {
|
||||
'--color-background-primary': '--surface-primary',
|
||||
'--color-background-secondary': '--surface-secondary',
|
||||
'--color-background-tertiary': '--surface-tertiary',
|
||||
'--color-background-danger': '--surface-destructive',
|
||||
'--color-text-primary': '--text-primary',
|
||||
'--color-text-secondary': '--text-secondary',
|
||||
'--color-text-tertiary': '--text-tertiary',
|
||||
'--color-text-danger': '--text-destructive',
|
||||
'--color-text-warning': '--text-warning',
|
||||
'--color-border-primary': '--border-medium',
|
||||
'--color-border-secondary': '--border-light',
|
||||
'--color-border-danger': '--border-destructive',
|
||||
};
|
||||
|
||||
function readHostTheme(): 'light' | 'dark' {
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function buildHostStyleVariables(): McpUiStyles {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
const variables: Record<string, string> = {};
|
||||
for (const [specVar, lcVar] of Object.entries(HOST_STYLE_VAR_MAP)) {
|
||||
const value = computed.getPropertyValue(lcVar).trim();
|
||||
if (value) {
|
||||
variables[specVar] = value;
|
||||
}
|
||||
}
|
||||
// The token record is optional/partial by design (apps fall back on any we omit); the generated
|
||||
// type requires every key, so assert the mapped subset.
|
||||
return variables as McpUiStyles;
|
||||
}
|
||||
|
||||
export function useAppBridge(
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>,
|
||||
resource: UIResource,
|
||||
|
|
@ -76,7 +112,7 @@ export function useAppBridge(
|
|||
|
||||
const transport = new PostMessageTransport(iframe.contentWindow, iframe.contentWindow);
|
||||
|
||||
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
const theme = readHostTheme();
|
||||
const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions();
|
||||
// Display-only views advertise no host-bound action capabilities so a well-behaved app
|
||||
// disables those affordances rather than issuing calls the host ignores.
|
||||
|
|
@ -98,6 +134,7 @@ export function useAppBridge(
|
|||
timeZone,
|
||||
displayMode: 'inline',
|
||||
availableDisplayModes: ['inline'],
|
||||
styles: { variables: buildHostStyleVariables() },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -240,8 +277,34 @@ export function useAppBridge(
|
|||
iframe.addEventListener('load', handleLoad, { once: true });
|
||||
iframe.src = iframe.getAttribute('data-sandbox-url') ?? '';
|
||||
|
||||
// Host context is captured once at init, so push theme (and the derived style tokens) to the
|
||||
// app when the user toggles light/dark while it is open.
|
||||
let lastTheme = readHostTheme();
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
const nextTheme = readHostTheme();
|
||||
if (nextTheme === lastTheme) {
|
||||
return;
|
||||
}
|
||||
lastTheme = nextTheme;
|
||||
const activeBridge = bridgeRef.current;
|
||||
if (!activeBridge) {
|
||||
return;
|
||||
}
|
||||
Promise.resolve(
|
||||
activeBridge.sendHostContextChange({
|
||||
theme: nextTheme,
|
||||
styles: { variables: buildHostStyleVariables() },
|
||||
}),
|
||||
).catch((err: unknown) => logger.error('[MCP App] sendHostContextChange failed', err));
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
themeObserver.disconnect();
|
||||
iframe.removeEventListener('load', handleLoad);
|
||||
bridgeRef.current?.teardownResource({}).catch(() => {});
|
||||
bridgeRef.current?.close();
|
||||
|
|
|
|||
|
|
@ -50,11 +50,16 @@ export function buildAppToolResult(resource: UIResource): AppToolResult | undefi
|
|||
}
|
||||
|
||||
export function getMCPSandboxUrl(): string {
|
||||
const configured = (import.meta.env as Record<string, string | undefined>).VITE_MCP_SANDBOX_URL;
|
||||
const base = configured ?? `${apiBaseUrl()}/api/mcp/sandbox`;
|
||||
const env = import.meta.env as Record<string, string | undefined>;
|
||||
const base = env.VITE_MCP_SANDBOX_URL ?? `${apiBaseUrl()}/api/mcp/sandbox`;
|
||||
const strictCsp =
|
||||
env.VITE_MCP_SANDBOX_STRICT_CSP === 'true' || env.VITE_MCP_SANDBOX_STRICT_CSP === '1';
|
||||
try {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set('parentOrigin', window.location.origin);
|
||||
if (strictCsp) {
|
||||
url.searchParams.set('strictCsp', '1');
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return base;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue