mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-03 00:58:50 +01:00
🧮 refactor: Replace Eval with Safe Math Expression Parser (#11098)
* chore: Add mathjs dependency * refactor: Replace eval with mathjs for safer expression evaluation and improve session expiry handling to not environment variables from data-schemas package * test: Add integration tests for math function with environment variable expressions * refactor: Update test description for clarity on expiresIn behavior * refactor: Update test cases to clarify default expiration behavior for token generation * refactor: Improve error handling in math function for clearer evaluation errors
This commit is contained in:
parent
d0863de8d4
commit
6ffb176056
14 changed files with 602 additions and 85 deletions
196
packages/api/src/utils/math.integration.spec.ts
Normal file
196
packages/api/src/utils/math.integration.spec.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Integration tests for math function with actual config patterns.
|
||||
* These tests verify that real environment variable expressions from .env.example
|
||||
* are correctly evaluated by the math function.
|
||||
*/
|
||||
import { math } from './math';
|
||||
|
||||
describe('math - integration with real config patterns', () => {
|
||||
describe('SESSION_EXPIRY patterns', () => {
|
||||
test('should evaluate default SESSION_EXPIRY (15 minutes)', () => {
|
||||
const result = math('1000 * 60 * 15');
|
||||
expect(result).toBe(900000); // 15 minutes in ms
|
||||
});
|
||||
|
||||
test('should evaluate 30 minute session', () => {
|
||||
const result = math('1000 * 60 * 30');
|
||||
expect(result).toBe(1800000); // 30 minutes in ms
|
||||
});
|
||||
|
||||
test('should evaluate 1 hour session', () => {
|
||||
const result = math('1000 * 60 * 60');
|
||||
expect(result).toBe(3600000); // 1 hour in ms
|
||||
});
|
||||
});
|
||||
|
||||
describe('REFRESH_TOKEN_EXPIRY patterns', () => {
|
||||
test('should evaluate default REFRESH_TOKEN_EXPIRY (7 days)', () => {
|
||||
const result = math('(1000 * 60 * 60 * 24) * 7');
|
||||
expect(result).toBe(604800000); // 7 days in ms
|
||||
});
|
||||
|
||||
test('should evaluate 1 day refresh token', () => {
|
||||
const result = math('1000 * 60 * 60 * 24');
|
||||
expect(result).toBe(86400000); // 1 day in ms
|
||||
});
|
||||
|
||||
test('should evaluate 30 day refresh token', () => {
|
||||
const result = math('(1000 * 60 * 60 * 24) * 30');
|
||||
expect(result).toBe(2592000000); // 30 days in ms
|
||||
});
|
||||
});
|
||||
|
||||
describe('BAN_DURATION patterns', () => {
|
||||
test('should evaluate default BAN_DURATION (2 hours)', () => {
|
||||
const result = math('1000 * 60 * 60 * 2');
|
||||
expect(result).toBe(7200000); // 2 hours in ms
|
||||
});
|
||||
|
||||
test('should evaluate 24 hour ban', () => {
|
||||
const result = math('1000 * 60 * 60 * 24');
|
||||
expect(result).toBe(86400000); // 24 hours in ms
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redis config patterns', () => {
|
||||
test('should evaluate REDIS_RETRY_MAX_DELAY', () => {
|
||||
expect(math('3000')).toBe(3000);
|
||||
});
|
||||
|
||||
test('should evaluate REDIS_RETRY_MAX_ATTEMPTS', () => {
|
||||
expect(math('10')).toBe(10);
|
||||
});
|
||||
|
||||
test('should evaluate REDIS_CONNECT_TIMEOUT', () => {
|
||||
expect(math('10000')).toBe(10000);
|
||||
});
|
||||
|
||||
test('should evaluate REDIS_MAX_LISTENERS', () => {
|
||||
expect(math('40')).toBe(40);
|
||||
});
|
||||
|
||||
test('should evaluate REDIS_DELETE_CHUNK_SIZE', () => {
|
||||
expect(math('1000')).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP config patterns', () => {
|
||||
test('should evaluate MCP_OAUTH_DETECTION_TIMEOUT', () => {
|
||||
expect(math('5000')).toBe(5000);
|
||||
});
|
||||
|
||||
test('should evaluate MCP_CONNECTION_CHECK_TTL', () => {
|
||||
expect(math('60000')).toBe(60000); // 1 minute
|
||||
});
|
||||
|
||||
test('should evaluate MCP_USER_CONNECTION_IDLE_TIMEOUT (15 minutes)', () => {
|
||||
const result = math('15 * 60 * 1000');
|
||||
expect(result).toBe(900000); // 15 minutes in ms
|
||||
});
|
||||
|
||||
test('should evaluate MCP_REGISTRY_CACHE_TTL', () => {
|
||||
expect(math('5000')).toBe(5000); // 5 seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leader election config patterns', () => {
|
||||
test('should evaluate LEADER_LEASE_DURATION (25 seconds)', () => {
|
||||
expect(math('25')).toBe(25);
|
||||
});
|
||||
|
||||
test('should evaluate LEADER_RENEW_INTERVAL (10 seconds)', () => {
|
||||
expect(math('10')).toBe(10);
|
||||
});
|
||||
|
||||
test('should evaluate LEADER_RENEW_ATTEMPTS', () => {
|
||||
expect(math('3')).toBe(3);
|
||||
});
|
||||
|
||||
test('should evaluate LEADER_RENEW_RETRY_DELAY (0.5 seconds)', () => {
|
||||
expect(math('0.5')).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenID config patterns', () => {
|
||||
test('should evaluate OPENID_JWKS_URL_CACHE_TIME (10 minutes)', () => {
|
||||
const result = math('600000');
|
||||
expect(result).toBe(600000); // 10 minutes in ms
|
||||
});
|
||||
|
||||
test('should evaluate custom cache time expression', () => {
|
||||
const result = math('1000 * 60 * 10');
|
||||
expect(result).toBe(600000); // 10 minutes in ms
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulated process.env usage', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
test('should work with SESSION_EXPIRY from env', () => {
|
||||
process.env.SESSION_EXPIRY = '1000 * 60 * 15';
|
||||
const result = math(process.env.SESSION_EXPIRY, 900000);
|
||||
expect(result).toBe(900000);
|
||||
});
|
||||
|
||||
test('should work with REFRESH_TOKEN_EXPIRY from env', () => {
|
||||
process.env.REFRESH_TOKEN_EXPIRY = '(1000 * 60 * 60 * 24) * 7';
|
||||
const result = math(process.env.REFRESH_TOKEN_EXPIRY, 604800000);
|
||||
expect(result).toBe(604800000);
|
||||
});
|
||||
|
||||
test('should work with BAN_DURATION from env', () => {
|
||||
process.env.BAN_DURATION = '1000 * 60 * 60 * 2';
|
||||
const result = math(process.env.BAN_DURATION, 7200000);
|
||||
expect(result).toBe(7200000);
|
||||
});
|
||||
|
||||
test('should use fallback when env var is undefined', () => {
|
||||
delete process.env.SESSION_EXPIRY;
|
||||
const result = math(process.env.SESSION_EXPIRY, 900000);
|
||||
expect(result).toBe(900000);
|
||||
});
|
||||
|
||||
test('should use fallback when env var is empty string', () => {
|
||||
process.env.SESSION_EXPIRY = '';
|
||||
const result = math(process.env.SESSION_EXPIRY, 900000);
|
||||
expect(result).toBe(900000);
|
||||
});
|
||||
|
||||
test('should use fallback when env var has invalid expression', () => {
|
||||
process.env.SESSION_EXPIRY = 'invalid';
|
||||
const result = math(process.env.SESSION_EXPIRY, 900000);
|
||||
expect(result).toBe(900000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time calculation helpers', () => {
|
||||
// Helper functions to make time calculations more readable
|
||||
const seconds = (n: number) => n * 1000;
|
||||
const minutes = (n: number) => seconds(n * 60);
|
||||
const hours = (n: number) => minutes(n * 60);
|
||||
const days = (n: number) => hours(n * 24);
|
||||
|
||||
test('should match helper calculations', () => {
|
||||
// Verify our math function produces same results as programmatic calculations
|
||||
expect(math('1000 * 60 * 15')).toBe(minutes(15));
|
||||
expect(math('1000 * 60 * 60 * 2')).toBe(hours(2));
|
||||
expect(math('(1000 * 60 * 60 * 24) * 7')).toBe(days(7));
|
||||
});
|
||||
|
||||
test('should handle complex expressions', () => {
|
||||
// 2 hours + 30 minutes
|
||||
expect(math('(1000 * 60 * 60 * 2) + (1000 * 60 * 30)')).toBe(hours(2) + minutes(30));
|
||||
|
||||
// Half a day
|
||||
expect(math('(1000 * 60 * 60 * 24) / 2')).toBe(days(1) / 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
326
packages/api/src/utils/math.spec.ts
Normal file
326
packages/api/src/utils/math.spec.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { math } from './math';
|
||||
|
||||
describe('math', () => {
|
||||
describe('number input passthrough', () => {
|
||||
test('should return number as-is when input is a number', () => {
|
||||
expect(math(42)).toBe(42);
|
||||
});
|
||||
|
||||
test('should return zero when input is 0', () => {
|
||||
expect(math(0)).toBe(0);
|
||||
});
|
||||
|
||||
test('should return negative numbers as-is', () => {
|
||||
expect(math(-10)).toBe(-10);
|
||||
});
|
||||
|
||||
test('should return decimal numbers as-is', () => {
|
||||
expect(math(0.5)).toBe(0.5);
|
||||
});
|
||||
|
||||
test('should return very large numbers as-is', () => {
|
||||
expect(math(Number.MAX_SAFE_INTEGER)).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('simple string number parsing', () => {
|
||||
test('should parse simple integer string', () => {
|
||||
expect(math('42')).toBe(42);
|
||||
});
|
||||
|
||||
test('should parse zero string', () => {
|
||||
expect(math('0')).toBe(0);
|
||||
});
|
||||
|
||||
test('should parse negative number string', () => {
|
||||
expect(math('-10')).toBe(-10);
|
||||
});
|
||||
|
||||
test('should parse decimal string', () => {
|
||||
expect(math('0.5')).toBe(0.5);
|
||||
});
|
||||
|
||||
test('should parse string with leading/trailing spaces', () => {
|
||||
expect(math(' 42 ')).toBe(42);
|
||||
});
|
||||
|
||||
test('should parse large number string', () => {
|
||||
expect(math('9007199254740991')).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mathematical expressions - multiplication', () => {
|
||||
test('should evaluate simple multiplication', () => {
|
||||
expect(math('2 * 3')).toBe(6);
|
||||
});
|
||||
|
||||
test('should evaluate chained multiplication (BAN_DURATION pattern: 1000 * 60 * 60 * 2)', () => {
|
||||
// 2 hours in milliseconds
|
||||
expect(math('1000 * 60 * 60 * 2')).toBe(7200000);
|
||||
});
|
||||
|
||||
test('should evaluate SESSION_EXPIRY pattern (1000 * 60 * 15)', () => {
|
||||
// 15 minutes in milliseconds
|
||||
expect(math('1000 * 60 * 15')).toBe(900000);
|
||||
});
|
||||
|
||||
test('should evaluate multiplication without spaces', () => {
|
||||
expect(math('2*3')).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mathematical expressions - addition and subtraction', () => {
|
||||
test('should evaluate simple addition', () => {
|
||||
expect(math('2 + 3')).toBe(5);
|
||||
});
|
||||
|
||||
test('should evaluate simple subtraction', () => {
|
||||
expect(math('10 - 3')).toBe(7);
|
||||
});
|
||||
|
||||
test('should evaluate mixed addition and subtraction', () => {
|
||||
expect(math('10 + 5 - 3')).toBe(12);
|
||||
});
|
||||
|
||||
test('should handle negative results', () => {
|
||||
expect(math('3 - 10')).toBe(-7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mathematical expressions - division', () => {
|
||||
test('should evaluate simple division', () => {
|
||||
expect(math('10 / 2')).toBe(5);
|
||||
});
|
||||
|
||||
test('should evaluate division resulting in decimal', () => {
|
||||
expect(math('7 / 2')).toBe(3.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mathematical expressions - parentheses', () => {
|
||||
test('should evaluate expression with parentheses (REFRESH_TOKEN_EXPIRY pattern)', () => {
|
||||
// 7 days in milliseconds: (1000 * 60 * 60 * 24) * 7
|
||||
expect(math('(1000 * 60 * 60 * 24) * 7')).toBe(604800000);
|
||||
});
|
||||
|
||||
test('should evaluate nested parentheses', () => {
|
||||
expect(math('((2 + 3) * 4)')).toBe(20);
|
||||
});
|
||||
|
||||
test('should respect operator precedence with parentheses', () => {
|
||||
expect(math('2 * (3 + 4)')).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mathematical expressions - modulo', () => {
|
||||
test('should evaluate modulo operation', () => {
|
||||
expect(math('10 % 3')).toBe(1);
|
||||
});
|
||||
|
||||
test('should evaluate modulo with larger numbers', () => {
|
||||
expect(math('100 % 7')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex real-world expressions', () => {
|
||||
test('should evaluate MCP_USER_CONNECTION_IDLE_TIMEOUT pattern (15 * 60 * 1000)', () => {
|
||||
// 15 minutes in milliseconds
|
||||
expect(math('15 * 60 * 1000')).toBe(900000);
|
||||
});
|
||||
|
||||
test('should evaluate Redis default TTL (5000)', () => {
|
||||
expect(math('5000')).toBe(5000);
|
||||
});
|
||||
|
||||
test('should evaluate LEADER_RENEW_RETRY_DELAY decimal (0.5)', () => {
|
||||
expect(math('0.5')).toBe(0.5);
|
||||
});
|
||||
|
||||
test('should evaluate BAN_DURATION default (7200000)', () => {
|
||||
// 2 hours in milliseconds
|
||||
expect(math('7200000')).toBe(7200000);
|
||||
});
|
||||
|
||||
test('should evaluate expression with mixed operators and parentheses', () => {
|
||||
// (1 hour + 30 min) in ms
|
||||
expect(math('(1000 * 60 * 60) + (1000 * 60 * 30)')).toBe(5400000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback value behavior', () => {
|
||||
test('should return fallback when input is undefined', () => {
|
||||
expect(math(undefined, 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should return fallback when input is null', () => {
|
||||
// @ts-expect-error - testing runtime behavior with invalid input
|
||||
expect(math(null, 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should return fallback when input contains invalid characters', () => {
|
||||
expect(math('abc', 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should return fallback when input has SQL injection attempt', () => {
|
||||
expect(math('1; DROP TABLE users;', 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should return fallback when input has function call attempt', () => {
|
||||
expect(math('console.log("hacked")', 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should return fallback when input is empty string', () => {
|
||||
expect(math('', 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should return zero fallback when specified', () => {
|
||||
expect(math(undefined, 0)).toBe(0);
|
||||
});
|
||||
|
||||
test('should use number input even when fallback is provided', () => {
|
||||
expect(math(42, 100)).toBe(42);
|
||||
});
|
||||
|
||||
test('should use valid string even when fallback is provided', () => {
|
||||
expect(math('42', 100)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases without fallback', () => {
|
||||
test('should throw error when input is undefined without fallback', () => {
|
||||
expect(() => math(undefined)).toThrow('str is undefined, but should be a string');
|
||||
});
|
||||
|
||||
test('should throw error when input is null without fallback', () => {
|
||||
// @ts-expect-error - testing runtime behavior with invalid input
|
||||
expect(() => math(null)).toThrow('str is object, but should be a string');
|
||||
});
|
||||
|
||||
test('should throw error when input contains invalid characters without fallback', () => {
|
||||
expect(() => math('abc')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should throw error when input has letter characters', () => {
|
||||
expect(() => math('10x')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should throw error when input has special characters', () => {
|
||||
expect(() => math('10!')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should throw error for malicious code injection', () => {
|
||||
expect(() => math('process.exit(1)')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should throw error for require injection', () => {
|
||||
expect(() => math('require("fs")')).toThrow('Invalid characters in string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('security - input validation', () => {
|
||||
test('should reject strings with alphabetic characters', () => {
|
||||
expect(() => math('Math.PI')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should reject strings with brackets', () => {
|
||||
expect(() => math('[1,2,3]')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should reject strings with curly braces', () => {
|
||||
expect(() => math('{}')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should reject strings with semicolons', () => {
|
||||
expect(() => math('1;2')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should reject strings with quotes', () => {
|
||||
expect(() => math('"test"')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should reject strings with backticks', () => {
|
||||
expect(() => math('`test`')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should reject strings with equals sign', () => {
|
||||
expect(() => math('x=1')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should reject strings with ampersand', () => {
|
||||
expect(() => math('1 && 2')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should reject strings with pipe', () => {
|
||||
expect(() => math('1 || 2')).toThrow('Invalid characters in string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle expression resulting in Infinity with fallback', () => {
|
||||
// Division by zero returns Infinity, which is technically a number
|
||||
expect(math('1 / 0')).toBe(Infinity);
|
||||
});
|
||||
|
||||
test('should handle very small decimals', () => {
|
||||
expect(math('0.001')).toBe(0.001);
|
||||
});
|
||||
|
||||
test('should handle scientific notation format', () => {
|
||||
// Note: 'e' is not in the allowed character set, so this should fail
|
||||
expect(() => math('1e3')).toThrow('Invalid characters in string');
|
||||
});
|
||||
|
||||
test('should handle expression with only whitespace with fallback', () => {
|
||||
expect(math(' ', 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should handle +number syntax', () => {
|
||||
expect(math('+42')).toBe(42);
|
||||
});
|
||||
|
||||
test('should handle expression starting with negative', () => {
|
||||
expect(math('-5 + 10')).toBe(5);
|
||||
});
|
||||
|
||||
test('should handle multiple decimal points with fallback', () => {
|
||||
// Invalid syntax should return fallback value
|
||||
expect(math('1.2.3', 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should throw for multiple decimal points without fallback', () => {
|
||||
expect(() => math('1.2.3')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('type coercion edge cases', () => {
|
||||
test('should handle object input with fallback', () => {
|
||||
// @ts-expect-error - testing runtime behavior with invalid input
|
||||
expect(math({}, 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should handle array input with fallback', () => {
|
||||
// @ts-expect-error - testing runtime behavior with invalid input
|
||||
expect(math([], 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should handle boolean true with fallback', () => {
|
||||
// @ts-expect-error - testing runtime behavior with invalid input
|
||||
expect(math(true, 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should handle boolean false with fallback', () => {
|
||||
// @ts-expect-error - testing runtime behavior with invalid input
|
||||
expect(math(false, 100)).toBe(100);
|
||||
});
|
||||
|
||||
test('should throw for object input without fallback', () => {
|
||||
// @ts-expect-error - testing runtime behavior with invalid input
|
||||
expect(() => math({})).toThrow('str is object, but should be a string');
|
||||
});
|
||||
|
||||
test('should throw for array input without fallback', () => {
|
||||
// @ts-expect-error - testing runtime behavior with invalid input
|
||||
expect(() => math([])).toThrow('str is object, but should be a string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { evaluate } from 'mathjs';
|
||||
|
||||
/**
|
||||
* Evaluates a mathematical expression provided as a string and returns the result.
|
||||
*
|
||||
|
|
@ -5,6 +7,8 @@
|
|||
* If the input is not a string or contains invalid characters, an error is thrown.
|
||||
* If the evaluated result is not a number, an error is thrown.
|
||||
*
|
||||
* Uses mathjs for safe expression evaluation instead of eval().
|
||||
*
|
||||
* @param str - The mathematical expression to evaluate, or a number.
|
||||
* @param fallbackValue - The default value to return if the input is not a string or number, or if the evaluated result is not a number.
|
||||
*
|
||||
|
|
@ -32,14 +36,22 @@ export function math(str: string | number | undefined, fallbackValue?: number):
|
|||
throw new Error('Invalid characters in string');
|
||||
}
|
||||
|
||||
const value = eval(str);
|
||||
try {
|
||||
const value = evaluate(str);
|
||||
|
||||
if (typeof value !== 'number') {
|
||||
if (typeof value !== 'number') {
|
||||
if (fallback) {
|
||||
return fallbackValue;
|
||||
}
|
||||
throw new Error(`[math] str did not evaluate to a number but to a ${typeof value}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (fallback) {
|
||||
return fallbackValue;
|
||||
}
|
||||
throw new Error(`[math] str did not evaluate to a number but to a ${typeof value}`);
|
||||
const originalMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`[math] Error while evaluating mathematical expression: ${originalMessage}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue