mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-03 04:42:11 +00:00
Render non-app (no profile=mcp-app) ui:// HTML inert: the static srcDoc iframes in ToolCall, MCPUIResource, and UIResourceCarousel now use sandbox="" so scripts and forms run only through the CSP-applying sandbox proxy. Make the proxy's meta CSP unbypassable by wrapping any document whose markup precedes <head>, so nothing untrusted is parsed before the policy takes effect. Fail closed in resolveAppContext when MCP auth-value resolution throws, logging and rejecting rather than proceeding with unresolved or stale credentials. Validate each MCP_SANDBOX_FRAME_ANCESTORS token against a scheme://host[:port] pattern so a stray ";" cannot inject an extra CSP directive. Rate-limit the app resource endpoints (resources/read, list, templates/list) per user, and correct AppToolResult.content from an empty-tuple type to unknown[]. Add controller tests for the frame-ancestors validation and the auth fail-closed path.
98 lines
3.4 KiB
JavaScript
98 lines
3.4 KiB
JavaScript
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
|
}));
|
|
jest.mock('@librechat/api', () => ({
|
|
getUserMCPAuthMap: jest.fn(),
|
|
readAppResource: jest.fn(),
|
|
listAppResources: jest.fn(),
|
|
listAppResourceTemplates: jest.fn(),
|
|
callAppTool: jest.fn(),
|
|
}));
|
|
jest.mock('~/config', () => ({
|
|
getMCPManager: jest.fn(),
|
|
getFlowStateManager: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
|
|
jest.mock('~/server/services/MCP', () => ({ resolveConfigServers: jest.fn() }));
|
|
jest.mock('~/models', () => ({
|
|
findPluginAuthsByKeys: jest.fn(),
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
}));
|
|
jest.mock('~/cache', () => ({ getLogStores: jest.fn() }));
|
|
|
|
const { getUserMCPAuthMap, readAppResource } = require('@librechat/api');
|
|
const { resolveConfigServers } = require('~/server/services/MCP');
|
|
const { serveMCPSandbox, readMCPResource } = require('./mcpApps');
|
|
|
|
const makeRes = () => {
|
|
const headers = {};
|
|
return {
|
|
headers,
|
|
headersSent: false,
|
|
setHeader: jest.fn((k, v) => {
|
|
headers[k] = v;
|
|
}),
|
|
sendFile: jest.fn(),
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn().mockReturnThis(),
|
|
};
|
|
};
|
|
|
|
describe('serveMCPSandbox frame-ancestors', () => {
|
|
const original = process.env.MCP_SANDBOX_FRAME_ANCESTORS;
|
|
afterEach(() => {
|
|
if (original === undefined) {
|
|
delete process.env.MCP_SANDBOX_FRAME_ANCESTORS;
|
|
} else {
|
|
process.env.MCP_SANDBOX_FRAME_ANCESTORS = original;
|
|
}
|
|
});
|
|
|
|
it('allows a valid host origin and marks the resource cross-origin', async () => {
|
|
process.env.MCP_SANDBOX_FRAME_ANCESTORS = 'https://host.example.com';
|
|
const res = makeRes();
|
|
await serveMCPSandbox({}, res);
|
|
expect(res.headers['Content-Security-Policy']).toBe(
|
|
"frame-ancestors 'self' https://host.example.com",
|
|
);
|
|
expect(res.headers['Cross-Origin-Resource-Policy']).toBe('cross-origin');
|
|
});
|
|
|
|
it('drops a token that tries to inject an extra directive', async () => {
|
|
process.env.MCP_SANDBOX_FRAME_ANCESTORS = 'https://ok.com; script-src *';
|
|
const res = makeRes();
|
|
await serveMCPSandbox({}, res);
|
|
const csp = res.headers['Content-Security-Policy'];
|
|
expect(csp).not.toContain('script-src');
|
|
// The ";"-bearing token is rejected wholesale, leaving no valid ancestors -> same-origin default.
|
|
expect(csp).toBe("frame-ancestors 'self'");
|
|
expect(res.headers['X-Frame-Options']).toBe('SAMEORIGIN');
|
|
});
|
|
|
|
it('defaults to same-origin when no ancestors are configured', async () => {
|
|
delete process.env.MCP_SANDBOX_FRAME_ANCESTORS;
|
|
const res = makeRes();
|
|
await serveMCPSandbox({}, res);
|
|
expect(res.headers['Content-Security-Policy']).toBe("frame-ancestors 'self'");
|
|
expect(res.headers['Cross-Origin-Resource-Policy']).toBe('same-origin');
|
|
});
|
|
});
|
|
|
|
describe('resolveAppContext fail-closed', () => {
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
it('rejects the request and does not proxy when auth-value resolution fails', async () => {
|
|
resolveConfigServers.mockResolvedValue({});
|
|
getUserMCPAuthMap.mockRejectedValue(new Error('db down'));
|
|
const req = { user: { id: 'user-1' }, body: { serverName: 'srv', uri: 'ui://x' } };
|
|
const res = makeRes();
|
|
|
|
await readMCPResource(req, res);
|
|
|
|
expect(readAppResource).not.toHaveBeenCalled();
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
});
|
|
});
|