🛡️ feat: Add Middleware for JSON Parsing and Prompt Group Updates (#10757)

* 🗨️ fix: Safe Validation for Prompt Updates

- Added `safeValidatePromptGroupUpdate` function to validate and sanitize prompt group update requests, ensuring only allowed fields are processed and sensitive fields are stripped.
- Updated the `patchPromptGroup` route to utilize the new validation function, returning appropriate error messages for invalid requests.
- Introduced comprehensive tests for the validation logic, covering various scenarios including allowed and disallowed fields, enhancing overall request integrity and security.
- Created a new schema file for prompt group updates, defining validation rules and types for better maintainability.

* 🔒 feat: Add JSON parse error handling middleware
This commit is contained in:
Danny Avila 2025-12-02 00:10:30 -05:00 committed by GitHub
parent 6fa94d3eb8
commit 01413eea3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 653 additions and 1 deletions

View file

@ -1,3 +1,4 @@
export * from './access';
export * from './error';
export * from './balance';
export * from './json';

View file

@ -0,0 +1,158 @@
import { handleJsonParseError } from './json';
import type { Request, Response, NextFunction } from 'express';
describe('handleJsonParseError', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;
let jsonSpy: jest.Mock;
let statusSpy: jest.Mock;
beforeEach(() => {
req = {
path: '/api/test',
method: 'POST',
ip: '127.0.0.1',
};
jsonSpy = jest.fn();
statusSpy = jest.fn().mockReturnValue({ json: jsonSpy });
res = {
status: statusSpy,
json: jsonSpy,
};
next = jest.fn();
});
describe('JSON parse errors', () => {
it('should handle JSON SyntaxError with 400 status', () => {
const err = new SyntaxError('Unexpected token < in JSON at position 0') as SyntaxError & {
status?: number;
body?: unknown;
};
err.status = 400;
err.body = {};
handleJsonParseError(err, req as Request, res as Response, next);
expect(statusSpy).toHaveBeenCalledWith(400);
expect(jsonSpy).toHaveBeenCalledWith({
error: 'Invalid JSON format',
message: 'The request body contains malformed JSON',
});
expect(next).not.toHaveBeenCalled();
});
it('should not reflect user input in error message', () => {
const maliciousInput = '<script>alert("xss")</script>';
const err = new SyntaxError(
`Unexpected token < in JSON at position 0: ${maliciousInput}`,
) as SyntaxError & {
status?: number;
body?: unknown;
};
err.status = 400;
err.body = maliciousInput;
handleJsonParseError(err, req as Request, res as Response, next);
expect(statusSpy).toHaveBeenCalledWith(400);
const errorResponse = jsonSpy.mock.calls[0][0];
expect(errorResponse.message).not.toContain(maliciousInput);
expect(errorResponse.message).toBe('The request body contains malformed JSON');
expect(next).not.toHaveBeenCalled();
});
it('should handle JSON parse error with HTML tags in body', () => {
const err = new SyntaxError('Invalid JSON') as SyntaxError & {
status?: number;
body?: unknown;
};
err.status = 400;
err.body = '<html><body><h1>XSS</h1></body></html>';
handleJsonParseError(err, req as Request, res as Response, next);
expect(statusSpy).toHaveBeenCalledWith(400);
const errorResponse = jsonSpy.mock.calls[0][0];
expect(errorResponse.message).not.toContain('<html>');
expect(errorResponse.message).not.toContain('<script>');
expect(next).not.toHaveBeenCalled();
});
});
describe('non-JSON errors', () => {
it('should pass through non-SyntaxError errors', () => {
const err = new Error('Some other error');
handleJsonParseError(err, req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(statusSpy).not.toHaveBeenCalled();
expect(jsonSpy).not.toHaveBeenCalled();
});
it('should pass through SyntaxError without status 400', () => {
const err = new SyntaxError('Some syntax error') as SyntaxError & { status?: number };
err.status = 500;
handleJsonParseError(err, req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(statusSpy).not.toHaveBeenCalled();
});
it('should pass through SyntaxError without body property', () => {
const err = new SyntaxError('Some syntax error') as SyntaxError & { status?: number };
err.status = 400;
handleJsonParseError(err, req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(statusSpy).not.toHaveBeenCalled();
});
it('should pass through TypeError', () => {
const err = new TypeError('Type error');
handleJsonParseError(err, req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(statusSpy).not.toHaveBeenCalled();
});
});
describe('security verification', () => {
it('should return generic error message for all JSON parse errors', () => {
const testCases = [
'Unexpected token < in JSON',
'Unexpected end of JSON input',
'Invalid or unexpected token',
'<script>alert(1)</script>',
'"><img src=x onerror=alert(1)>',
];
testCases.forEach((errorMsg) => {
const err = new SyntaxError(errorMsg) as SyntaxError & {
status?: number;
body?: unknown;
};
err.status = 400;
err.body = errorMsg;
jsonSpy.mockClear();
statusSpy.mockClear();
(next as jest.Mock).mockClear();
handleJsonParseError(err, req as Request, res as Response, next);
const errorResponse = jsonSpy.mock.calls[0][0];
// Verify the generic message is always returned, not the user input
expect(errorResponse.message).toBe('The request body contains malformed JSON');
expect(errorResponse.error).toBe('Invalid JSON format');
});
});
});
});

View file

@ -0,0 +1,40 @@
import { logger } from '@librechat/data-schemas';
import type { Request, Response, NextFunction } from 'express';
/**
* Middleware to handle JSON parsing errors from express.json()
* Prevents user input from being reflected in error messages (XSS prevention)
*
* This middleware should be placed immediately after express.json() middleware.
*
* @param err - Error object from express.json()
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*
* @example
* app.use(express.json({ limit: '3mb' }));
* app.use(handleJsonParseError);
*/
export function handleJsonParseError(
err: Error & { status?: number; body?: unknown },
req: Request,
res: Response,
next: NextFunction,
): void {
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
logger.warn('[JSON Parse Error] Invalid JSON received', {
path: req.path,
method: req.method,
ip: req.ip,
});
res.status(400).json({
error: 'Invalid JSON format',
message: 'The request body contains malformed JSON',
});
return;
}
next(err);
}

View file

@ -1,2 +1,3 @@
export * from './format';
export * from './migration';
export * from './schemas';

View file

@ -0,0 +1,222 @@
import {
updatePromptGroupSchema,
validatePromptGroupUpdate,
safeValidatePromptGroupUpdate,
} from './schemas';
describe('updatePromptGroupSchema', () => {
describe('allowed fields', () => {
it('should accept valid name field', () => {
const result = updatePromptGroupSchema.safeParse({ name: 'Test Group' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Test Group');
}
});
it('should accept valid oneliner field', () => {
const result = updatePromptGroupSchema.safeParse({ oneliner: 'A short description' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.oneliner).toBe('A short description');
}
});
it('should accept valid category field', () => {
const result = updatePromptGroupSchema.safeParse({ category: 'testing' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.category).toBe('testing');
}
});
it('should accept valid projectIds array', () => {
const result = updatePromptGroupSchema.safeParse({
projectIds: ['proj1', 'proj2'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.projectIds).toEqual(['proj1', 'proj2']);
}
});
it('should accept valid removeProjectIds array', () => {
const result = updatePromptGroupSchema.safeParse({
removeProjectIds: ['proj1'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.removeProjectIds).toEqual(['proj1']);
}
});
it('should accept valid command field', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'my-command-123' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.command).toBe('my-command-123');
}
});
it('should accept null command field', () => {
const result = updatePromptGroupSchema.safeParse({ command: null });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.command).toBeNull();
}
});
it('should accept multiple valid fields', () => {
const input = {
name: 'Updated Name',
category: 'new-category',
oneliner: 'New description',
};
const result = updatePromptGroupSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(input);
}
});
it('should accept empty object', () => {
const result = updatePromptGroupSchema.safeParse({});
expect(result.success).toBe(true);
});
});
describe('security - strips sensitive fields', () => {
it('should reject author field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
author: '507f1f77bcf86cd799439011',
});
expect(result.success).toBe(false);
});
it('should reject authorName field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
authorName: 'Malicious Author',
});
expect(result.success).toBe(false);
});
it('should reject _id field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
_id: '507f1f77bcf86cd799439011',
});
expect(result.success).toBe(false);
});
it('should reject productionId field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
productionId: '507f1f77bcf86cd799439011',
});
expect(result.success).toBe(false);
});
it('should reject createdAt field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
createdAt: new Date().toISOString(),
});
expect(result.success).toBe(false);
});
it('should reject updatedAt field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
updatedAt: new Date().toISOString(),
});
expect(result.success).toBe(false);
});
it('should reject __v field', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Test',
__v: 999,
});
expect(result.success).toBe(false);
});
it('should reject multiple sensitive fields in a single request', () => {
const result = updatePromptGroupSchema.safeParse({
name: 'Legit Name',
author: '507f1f77bcf86cd799439011',
authorName: 'Hacker',
_id: 'newid123',
productionId: 'prodid456',
createdAt: '2020-01-01T00:00:00.000Z',
__v: 999,
});
expect(result.success).toBe(false);
});
});
describe('validation rules', () => {
it('should reject empty name', () => {
const result = updatePromptGroupSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
});
it('should reject name exceeding max length', () => {
const result = updatePromptGroupSchema.safeParse({ name: 'a'.repeat(256) });
expect(result.success).toBe(false);
});
it('should reject oneliner exceeding max length', () => {
const result = updatePromptGroupSchema.safeParse({ oneliner: 'a'.repeat(501) });
expect(result.success).toBe(false);
});
it('should reject category exceeding max length', () => {
const result = updatePromptGroupSchema.safeParse({ category: 'a'.repeat(101) });
expect(result.success).toBe(false);
});
it('should reject command with invalid characters (uppercase)', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'MyCommand' });
expect(result.success).toBe(false);
});
it('should reject command with invalid characters (spaces)', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'my command' });
expect(result.success).toBe(false);
});
it('should reject command with invalid characters (special)', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'my_command!' });
expect(result.success).toBe(false);
});
});
});
describe('validatePromptGroupUpdate', () => {
it('should return validated data for valid input', () => {
const input = { name: 'Test', category: 'testing' };
const result = validatePromptGroupUpdate(input);
expect(result).toEqual(input);
});
it('should throw ZodError for invalid input', () => {
expect(() => validatePromptGroupUpdate({ author: 'malicious-id' })).toThrow();
});
});
describe('safeValidatePromptGroupUpdate', () => {
it('should return success true for valid input', () => {
const result = safeValidatePromptGroupUpdate({ name: 'Test' });
expect(result.success).toBe(true);
});
it('should return success false for invalid input with errors', () => {
const result = safeValidatePromptGroupUpdate({ author: 'malicious-id' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors.length).toBeGreaterThan(0);
}
});
});

View file

@ -0,0 +1,53 @@
import { z } from 'zod';
import { Constants } from 'librechat-data-provider';
/**
* Schema for validating prompt group update payloads.
* Only allows fields that users should be able to modify.
* Sensitive fields like author, authorName, _id, productionId, etc. are excluded.
*/
export const updatePromptGroupSchema = z
.object({
/** The name of the prompt group */
name: z.string().min(1).max(255).optional(),
/** Short description/oneliner for the prompt group */
oneliner: z.string().max(500).optional(),
/** Category for organizing prompt groups */
category: z.string().max(100).optional(),
/** Project IDs to add for sharing */
projectIds: z.array(z.string()).optional(),
/** Project IDs to remove from sharing */
removeProjectIds: z.array(z.string()).optional(),
/** Command shortcut for the prompt group */
command: z
.string()
.max(Constants.COMMANDS_MAX_LENGTH as number)
.regex(/^[a-z0-9-]*$/, {
message: 'Command must only contain lowercase alphanumeric characters and hyphens',
})
.optional()
.nullable(),
})
.strict();
export type TUpdatePromptGroupSchema = z.infer<typeof updatePromptGroupSchema>;
/**
* Validates and sanitizes a prompt group update payload.
* Returns only the allowed fields, stripping any sensitive fields.
* @param data - The raw request body to validate
* @returns The validated and sanitized payload
* @throws ZodError if validation fails
*/
export function validatePromptGroupUpdate(data: unknown): TUpdatePromptGroupSchema {
return updatePromptGroupSchema.parse(data);
}
/**
* Safely validates a prompt group update payload without throwing.
* @param data - The raw request body to validate
* @returns A SafeParseResult with either the validated data or validation errors
*/
export function safeValidatePromptGroupUpdate(data: unknown) {
return updatePromptGroupSchema.safeParse(data);
}