diff --git a/.env.example b/.env.example index eb7350a93e2..e7320aa3a42 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,93 @@ NO_INDEX=true # Defaulted to 1. TRUST_PROXY=1 +#===============================# +# Security Headers # +#===============================# + +# Baseline HTTP security headers (HSTS, X-Frame-Options, X-Content-Type-Options, +# COOP, CORP, Referrer-Policy) are sent on every response. Content-Security-Policy +# is never set here. Set to false to send no security headers at all. +# SECURITY_HEADERS=true + +# Strict-Transport-Security. Only meaningful over HTTPS; browsers ignore it on +# plain HTTP. HSTS_INCLUDE_SUBDOMAINS applies the policy to every subdomain of +# this host for the full max-age, so enable it only if all of them serve HTTPS. +# HSTS_ENABLED=true +# HSTS_MAX_AGE=31536000 +# HSTS_INCLUDE_SUBDOMAINS=false +# HSTS_PRELOAD=false + +# X-Frame-Options. Set to DENY to block all framing, or to `off` if you embed +# LibreChat in an iframe on another origin. +# X_FRAME_OPTIONS=SAMEORIGIN + +# Referrer-Policy. Any standard token, or `off` to omit the header. +# REFERRER_POLICY=no-referrer + +# Cross-Origin-Opener-Policy. Use same-origin-allow-popups if a popup-based +# sign-in flow needs to reach back to the window that opened it. +# CROSS_ORIGIN_OPENER_POLICY=same-origin + +# Cross-Origin-Resource-Policy. Use cross-origin if other sites need to load +# resources served by LibreChat, such as uploaded images. +# CROSS_ORIGIN_RESOURCE_POLICY=same-origin + +#===============================# +# Content Security Policy # +#===============================# + +# Nonce-based CSP for the SPA HTML response. Off by default so existing +# deployments are unaffected. Turn it on in report-only mode first, review the +# violations your deployment actually produces, then set CSP_REPORT_ONLY=false. +# Only an explicit false/off/0/no enforces; anything unrecognized warns and stays +# report-only, so a typo cannot silently start blocking scripts. +# CSP_ENABLED=false +# CSP_REPORT_ONLY=true +# CSP_REPORT_URI= + +# The default policy accommodates what LibreChat actually loads at runtime: +# script-src 'wasm-unsafe-eval' HEIC image conversion compiles WebAssembly +# worker-src data: Monaco's loader bootstraps workers from data: +# Both are narrower than 'unsafe-eval'. Set these to false to drop them if your +# deployment uses neither HEIC uploads nor the artifact code editor. (The CSP_*_EXTRA +# and CSP_ADDITIONAL_DIRECTIVES variables only add sources; they cannot remove one.) +# CSP_ALLOW_WASM=true +# CSP_ALLOW_DATA_WORKERS=true + +# While CSP is enabled the SPA shell is always sent as `no-store` and the +# INDEX_CACHE_CONTROL / INDEX_PRAGMA / INDEX_EXPIRES overrides are ignored for it. +# A cached shell would pin a single nonce across page loads and users, which is +# precisely what a nonce policy exists to prevent. +# +# SECURITY_HEADERS=false disables CSP too; it is the global kill switch. + +# Add deployment-specific sources on top of LibreChat's defaults; they are +# appended, never replacing them. Comma- or space-separated. Quote values +# containing spaces. +# CSP_CONNECT_SRC_EXTRA="https://telemetry.example.com wss://stream.example.com" +# CSP_FRAME_SRC_EXTRA="https://tenant.sharepoint.com" +# CSP_IMG_SRC_EXTRA="https://cdn.example.com" +# CSP_STYLE_SRC_EXTRA= +# CSP_FONT_SRC_EXTRA= +# CSP_MEDIA_SRC_EXTRA= +# CSP_WORKER_SRC_EXTRA= +# CSP_FORM_ACTION_EXTRA= +# CSP_DEFAULT_SRC_EXTRA= + +# Script hosts get their own note: the default policy uses 'strict-dynamic', +# which makes browsers ignore every host source in script-src. Setting this +# drops 'strict-dynamic' so the hosts you list actually take effect. +# CSP_SCRIPT_SRC_EXTRA="https://trusted-scripts.example.com" + +# Who may frame LibreChat. Defaults to 'self'. Replace it if you embed LibreChat +# in a portal on another origin, and set X_FRAME_OPTIONS=off alongside it since +# older browsers honor that header instead. +# CSP_FRAME_ANCESTORS="'self' https://portal.example.com" + +# Raw directives appended to the policy, separated by semicolons. +# CSP_ADDITIONAL_DIRECTIVES="upgrade-insecure-requests" + # Trust X-Tenant-Id on unauthenticated routes. Disabled by default. # Enable only when a trusted reverse proxy strips any client-supplied value and sets its own. # TRUST_TENANT_HEADER=false diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 59e24d3c8df..02d35d6643c 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -63,6 +63,7 @@ const mockFilterPersistableAbortContent = jest.fn((content) => const mockGetConvo = jest.fn(); const mockGetMessages = jest.fn(); const mockSaveMessage = jest.fn(); +const mockSaveConvo = jest.fn(); const mockIsAgentTriggerPrincipalActive = jest.fn(); const mockIsSubagentOwnerAdmissible = jest.fn(); const mockAcquireEventChildGenerationLease = jest.fn(); @@ -224,6 +225,7 @@ jest.mock('~/cache', () => ({ jest.mock('~/models', () => ({ saveMessage: (...args) => mockSaveMessage(...args), + saveConvo: (...args) => mockSaveConvo(...args), getMessages: (...args) => mockGetMessages(...args), getConvo: (...args) => mockGetConvo(...args), isAgentTriggerPrincipalActive: (...args) => mockIsAgentTriggerPrincipalActive(...args), @@ -319,7 +321,12 @@ describe('ResumableAgentController resume metadata', () => { }), ); mockGenerationJobManager.finishTerminalJob.mockResolvedValue(undefined); - mockGenerationJobManager.completeJob.mockResolvedValue(true); + mockGenerationJobManager.completeJob.mockImplementation( + async (_streamId, _error, _createdAt, options) => { + await options?.beforeErrorPublication?.(); + return true; + }, + ); mockGenerationJobManager.beginProviderExecution.mockResolvedValue(true); mockGenerationJobManager.markProviderExecutionDrained.mockResolvedValue(true); mockGenerationJobManager.failPausePersistence.mockResolvedValue(true); @@ -334,6 +341,7 @@ describe('ResumableAgentController resume metadata', () => { mockGenerationJobManager.steering.park.mockResolvedValue(undefined); mockGenerationJobManager.steering.consumeRecovered.mockResolvedValue(true); mockSaveMessage.mockResolvedValue({}); + mockSaveConvo.mockResolvedValue({}); mockDeleteAgentCheckpoint.mockResolvedValue(undefined); }); @@ -1362,6 +1370,8 @@ describe('ResumableAgentController resume metadata', () => { await AgentController(req, res, jest.fn(), initializeClient, null); expect(allSubscribersLeftHandler).toEqual(expect.any(Function)); + mockSaveMessage.mockClear(); + mockSaveConvo.mockClear(); const oauthPart = { type: 'tool_call', @@ -2289,6 +2299,7 @@ describe('ResumableAgentController resume metadata', () => { error: 'Attached resources could not be restored', }), 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); }); @@ -2326,6 +2337,7 @@ describe('ResumableAgentController resume metadata', () => { error: 'Stateful code environment is not allowed by this deployment: conversation', }), 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); }); @@ -2516,6 +2528,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', 'provider init failed', 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); }); @@ -2552,6 +2565,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', 'Recovered steer cannot skip user message persistence', 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); }); @@ -3024,6 +3038,247 @@ describe('ResumableAgentController resume metadata', () => { expect(mockGenerationJobManager.claimTerminalJob).not.toHaveBeenCalled(); }); + describe('failed-turn persistence', () => { + const conversationId = 'conversation-123'; + + const createFailedRequest = (bodyOverrides = {}) => ({ + user: { id: 'user-123' }, + body: { + text: 'Hello with a removed model.', + messageId: 'user-message', + parentMessageId: 'prior-response', + conversationId, + endpointOption: { + endpoint: 'azureOpenAI', + modelOptions: { model: 'gpt-4o' }, + }, + ...bodyOverrides, + }, + config: {}, + }); + + async function flushBackgroundGeneration() { + for (let i = 0; i < 10; i++) { + await nextTick(); + } + } + + it('persists an initialization failure before terminal error publication', async () => { + const events = []; + mockSaveConvo.mockImplementation(async () => { + events.push('turn-persisted'); + return {}; + }); + mockGenerationJobManager.completeJob.mockImplementation( + async (_streamId, _error, _createdAt, options) => { + await options.beforeErrorPublication(); + events.push('error-published'); + return true; + }, + ); + const initializeClient = jest + .fn() + .mockRejectedValue(new Error('The model "gpt-4o" is not available.')); + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + initializeClient, + null, + ); + + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + messageId: 'user-message', + parentMessageId: 'prior-response', + conversationId, + text: 'Hello with a removed model.', + isCreatedByUser: true, + error: false, + }), + expect.any(Object), + ); + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + messageId: 'user-message_', + parentMessageId: 'user-message', + conversationId, + endpoint: 'azureOpenAI', + model: 'gpt-4o', + text: 'The model "gpt-4o" is not available.', + error: true, + isCreatedByUser: false, + }), + expect.any(Object), + ); + expect(events).toEqual(['turn-persisted', 'error-published']); + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + { conversationId }, + expect.objectContaining({ noUpsert: true }), + ); + }); + + it('allows a follow-up to chain from the persisted failed response', async () => { + const initializeClient = jest.fn().mockRejectedValue(new Error('model unavailable')); + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + initializeClient, + null, + ); + + expect(mockSaveMessage.mock.calls.map(([, message]) => message.messageId)).toContain( + 'user-message_', + ); + mockGetMessages.mockResolvedValue([{ _id: 'persisted-error-turn' }]); + const followUpRes = createResumableResponse(); + + await AgentController( + createFailedRequest({ + text: 'Retry with a valid model.', + messageId: 'follow-up-user', + parentMessageId: 'user-message_', + }), + followUpRes, + jest.fn(), + initializeClient, + null, + ); + + expect(followUpRes.status).not.toHaveBeenCalledWith(409); + expect(mockCheckAndIncrementPendingRequest).toHaveBeenCalledTimes(2); + }); + + it('persists failures raised before generation saves any message', async () => { + const client = { + options: {}, + sendMessage: jest.fn().mockRejectedValue(new Error('provider exploded')), + }; + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + jest.fn().mockResolvedValue({ client }), + null, + ); + await flushBackgroundGeneration(); + + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + messageId: 'user-message_', + text: 'provider exploded', + error: true, + }), + expect.any(Object), + ); + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith( + conversationId, + 'provider exploded', + 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), + ); + }); + + it('uses the live user identity after generation starts', async () => { + const serverUserMessage = { + messageId: 'server-user', + parentMessageId: 'prior-response', + conversationId, + sender: 'User', + text: 'Hello with a removed model.', + isCreatedByUser: true, + }; + const client = { + options: {}, + sendMessage: jest.fn(async (_text, options) => { + options.onStart(serverUserMessage, 'server-response-uuid'); + throw new Error('failed after onStart'); + }), + }; + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + jest.fn().mockResolvedValue({ client }), + null, + ); + await flushBackgroundGeneration(); + + const savedIds = mockSaveMessage.mock.calls.map(([, message]) => message.messageId); + expect(savedIds).toEqual(expect.arrayContaining(['server-user', 'server-user_'])); + expect(savedIds).not.toContain('user-message_'); + }); + + it('does not overwrite an existing response row', async () => { + mockGetMessages.mockResolvedValue([{ _id: 'already-saved' }]); + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + jest.fn().mockRejectedValue(new Error('late failure')), + null, + ); + + expect(mockSaveMessage).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + }); + + it('creates the conversation row for a failed first turn', async () => { + const res = createResumableResponse(); + mockGenerationJobManager.claimGeneration.mockImplementation( + async (_userId, _clientRequestId, streamId, claimedConversationId) => + wonGenerationClaim({ streamId, conversationId: claimedConversationId }), + ); + const req = createFailedRequest({ + conversationId: undefined, + clientRequestId: 'failed-new-conversation', + parentMessageId: '00000000-0000-0000-0000-000000000000', + endpointOption: { + endpoint: 'azureOpenAI', + modelOptions: { model: 'gpt-4o' }, + chatProjectId: '507f1f77bcf86cd799439011', + }, + }); + + await AgentController( + req, + res, + jest.fn(), + jest.fn().mockRejectedValue(new Error('model unavailable')), + null, + ); + + const mintedConversationId = res.json.mock.calls[0][0].conversationId; + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + messageId: 'user-message_', + conversationId: mintedConversationId, + }), + expect.any(Object), + ); + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + conversationId: mintedConversationId, + endpoint: 'azureOpenAI', + model: 'gpt-4o', + chatProjectId: '507f1f77bcf86cd799439011', + }), + expect.any(Object), + ); + }); + }); + it('finalizes the failed job before releasing the idempotency claim', async () => { mockGenerationJobManager.claimGeneration.mockResolvedValue(wonGenerationClaim()); const initializeClient = jest.fn().mockRejectedValue(new Error('init boom after res.json')); @@ -3046,6 +3301,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', expect.any(String), 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith( 'user-123', @@ -3112,6 +3368,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', 'init boom after res.json', 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith( 'user-123', @@ -3227,6 +3484,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', generationError.message, 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); expect(mockGenerationJobManager.completeJob.mock.invocationCallOrder[0]).toBeLessThan( mockDecrementPendingRequest.mock.invocationCallOrder[0], diff --git a/api/server/controllers/agents/errors.js b/api/server/controllers/agents/errors.js index b16ce75591c..308e10cfc41 100644 --- a/api/server/controllers/agents/errors.js +++ b/api/server/controllers/agents/errors.js @@ -1,5 +1,6 @@ // errorHandler.js const { logger } = require('@librechat/data-schemas'); +const { getTransactionsConfig } = require('@librechat/api'); const { CacheKeys, ViolationTypes } = require('librechat-data-provider'); const { sendResponse } = require('~/server/middleware/error'); const { recordUsage } = require('~/server/services/Threads'); @@ -118,6 +119,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch model: run.model, user: req.user.id, conversationId, + transactions: getTransactionsConfig(req.config), }); } catch (error) { logger.error(`[${originPath}] Error fetching or processing run`, error); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 75a1ee744c6..55ac564bb4e 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -40,6 +40,7 @@ const { logViolation } = require('~/cache'); const { recordScheduleOutcome, isScheduleLive } = require('~/server/services/Schedules'); const { saveMessage, + saveConvo, getMessages, getConvo, isAgentTriggerPrincipalActive, @@ -107,6 +108,18 @@ async function attachConversationCreatedAt(req, conversationId, conversationAnch } } +function getPreliminaryResponseMessageId({ messageId, responseMessageId }) { + if (typeof responseMessageId === 'string' && responseMessageId.length > 0) { + return responseMessageId; + } + + if (typeof messageId !== 'string' || messageId.length === 0) { + return null; + } + + return `${messageId.replace(/_+$/, '')}_`; +} + function getPreliminaryUserMessage( { messageId, parentMessageId, text, quotes, files, manualSkills, alwaysAppliedSkills }, conversationId, @@ -190,6 +203,165 @@ async function finishResumableRequest(req, userId) { } } +async function saveErrorTurn( + req, + { + conversationId, + endpointOption, + isNewConvo, + errorText, + liveUserMessage, + liveResponseMessageId, + sender, + }, +) { + try { + const { isContinued, isRegenerate, editedContent, responseMessageId, overrideParentMessageId } = + req.body ?? {}; + if ( + isContinued || + editedContent != null || + (responseMessageId && !isRegenerate) || + req.body?.recoverySteerId != null || + req.body?.clientRequestId?.startsWith?.('steer-recovery:') === true + ) { + return; + } + + let userMessage = null; + let errorMessageId = null; + let errorParentMessageId = null; + if (isRegenerate) { + errorMessageId = + typeof responseMessageId === 'string' && responseMessageId.length > 0 + ? responseMessageId + : null; + errorParentMessageId = liveUserMessage?.messageId ?? overrideParentMessageId ?? null; + } else { + userMessage = + liveUserMessage != null + ? { + ...liveUserMessage, + ...(liveUserMessage.files == null && + Array.isArray(req.body?.files) && + req.body.files.length > 0 && { files: req.body.files }), + ...(liveUserMessage.manualSkills == null && + Array.isArray(req.body?.manualSkills) && + req.body.manualSkills.length > 0 && { manualSkills: req.body.manualSkills }), + ...(liveUserMessage.alwaysAppliedSkills == null && + Array.isArray(req.body?.alwaysAppliedSkills) && + req.body.alwaysAppliedSkills.length > 0 && { + alwaysAppliedSkills: req.body.alwaysAppliedSkills, + }), + } + : getPreliminaryUserMessage(req.body, conversationId); + if (!userMessage) { + return; + } + errorMessageId = getPreliminaryResponseMessageId( + liveUserMessage != null ? { messageId: liveUserMessage.messageId } : req.body, + ); + errorParentMessageId = userMessage.messageId; + } + if (!errorMessageId || !errorParentMessageId) { + return; + } + + const userId = req.user.id; + const existing = await getMessages( + { user: userId, messageId: errorMessageId, conversationId }, + '_id', + ); + if (existing.length > 0) { + return; + } + if (liveResponseMessageId != null && liveResponseMessageId !== errorMessageId) { + const partial = await getMessages( + { user: userId, messageId: liveResponseMessageId, conversationId }, + '_id', + ); + if (partial.length > 0) { + return; + } + } + + const reqCtx = { + userId, + isTemporary: req?._agentEventBindingRetention?.isTemporary ?? req?.body?.isTemporary, + expiredAt: req?._agentEventBindingRetention?.expiredAt, + interfaceConfig: req?.config?.interfaceConfig, + }; + const context = 'api/server/controllers/agents/request.js - failed turn'; + const endpoint = endpointOption?.endpoint; + const model = getAgentResponseModel(req, endpointOption); + const iconURL = getEndpointIconURL(req, endpointOption); + + if (userMessage) { + const savedUserMessage = await saveMessage( + reqCtx, + { + ...userMessage, + user: userId, + sender: 'User', + isCreatedByUser: true, + error: false, + unfinished: false, + }, + { context }, + ); + if (!savedUserMessage) { + throw new Error('Failed user message could not be persisted'); + } + } + const savedErrorMessage = await saveMessage( + reqCtx, + { + messageId: errorMessageId, + conversationId, + parentMessageId: errorParentMessageId, + sender: sender ?? 'AI', + ...(endpoint != null && { endpoint }), + ...(model != null && { model }), + ...(iconURL != null && { iconURL }), + user: userId, + text: errorText, + error: true, + unfinished: false, + isCreatedByUser: false, + }, + { context }, + ); + if (!savedErrorMessage) { + throw new Error('Failed response message could not be persisted'); + } + + const agentId = endpointOption?.agent_id ?? req.body?.agent_id; + const chatProjectId = endpointOption?.chatProjectId ?? req.body?.chatProjectId; + const seedConvo = isNewConvo || req.resolvedConversation === null; + const convoFields = seedConvo + ? { + ...(endpoint != null && { endpoint }), + ...(endpointOption?.endpointType != null && { + endpointType: endpointOption.endpointType, + }), + ...(model != null && { model }), + ...(iconURL != null && { iconURL }), + ...(endpointOption?.spec != null && { spec: endpointOption.spec }), + ...(agentId != null && { agent_id: agentId }), + ...(typeof chatProjectId === 'string' && chatProjectId.length > 0 && { chatProjectId }), + } + : {}; + await saveConvo( + reqCtx, + { conversationId, ...convoFields }, + seedConvo ? { context } : { context, noUpsert: true }, + ); + } catch (err) { + logger.error('[AgentController] Failed to persist error turn', err); + throw err; + } +} + function classifyScheduledFailure(error, aborted = false) { if (aborted || error?.code === 'SCHEDULE_NO_LONGER_ACTIVE') { return { status: 'interrupted', error: error?.message }; @@ -1450,11 +1622,15 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } let userMessage; + let liveResponseMessageId = preallocatedResponseMessageId; const getReqData = (data = {}) => { if (data.userMessage) { userMessage = data.userMessage; } + if (data.responseMessageId) { + liveResponseMessageId = data.responseMessageId; + } // conversationId is pre-generated, no need to update from callback }; @@ -1593,6 +1769,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit try { const onStart = (userMsg, respMsgId, _isNewConvo) => { userMessage = userMsg; + liveResponseMessageId = respMsgId; // Store userMessage and responseMessageId upfront for resume capability GenerationJobManager.updateMetadata( @@ -2179,8 +2356,18 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // completeJob first wins running -> error and atomically parks // steers, then publishes. A competing abort/pause emits nothing. ownsScheduledFailure = - (await GenerationJobManager.completeJob(streamId, generationError, jobCreatedAt)) === - true; + (await GenerationJobManager.completeJob(streamId, generationError, jobCreatedAt, { + beforeErrorPublication: () => + saveErrorTurn(req, { + conversationId, + endpointOption, + isNewConvo, + errorText: generationError, + liveUserMessage: userMessage, + liveResponseMessageId, + sender: client?.sender, + }), + })) === true; } catch (completeErr) { logger.warn( '[ResumableAgentController] completeJob failed during generation-error cleanup', @@ -2262,6 +2449,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } catch (error) { logger.error('[ResumableAgentController] Initialization error:', error); const initializationFailure = getInitializationFailure(error); + const streamStarted = res.headersSent; try { if (!res.headersSent) { if (error?.code === 'GENERATION_PREDECESSOR_MISMATCH') { @@ -2354,16 +2542,25 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const initializationError = initializationFailure ? JSON.stringify(initializationFailure) : error.message || 'Failed to start generation'; + const completionPromise = streamStarted + ? GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt, { + beforeErrorPublication: () => + saveErrorTurn(req, { + conversationId, + endpointOption, + isNewConvo, + errorText: initializationError, + }), + }) + : GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt); initializationFinalized = - (await GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt).catch( - (completeErr) => { - logger.warn( - '[ResumableAgentController] completeJob failed during init-error cleanup', - completeErr, - ); - return false; - }, - )) === true; + (await completionPromise.catch((completeErr) => { + logger.warn( + '[ResumableAgentController] completeJob failed during init-error cleanup', + completeErr, + ); + return false; + })) === true; } if (initializationFinalized && !scheduleTerminalOutcomeRecorded) { await settleScheduledRun(classifyScheduledFailure(error)); diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index 2e6e6278756..cac475dee6b 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -7,6 +7,7 @@ const { checkBalance, getBalanceConfig, getModelMaxTokens, + getTransactionsConfig, ATTACHMENT_ONLY_TEXT, isContentFilterError, hasActiveFilePolicy, @@ -194,6 +195,7 @@ const chatV1 = async (req, res) => { model: run.model, user: req.user.id, conversationId, + transactions: getTransactionsConfig(req.config), }); } catch (error) { logger.error('[/assistants/chat/] Error fetching or processing run', error); @@ -734,6 +736,7 @@ const chatV1 = async (req, res) => { user: req.user.id, model: completedRun.model ?? model, conversationId, + transactions: getTransactionsConfig(req.config), }); } } else { @@ -742,6 +745,7 @@ const chatV1 = async (req, res) => { user: req.user.id, model: response.run.model ?? model, conversationId, + transactions: getTransactionsConfig(req.config), }); } } catch (error) { diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js index cae3da468d3..ac451fa1732 100644 --- a/api/server/controllers/assistants/chatV2.js +++ b/api/server/controllers/assistants/chatV2.js @@ -6,6 +6,7 @@ const { countTokens, checkBalance, getBalanceConfig, + getTransactionsConfig, getModelMaxTokens, ATTACHMENT_ONLY_TEXT, isContentFilterError, @@ -574,6 +575,7 @@ const chatV2 = async (req, res) => { user: req.user.id, model: completedRun.model ?? model, conversationId, + transactions: getTransactionsConfig(req.config), }); } } else { @@ -582,6 +584,7 @@ const chatV2 = async (req, res) => { user: req.user.id, model: response.run.model ?? model, conversationId, + transactions: getTransactionsConfig(req.config), }); } } catch (error) { diff --git a/api/server/controllers/assistants/errors.js b/api/server/controllers/assistants/errors.js index f8dcf39f2bc..4aaa4c68d07 100644 --- a/api/server/controllers/assistants/errors.js +++ b/api/server/controllers/assistants/errors.js @@ -1,5 +1,6 @@ // errorHandler.js const { logger } = require('@librechat/data-schemas'); +const { getTransactionsConfig } = require('@librechat/api'); const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider'); const { recordUsage, checkMessageGaps } = require('~/server/services/Threads'); const { sendResponse } = require('~/server/middleware/error'); @@ -124,6 +125,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch model: run.model, user: req.user.id, conversationId, + transactions: getTransactionsConfig(req.config), }); } catch (error) { logger.error(`[${originPath}] Error fetching or processing run`, error); diff --git a/api/server/csp.spec.js b/api/server/csp.spec.js new file mode 100644 index 00000000000..6e2e4eef46c --- /dev/null +++ b/api/server/csp.spec.js @@ -0,0 +1,232 @@ +const fs = require('fs'); +const path = require('path'); +const request = require('supertest'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); + +/** + * Mirrors what a production `client/dist/index.html` actually contains: inline + * style, inline script, a module entry, and the module preloads Vite emits. + */ +const INDEX_HTML = + 'LibreChat' + + '' + + '' + + '' + + '' + + '' + + '' + + '
'; + +jest.mock('~/server/services/Config', () => ({ + syncStaticTools: jest.fn().mockResolvedValue(undefined), + mergeAppTools: jest.fn().mockResolvedValue(undefined), + loadCustomConfig: jest.fn(() => Promise.resolve({})), + getAppConfig: jest.fn().mockResolvedValue({ + paths: { + uploads: '/tmp', + dist: '/tmp/dist-csp', + fonts: '/tmp/fonts-csp', + assets: '/tmp/assets-csp', + }, + fileStrategy: 'local', + imageOutputType: 'PNG', + }), + setCachedTools: jest.fn(), +})); + +jest.mock('~/server/services/Agents/triggers', () => ({ + initializeAgentTriggerService: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('~/server/services/Schedules', () => ({ + initializeScheduleEngine: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('~/app/clients/tools', () => ({ + createOpenAIImageTools: jest.fn(() => []), + createYouTubeTools: jest.fn(() => []), + manifestToolMap: {}, + toolkits: [], +})); + +jest.mock('~/config', () => ({ + createMCPServersRegistry: jest.fn(), + createMCPManager: jest.fn().mockResolvedValue({ + getAppToolFunctions: jest.fn().mockResolvedValue({}), + }), +})); + +jest.mock( + '@librechat/api/telemetry', + () => ({ + initializeTelemetry: jest.fn(() => ({ + enabled: false, + status: 'disabled', + shutdown: jest.fn(), + })), + telemetryMiddleware: jest.fn((_req, _res, next) => next()), + telemetryErrorMiddleware: jest.fn((err, _req, _res, next) => next(err)), + }), + { virtual: true }, +); + +describe('Content Security Policy', () => { + jest.setTimeout(30_000); + + let mongoServer; + let app; + + const originalReadFileSync = fs.readFileSync; + + beforeAll(async () => { + fs.readFileSync = function (filepath, options) { + if (filepath.includes('index.html')) { + return INDEX_HTML; + } + return originalReadFileSync(filepath, options); + }; + + for (const dir of ['/tmp/dist-csp', '/tmp/fonts-csp', '/tmp/assets-csp']) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + fs.writeFileSync(path.join('/tmp/dist-csp', 'index.html'), INDEX_HTML); + + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URI = mongoServer.getUri(); + process.env.PORT = '0'; + + /* Read once at startup, so they must be set before the server module loads. */ + process.env.CSP_ENABLED = 'true'; + process.env.CSP_REPORT_ONLY = 'false'; + process.env.CSP_CONNECT_SRC_EXTRA = 'https://telemetry.example.com'; + /* A cacheable override that CSP must refuse for the shell. */ + process.env.INDEX_CACHE_CONTROL = 'public, max-age=3600'; + + app = require('~/server'); + await healthCheckPoll(app); + }); + + afterAll(async () => { + fs.readFileSync = originalReadFileSync; + delete process.env.CSP_ENABLED; + delete process.env.CSP_REPORT_ONLY; + delete process.env.CSP_CONNECT_SRC_EXTRA; + delete process.env.INDEX_CACHE_CONTROL; + await mongoServer.stop(); + await mongoose.disconnect(); + }); + + it('sends an enforcing policy whose nonce matches the served scripts', async () => { + const response = await request(app).get('/'); + const csp = response.headers['content-security-policy']; + const nonce = csp?.match(/script-src 'nonce-([^']+)'/)?.[1]; + + expect(response.status).toBe(200); + expect(response.headers['content-security-policy-report-only']).toBeUndefined(); + expect(nonce).toBeTruthy(); + expect(response.text).toContain(``); + expect(response.text).toContain(`', + '', + ].join(''); + + expect(applyCspNonce(html, 'abc123')).toBe( + [ + '', + '', + '', + ].join(''), + ); + }); + + it('replaces a stale nonce rather than preserving it', () => { + const html = ''; + + expect(applyCspNonce(html, 'abc123')).toBe(''); + expect(applyCspNonce(html, 'abc123')).not.toContain('from-the-build'); + }); + + it('stamps module preloads, which strict-dynamic does not cover', () => { + const html = [ + '', + '', + '', + '', + ].join(''); + + expect(applyCspNonce(html, 'abc123')).toBe( + [ + '', + '', + '', + '', + ].join(''), + ); + }); + + it('returns the html untouched without a nonce', () => { + const html = ''; + expect(applyCspNonce(html, '')).toBe(html); + }); +}); diff --git a/packages/api/src/security/csp.ts b/packages/api/src/security/csp.ts new file mode 100644 index 00000000000..11c5a1dbee6 --- /dev/null +++ b/packages/api/src/security/csp.ts @@ -0,0 +1,308 @@ +import { randomBytes } from 'crypto'; +import { logger } from '@librechat/data-schemas'; +import { parseEnvSwitch } from './env'; +import { isEnabled } from '../utils'; + +/** Split point for the per-request nonce. Randomized so no env value can collide. */ +const NONCE_SLOT = `__csp_nonce_${randomBytes(8).toString('hex')}__`; + +const DIRECTIVE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; +/** `` is in here because module preloads are fetched under `script-src`. */ +const NONCEABLE_TAG_PATTERN = /<(script|link)\b([^>]*)>/gi; +const NONCE_ATTRIBUTE_PATTERN = /\snonce\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi; +const REL_PATTERN = /\srel\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i; +const AS_SCRIPT_PATTERN = /\sas\s*=\s*(?:"script"|'script'|script\b)/i; + +type CspDirective = [string, string[]]; + +/** Precomputed once at startup; only the nonce varies per response. */ +export interface CspPolicy { + headerName: 'Content-Security-Policy' | 'Content-Security-Policy-Report-Only'; + prefix: string; + suffix: string; +} + +export interface CspResponse { + headerName: CspPolicy['headerName']; + headerValue: string; + nonce: string; +} + +const SOURCE_EXTRA_ENV: Record = { + 'default-src': 'CSP_DEFAULT_SRC_EXTRA', + 'script-src': 'CSP_SCRIPT_SRC_EXTRA', + 'style-src': 'CSP_STYLE_SRC_EXTRA', + 'img-src': 'CSP_IMG_SRC_EXTRA', + 'font-src': 'CSP_FONT_SRC_EXTRA', + 'connect-src': 'CSP_CONNECT_SRC_EXTRA', + 'media-src': 'CSP_MEDIA_SRC_EXTRA', + 'frame-src': 'CSP_FRAME_SRC_EXTRA', + 'worker-src': 'CSP_WORKER_SRC_EXTRA', + 'form-action': 'CSP_FORM_ACTION_EXTRA', +}; + +function splitSourceList(value: string | undefined): string[] { + if (!value) { + return []; + } + return value + .split(/[,\s]+/) + .map((source) => source.trim()) + .filter(Boolean); +} + +/** + * Only an explicitly recognized false value enforces. A typo or an unrecognized + * truthy spelling stays report-only, so a config slip cannot turn a rollout into + * a blocked SPA. + */ +function isReportOnly(env: NodeJS.ProcessEnv): boolean { + return parseEnvSwitch('CSP_REPORT_ONLY', env.CSP_REPORT_ONLY, true); +} + +/** + * `'strict-dynamic'` makes browsers ignore every host source in `script-src`, so it + * cannot coexist with operator-supplied script hosts. When extras are configured we + * drop it and let the (now honored) `'self'` plus those hosts govern script loading. + */ +function scriptSources(scriptExtras: string[], allowWasm: boolean): string[] { + /* 'wasm-unsafe-eval' permits WebAssembly compilation without permitting eval(); + * the HEIC upload path (client/src/utils/heicConverter.ts -> heic-to) needs it. */ + const wasm = allowWasm ? ["'wasm-unsafe-eval'"] : []; + if (scriptExtras.length === 0) { + return [`'nonce-${NONCE_SLOT}'`, "'strict-dynamic'", ...wasm, "'self'"]; + } + logger.info( + "[CSP] CSP_SCRIPT_SRC_EXTRA is set; omitting 'strict-dynamic' so the configured script hosts take effect.", + ); + return [`'nonce-${NONCE_SLOT}'`, ...wasm, "'self'"]; +} + +/** + * Styles intentionally carry no nonce. A nonce in `style-src` makes browsers ignore + * `'unsafe-inline'`, which would block every `