🗝️ fix: Protect Model Spec Instructions (#13125)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled

* fix: prevent instruction exposure

* fix: tighten model spec preset restoration

* refactor: type model spec preset handling
This commit is contained in:
Danny Avila 2026-05-14 10:07:23 -04:00 committed by GitHub
parent b993d9fb28
commit ca8c212c0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 668 additions and 21 deletions

View file

@ -1,4 +1,10 @@
const { handleError } = require('@librechat/api');
const {
handleError,
applyModelSpecPreset,
findModelSpecByName,
isModelSpecEndpointMatch,
resolveModelSpecPromptPrefixVariables,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
EndpointURLs,
@ -21,6 +27,8 @@ const buildFunction = {
async function buildEndpointOption(req, res, next) {
const { endpoint, endpointType } = req.body;
const isAgents =
isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
let endpointsConfig;
try {
@ -48,6 +56,7 @@ async function buildEndpointOption(req, res, next) {
}
const appConfig = req.config;
let appliedModelSpecPrivateFields = new Set();
if (appConfig.modelSpecs?.list?.length && appConfig.modelSpecs?.enforce) {
/** @type {{ list: TModelSpec[] }}*/
const { list } = appConfig.modelSpecs;
@ -57,41 +66,63 @@ async function buildEndpointOption(req, res, next) {
return handleError(res, { text: 'No model spec selected' });
}
const currentModelSpec = list.find((s) => s.name === spec);
const currentModelSpec = findModelSpecByName({ list }, spec);
if (!currentModelSpec) {
return handleError(res, { text: 'Invalid model spec' });
}
if (endpoint !== currentModelSpec.preset.endpoint) {
if (!isModelSpecEndpointMatch(currentModelSpec, endpoint)) {
return handleError(res, { text: 'Model spec mismatch' });
}
try {
currentModelSpec.preset.spec = spec;
parsedBody = parseCompactConvo({
const result = applyModelSpecPreset({
modelSpec: currentModelSpec,
parsedBody: currentModelSpec.preset,
endpoint,
endpointType,
conversation: currentModelSpec.preset,
defaultParamsEndpoint,
includePresetDefaults: true,
});
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
parsedBody.iconURL = currentModelSpec.iconURL;
}
parsedBody = result.parsedBody;
appliedModelSpecPrivateFields = result.appliedPrivateFields;
} catch (error) {
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
return handleError(res, { text: 'Error parsing model spec' });
}
} else if (parsedBody.spec && appConfig.modelSpecs?.list) {
// Non-enforced mode: if spec is selected, derive iconURL from model spec
const modelSpec = appConfig.modelSpecs.list.find((s) => s.name === parsedBody.spec);
if (modelSpec?.iconURL) {
parsedBody.iconURL = modelSpec.iconURL;
const modelSpec = findModelSpecByName(appConfig.modelSpecs, parsedBody.spec);
if (modelSpec) {
if (!isModelSpecEndpointMatch(modelSpec, endpoint)) {
return handleError(res, { text: 'Model spec mismatch' });
}
try {
const result = applyModelSpecPreset({
modelSpec,
parsedBody,
endpoint,
endpointType,
defaultParamsEndpoint,
});
parsedBody = result.parsedBody;
appliedModelSpecPrivateFields = result.appliedPrivateFields;
} catch (error) {
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
return handleError(res, { text: 'Error parsing model spec' });
}
}
}
if (!isAgents && appliedModelSpecPrivateFields.has('promptPrefix')) {
parsedBody = resolveModelSpecPromptPrefixVariables(
parsedBody,
req.user,
req.body.clientTimestamp,
);
}
try {
const isAgents =
isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
const builder = isAgents
? (...args) => buildFunction[EModelEndpoint.agents](req, ...args)
: buildFunction[endpointType ?? endpoint];

View file

@ -17,6 +17,10 @@ const mockBuildOptions = jest.fn((_endpoint, parsedBody) => ({
...parsedBody,
endpoint: _endpoint,
}));
const mockAgentBuildOptions = jest.fn((_req, endpoint, parsedBody) => ({
...parsedBody,
endpoint,
}));
jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
buildOptions: mockBuildOptions,
@ -25,7 +29,7 @@ jest.mock('~/server/services/Endpoints/assistants', () => ({
buildOptions: mockBuildOptions,
}));
jest.mock('~/server/services/Endpoints/agents', () => ({
buildOptions: mockBuildOptions,
buildOptions: mockAgentBuildOptions,
}));
jest.mock('~/models', () => ({
@ -38,6 +42,7 @@ jest.mock('~/server/services/Config', () => ({
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
handleError: jest.fn(),
}));
@ -207,6 +212,183 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => {
expect(enforcedResult.maxContextTokens).toBe(50000);
});
it('should restore private model spec preset fields in non-enforced mode', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const modelSpec = {
name: 'guarded-openai',
iconURL: 'openAI',
preset: {
endpoint: EModelEndpoint.openAI,
model: 'gpt-4o',
promptPrefix: 'private prompt prefix',
instructions: 'private instructions',
additional_instructions: 'private additional instructions',
temperature: 0.2,
maxContextTokens: 10000,
},
};
const req = createReq(
{
endpoint: EModelEndpoint.openAI,
spec: 'guarded-openai',
model: 'gpt-4o',
temperature: 0.8,
},
{
modelSpecs: {
enforce: false,
list: [modelSpec],
},
},
);
req.baseUrl = '/api/agents/chat';
await buildEndpointOption(req, createRes(), jest.fn());
expect(req.body.endpointOption.promptPrefix).toBe('private prompt prefix');
expect(req.body.endpointOption.instructions).toBeUndefined();
expect(req.body.endpointOption.additional_instructions).toBeUndefined();
expect(req.body.endpointOption.temperature).toBe(0.8);
expect(req.body.endpointOption.maxContextTokens).toBeUndefined();
expect(req.body.endpointOption.iconURL).toBe('openAI');
});
it('should reject non-enforced model specs for a different endpoint', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.openAI,
spec: 'guarded-google',
model: 'gpt-4o',
},
{
modelSpecs: {
enforce: false,
list: [
{
name: 'guarded-google',
preset: {
endpoint: EModelEndpoint.google,
model: 'gemini-pro',
promptPrefix: 'private google prompt',
},
},
],
},
},
);
const res = createRes();
const next = jest.fn();
const { handleError } = require('@librechat/api');
await buildEndpointOption(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Model spec mismatch' });
expect(mockAgentBuildOptions).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('should restore private model spec examples when the parser supplies an empty default', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const examples = [{ input: { content: 'hello' }, output: { content: 'world' } }];
const req = createReq(
{
endpoint: EModelEndpoint.google,
spec: 'guarded-google',
model: 'gemini-pro',
},
{
modelSpecs: {
enforce: false,
list: [
{
name: 'guarded-google',
preset: {
endpoint: EModelEndpoint.google,
model: 'gemini-pro',
examples,
},
},
],
},
},
);
req.baseUrl = '/api/agents/chat';
await buildEndpointOption(req, createRes(), jest.fn());
expect(req.body.endpointOption.examples).toEqual(examples);
});
it('should resolve special variables for restored non-agent promptPrefix', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.assistants,
spec: 'guarded-assistant',
assistant_id: 'asst_123',
},
{
modelSpecs: {
enforce: false,
list: [
{
name: 'guarded-assistant',
preset: {
endpoint: EModelEndpoint.assistants,
assistant_id: 'asst_123',
promptPrefix: 'Help {{current_user}}.',
},
},
],
},
},
);
req.user = { name: 'Ada' };
await buildEndpointOption(req, createRes(), jest.fn());
expect(req.body.endpointOption.promptPrefix).toBe('Help Ada.');
});
it('should leave restored agent promptPrefix variables for agent initialization', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.openAI,
spec: 'guarded-openai',
model: 'gpt-4o',
},
{
modelSpecs: {
enforce: false,
list: [
{
name: 'guarded-openai',
preset: {
endpoint: EModelEndpoint.openAI,
model: 'gpt-4o',
promptPrefix: 'Help {{current_user}}.',
},
},
],
},
},
);
req.baseUrl = '/api/agents/chat';
req.user = { name: 'Ada' };
await buildEndpointOption(req, createRes(), jest.fn());
expect(req.body.endpointOption.promptPrefix).toBe('Help {{current_user}}.');
});
it('should fall back to OpenAI schema when getEndpointsConfig fails', async () => {
mockGetEndpointsConfig.mockRejectedValue(new Error('Config unavailable'));