mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 04:12:36 +00:00
chore(mcp): loosen csp safety so threejs mcp apps official demo server can run
This commit is contained in:
parent
20afb27961
commit
2f650687d6
1 changed files with 192 additions and 165 deletions
|
|
@ -1,191 +1,218 @@
|
|||
<!DOCTYPE html>
|
||||
<html style="height:100%;margin:0">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MCP App Sandbox</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: transparent; }
|
||||
iframe { width: 100%; height: 100%; border: none; display: block; background: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
'use strict';
|
||||
<!doctype html>
|
||||
<html style="height: 100%; margin: 0">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>MCP App Sandbox</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
let innerFrame = null;
|
||||
let innerFrameBlobUrl = null;
|
||||
let readyInterval = null;
|
||||
const SANDBOX_PREFIX = 'ui/notifications/sandbox-';
|
||||
let innerFrame = null;
|
||||
let innerFrameBlobUrl = null;
|
||||
let readyInterval = null;
|
||||
const SANDBOX_PREFIX = 'ui/notifications/sandbox-';
|
||||
|
||||
// Default to same-origin. When the sandbox is served from a dedicated origin, the parent
|
||||
// passes its origin via the parentOrigin query param so the handshake targets LibreChat
|
||||
// rather than the sandbox's own origin.
|
||||
const trustedOrigin = (function () {
|
||||
try {
|
||||
const param = new URLSearchParams(window.location.search).get('parentOrigin');
|
||||
if (param) {
|
||||
return new URL(param).origin;
|
||||
}
|
||||
} catch (e) {}
|
||||
return window.location.origin;
|
||||
})();
|
||||
|
||||
function notifyReady() {
|
||||
window.parent.postMessage(
|
||||
{ jsonrpc: '2.0', method: 'ui/notifications/sandbox-proxy-ready', params: {} },
|
||||
trustedOrigin
|
||||
);
|
||||
if (!readyInterval) {
|
||||
readyInterval = setInterval(() => {
|
||||
if (!innerFrame) {
|
||||
window.parent.postMessage(
|
||||
{ jsonrpc: '2.0', method: 'ui/notifications/sandbox-proxy-ready', params: {} },
|
||||
trustedOrigin
|
||||
);
|
||||
// Default to same-origin. When the sandbox is served from a dedicated origin, the parent
|
||||
// passes its origin via the parentOrigin query param so the handshake targets LibreChat
|
||||
// rather than the sandbox's own origin.
|
||||
const trustedOrigin = (function () {
|
||||
try {
|
||||
const param = new URLSearchParams(window.location.search).get('parentOrigin');
|
||||
if (param) {
|
||||
return new URL(param).origin;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return window.location.origin;
|
||||
})();
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
} catch {
|
||||
return;
|
||||
function notifyReady() {
|
||||
window.parent.postMessage(
|
||||
{ jsonrpc: '2.0', method: 'ui/notifications/sandbox-proxy-ready', params: {} },
|
||||
trustedOrigin,
|
||||
);
|
||||
if (!readyInterval) {
|
||||
readyInterval = setInterval(() => {
|
||||
if (!innerFrame) {
|
||||
window.parent.postMessage(
|
||||
{ jsonrpc: '2.0', method: 'ui/notifications/sandbox-proxy-ready', params: {} },
|
||||
trustedOrigin,
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
if (!msg || msg.jsonrpc !== '2.0') return;
|
||||
|
||||
if (event.source === window.parent) {
|
||||
if (event.origin !== trustedOrigin) {
|
||||
window.addEventListener('message', (event) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!msg || msg.jsonrpc !== '2.0') return;
|
||||
|
||||
if (event.source === window.parent) {
|
||||
if (event.origin !== trustedOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === 'ui/notifications/sandbox-resource-ready') {
|
||||
clearInterval(readyInterval);
|
||||
readyInterval = null;
|
||||
createInnerFrame(msg.params);
|
||||
return;
|
||||
}
|
||||
if (innerFrame && innerFrame.contentWindow) {
|
||||
innerFrame.contentWindow.postMessage(msg, '*');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === 'ui/notifications/sandbox-resource-ready') {
|
||||
clearInterval(readyInterval);
|
||||
readyInterval = null;
|
||||
createInnerFrame(msg.params);
|
||||
return;
|
||||
if (innerFrame && event.source === innerFrame.contentWindow) {
|
||||
if (msg.method && msg.method.startsWith(SANDBOX_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
window.parent.postMessage(msg, trustedOrigin);
|
||||
}
|
||||
if (innerFrame && innerFrame.contentWindow) {
|
||||
innerFrame.contentWindow.postMessage(msg, '*');
|
||||
});
|
||||
|
||||
function createInnerFrame(params) {
|
||||
const { html, csp, permissions } = params;
|
||||
|
||||
if (innerFrameBlobUrl) {
|
||||
URL.revokeObjectURL(innerFrameBlobUrl);
|
||||
innerFrameBlobUrl = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (innerFrame && event.source === innerFrame.contentWindow) {
|
||||
if (msg.method && msg.method.startsWith(SANDBOX_PREFIX)) {
|
||||
return;
|
||||
if (innerFrame) {
|
||||
innerFrame.remove();
|
||||
}
|
||||
window.parent.postMessage(msg, trustedOrigin);
|
||||
}
|
||||
});
|
||||
|
||||
function createInnerFrame(params) {
|
||||
const { html, csp, permissions } = params;
|
||||
innerFrame = document.createElement('iframe');
|
||||
|
||||
if (innerFrameBlobUrl) {
|
||||
URL.revokeObjectURL(innerFrameBlobUrl);
|
||||
innerFrameBlobUrl = null;
|
||||
}
|
||||
if (innerFrame) {
|
||||
innerFrame.remove();
|
||||
// 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';
|
||||
|
||||
const allowParts = [];
|
||||
if (permissions && typeof permissions === 'object') {
|
||||
if (permissions.clipboardWrite) allowParts.push('clipboard-write');
|
||||
if (permissions.camera) allowParts.push('camera');
|
||||
if (permissions.microphone) allowParts.push('microphone');
|
||||
if (permissions.geolocation) allowParts.push('geolocation');
|
||||
}
|
||||
if (allowParts.length > 0) {
|
||||
innerFrame.allow = allowParts.join('; ');
|
||||
}
|
||||
|
||||
const cspMeta = buildCspMeta(csp);
|
||||
const fullHtml = cspMeta ? injectIntoHead(html, cspMeta) : html;
|
||||
|
||||
const blob = new Blob([fullHtml], { type: 'text/html' });
|
||||
innerFrameBlobUrl = URL.createObjectURL(blob);
|
||||
innerFrame.src = innerFrameBlobUrl;
|
||||
innerFrame.style.cssText = 'width:100%;height:100%;border:none;display:block';
|
||||
|
||||
document.body.appendChild(innerFrame);
|
||||
}
|
||||
|
||||
innerFrame = document.createElement('iframe');
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (innerFrameBlobUrl) {
|
||||
URL.revokeObjectURL(innerFrameBlobUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// 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';
|
||||
|
||||
const allowParts = [];
|
||||
if (permissions && typeof permissions === 'object') {
|
||||
if (permissions.clipboardWrite) allowParts.push('clipboard-write');
|
||||
if (permissions.camera) allowParts.push('camera');
|
||||
if (permissions.microphone) allowParts.push('microphone');
|
||||
if (permissions.geolocation) allowParts.push('geolocation');
|
||||
}
|
||||
if (allowParts.length > 0) {
|
||||
innerFrame.allow = allowParts.join('; ');
|
||||
function injectIntoHead(html, injection) {
|
||||
if (/<head[^>]*>/i.test(html)) {
|
||||
return html.replace(/<head([^>]*)>/i, '<head$1>' + injection);
|
||||
}
|
||||
if (/<html[^>]*>/i.test(html)) {
|
||||
return html.replace(/<html([^>]*)>/i, '<html$1><head>' + injection + '</head>');
|
||||
}
|
||||
return (
|
||||
'<!DOCTYPE html><html><head>' + injection + '</head><body>' + html + '</body></html>'
|
||||
);
|
||||
}
|
||||
|
||||
const cspMeta = buildCspMeta(csp);
|
||||
const fullHtml = cspMeta ? injectIntoHead(html, cspMeta) : html;
|
||||
|
||||
const blob = new Blob([fullHtml], { type: 'text/html' });
|
||||
innerFrameBlobUrl = URL.createObjectURL(blob);
|
||||
innerFrame.src = innerFrameBlobUrl;
|
||||
innerFrame.style.cssText = 'width:100%;height:100%;border:none;display:block';
|
||||
|
||||
document.body.appendChild(innerFrame);
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (innerFrameBlobUrl) {
|
||||
URL.revokeObjectURL(innerFrameBlobUrl);
|
||||
function buildCspMeta(csp) {
|
||||
// Use an empty object as default so omitted csp still produces a restrictive policy.
|
||||
const effective = csp && typeof csp === 'object' ? csp : {};
|
||||
const policy = buildCspPolicy(effective);
|
||||
if (!policy) return '';
|
||||
return '<meta http-equiv="Content-Security-Policy" content="' + escapeAttr(policy) + '">';
|
||||
}
|
||||
});
|
||||
|
||||
function injectIntoHead(html, injection) {
|
||||
if (/<head[^>]*>/i.test(html)) {
|
||||
return html.replace(/<head([^>]*)>/i, '<head$1>' + injection);
|
||||
function buildCspPolicy(csp) {
|
||||
const resourceDomains = toDomainList(csp.resourceDomains);
|
||||
const connectDomains = toDomainList(csp.connectDomains) || "'none'";
|
||||
const frameDomains = toDomainList(csp.frameDomains) || "'none'";
|
||||
|
||||
return [
|
||||
"default-src 'none'",
|
||||
(
|
||||
"script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: data: " +
|
||||
resourceDomains
|
||||
).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
|
||||
// any origin; bound it to the declared egress allowlist ('none' when none is declared).
|
||||
'form-action ' + connectDomains,
|
||||
('img-src data: blob: ' + resourceDomains).trim(),
|
||||
('media-src ' + (resourceDomains || "'none'")).trim(),
|
||||
('font-src ' + (resourceDomains || "'none'")).trim(),
|
||||
'frame-src ' + frameDomains,
|
||||
"object-src 'none'",
|
||||
'base-uri ' + (toDomainList(csp.baseUriDomains) || "'self'"),
|
||||
].join('; ');
|
||||
}
|
||||
if (/<html[^>]*>/i.test(html)) {
|
||||
return html.replace(/<html([^>]*)>/i, '<html$1><head>' + injection + '</head>');
|
||||
|
||||
// Only permit host patterns: optional http(s)/ws(s) scheme, optional wildcard subdomain
|
||||
// prefix, hostname characters, optional port. Rejects CSP keywords and injection attempts.
|
||||
const SAFE_HOST_RE =
|
||||
/^(?:(?:https?|wss?):\/\/)?(?:\*\.)?[a-zA-Z0-9][a-zA-Z0-9\-.]*(?::\d{1,5})?$/;
|
||||
|
||||
function toDomainList(value) {
|
||||
if (!Array.isArray(value)) return '';
|
||||
return value.filter((d) => typeof d === 'string' && SAFE_HOST_RE.test(d.trim())).join(' ');
|
||||
}
|
||||
return '<!DOCTYPE html><html><head>' + injection + '</head><body>' + html + '</body></html>';
|
||||
}
|
||||
|
||||
function buildCspMeta(csp) {
|
||||
// Use an empty object as default so omitted csp still produces a restrictive policy.
|
||||
const effective = (csp && typeof csp === 'object') ? csp : {};
|
||||
const policy = buildCspPolicy(effective);
|
||||
if (!policy) return '';
|
||||
return '<meta http-equiv="Content-Security-Policy" content="' + escapeAttr(policy) + '">';
|
||||
}
|
||||
function escapeAttr(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function buildCspPolicy(csp) {
|
||||
const resourceDomains = toDomainList(csp.resourceDomains);
|
||||
const connectDomains = toDomainList(csp.connectDomains) || "'none'";
|
||||
const frameDomains = toDomainList(csp.frameDomains) || "'none'";
|
||||
|
||||
return [
|
||||
"default-src 'none'",
|
||||
("script-src 'unsafe-inline' " + resourceDomains).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
|
||||
// any origin; bound it to the declared egress allowlist ('none' when none is declared).
|
||||
"form-action " + connectDomains,
|
||||
("img-src data: blob: " + resourceDomains).trim(),
|
||||
("media-src " + (resourceDomains || "'none'")).trim(),
|
||||
("font-src " + (resourceDomains || "'none'")).trim(),
|
||||
"frame-src " + frameDomains,
|
||||
"object-src 'none'",
|
||||
"base-uri " + (toDomainList(csp.baseUriDomains) || "'self'")
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
// Only permit host patterns: optional http(s)/ws(s) scheme, optional wildcard subdomain
|
||||
// prefix, hostname characters, optional port. Rejects CSP keywords and injection attempts.
|
||||
const SAFE_HOST_RE = /^(?:(?:https?|wss?):\/\/)?(?:\*\.)?[a-zA-Z0-9][a-zA-Z0-9\-.]*(?::\d{1,5})?$/;
|
||||
|
||||
function toDomainList(value) {
|
||||
if (!Array.isArray(value)) return '';
|
||||
return value
|
||||
.filter(d => typeof d === 'string' && SAFE_HOST_RE.test(d.trim()))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
notifyReady();
|
||||
</script>
|
||||
</body>
|
||||
notifyReady();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue