LibreChat/api/server/controllers/mcpApps.test.js
Dustin Healy 0f708c2eb8 fix(mcp): harden app CSP, fail closed on auth resolution, and rate-limit resource reads
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.
2026-06-30 17:30:56 -07:00

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);
});
});