mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-18 05:36:33 +01:00
* 🔒 fix: Resolve env vars before body placeholder expansion to prevent secret exfiltration Body placeholders ({{LIBRECHAT_BODY_*}}) were substituted before extractEnvVariable ran, allowing user-controlled body fields containing ${SECRET} patterns to be expanded into real environment values in outbound headers. Reorder so env vars resolve first, preventing untrusted input from triggering env expansion. * 🛡️ fix: Block sensitive infrastructure env vars from placeholder resolution Add isSensitiveEnvVar blocklist to extractEnvVariable so that internal infrastructure secrets (JWT_SECRET, JWT_REFRESH_SECRET, CREDS_KEY, CREDS_IV, MEILI_MASTER_KEY, MONGO_URI, REDIS_URI, REDIS_PASSWORD) can never be resolved via ${VAR} expansion — even if an attacker manages to inject a placeholder pattern. Uses exact-match set (not substring patterns) to avoid breaking legitimate operator config that references OAuth/API secrets in MCP and custom endpoint configurations. * 🧹 test: Rename ANOTHER_SECRET test fixture to ANOTHER_VALUE Avoid using SECRET-containing names for non-sensitive test fixtures to prevent confusion with the new isSensitiveEnvVar blocklist. * 🔒 fix: Resolve env vars before all user-controlled substitutions in processSingleValue Move extractEnvVariable to run on the raw admin-authored template BEFORE customUserVars, user fields, OIDC tokens, and body placeholders. Previously env resolution ran after customUserVars, so a user setting a custom MCP variable to "${SECRET}" could still trigger env expansion. Now env vars are resolved strictly on operator config, and all subsequent user-controlled substitutions cannot introduce ${VAR} patterns that would be expanded. Gated by !dbSourced so DB-stored servers continue to skip env resolution. Adds a security-invariant comment documenting the ordering requirement. * 🧪 test: Comprehensive security regression tests for placeholder injection - Cover all three body fields (conversationId, parentMessageId, messageId) - Add user-field injection test (user.name containing ${VAR}) - Add customUserVars injection test (MY_TOKEN = "${VAR}") - Add processMCPEnv injection tests for body and customUserVars paths - Remove redundant process.env setup/teardown already handled by beforeEach/afterEach * 🧹 chore: Add REDIS_PASSWORD to blocklist integration test; document customUserVars gate
196 lines
8.2 KiB
TypeScript
196 lines
8.2 KiB
TypeScript
import { extractEnvVariable, isSensitiveEnvVar } from '../src/utils';
|
|
|
|
describe('Environment Variable Extraction', () => {
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
process.env = {
|
|
...originalEnv,
|
|
TEST_API_KEY: 'test-api-key-value',
|
|
ANOTHER_VALUE: 'another-value',
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
describe('extractEnvVariable (original tests)', () => {
|
|
test('should return the value of the environment variable', () => {
|
|
process.env.TEST_VAR = 'test_value';
|
|
expect(extractEnvVariable('${TEST_VAR}')).toBe('test_value');
|
|
});
|
|
|
|
test('should return the original string if the envrionment variable is not defined correctly', () => {
|
|
process.env.TEST_VAR = 'test_value';
|
|
expect(extractEnvVariable('${ TEST_VAR }')).toBe('${ TEST_VAR }');
|
|
});
|
|
|
|
test('should return the original string if environment variable is not set', () => {
|
|
expect(extractEnvVariable('${NON_EXISTENT_VAR}')).toBe('${NON_EXISTENT_VAR}');
|
|
});
|
|
|
|
test('should return the original string if it does not contain an environment variable', () => {
|
|
expect(extractEnvVariable('some_string')).toBe('some_string');
|
|
});
|
|
|
|
test('should handle empty strings', () => {
|
|
expect(extractEnvVariable('')).toBe('');
|
|
});
|
|
|
|
test('should handle strings without variable format', () => {
|
|
expect(extractEnvVariable('no_var_here')).toBe('no_var_here');
|
|
});
|
|
|
|
/** No longer the expected behavior; keeping for reference */
|
|
test.skip('should not process multiple variable formats', () => {
|
|
process.env.FIRST_VAR = 'first';
|
|
process.env.SECOND_VAR = 'second';
|
|
expect(extractEnvVariable('${FIRST_VAR} and ${SECOND_VAR}')).toBe(
|
|
'${FIRST_VAR} and ${SECOND_VAR}',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('extractEnvVariable function', () => {
|
|
it('should extract environment variables from exact matches', () => {
|
|
expect(extractEnvVariable('${TEST_API_KEY}')).toBe('test-api-key-value');
|
|
expect(extractEnvVariable('${ANOTHER_VALUE}')).toBe('another-value');
|
|
});
|
|
|
|
it('should extract environment variables from strings with prefixes', () => {
|
|
expect(extractEnvVariable('prefix-${TEST_API_KEY}')).toBe('prefix-test-api-key-value');
|
|
});
|
|
|
|
it('should extract environment variables from strings with suffixes', () => {
|
|
expect(extractEnvVariable('${TEST_API_KEY}-suffix')).toBe('test-api-key-value-suffix');
|
|
});
|
|
|
|
it('should extract environment variables from strings with both prefixes and suffixes', () => {
|
|
expect(extractEnvVariable('prefix-${TEST_API_KEY}-suffix')).toBe(
|
|
'prefix-test-api-key-value-suffix',
|
|
);
|
|
});
|
|
|
|
it('should not match invalid patterns', () => {
|
|
expect(extractEnvVariable('$TEST_API_KEY')).toBe('$TEST_API_KEY');
|
|
expect(extractEnvVariable('{TEST_API_KEY}')).toBe('{TEST_API_KEY}');
|
|
expect(extractEnvVariable('TEST_API_KEY')).toBe('TEST_API_KEY');
|
|
});
|
|
});
|
|
|
|
describe('extractEnvVariable', () => {
|
|
it('should extract environment variable values', () => {
|
|
expect(extractEnvVariable('${TEST_API_KEY}')).toBe('test-api-key-value');
|
|
expect(extractEnvVariable('${ANOTHER_VALUE}')).toBe('another-value');
|
|
});
|
|
|
|
it('should return the original string if environment variable is not found', () => {
|
|
expect(extractEnvVariable('${NON_EXISTENT_VAR}')).toBe('${NON_EXISTENT_VAR}');
|
|
});
|
|
|
|
it('should return the original string if no environment variable pattern is found', () => {
|
|
expect(extractEnvVariable('plain-string')).toBe('plain-string');
|
|
});
|
|
});
|
|
|
|
describe('extractEnvVariable space trimming', () => {
|
|
beforeEach(() => {
|
|
process.env.HELLO = 'world';
|
|
process.env.USER = 'testuser';
|
|
});
|
|
|
|
it('should extract the value when string contains only an environment variable with surrounding whitespace', () => {
|
|
expect(extractEnvVariable(' ${HELLO} ')).toBe('world');
|
|
expect(extractEnvVariable(' ${HELLO} ')).toBe('world');
|
|
expect(extractEnvVariable('\t${HELLO}\n')).toBe('world');
|
|
});
|
|
|
|
it('should preserve content when variable is part of a larger string', () => {
|
|
expect(extractEnvVariable('Hello ${USER}!')).toBe('Hello testuser!');
|
|
expect(extractEnvVariable(' Hello ${USER}! ')).toBe('Hello testuser!');
|
|
});
|
|
|
|
it('should not handle multiple variables', () => {
|
|
expect(extractEnvVariable('${HELLO} ${USER}')).toBe('${HELLO} ${USER}');
|
|
expect(extractEnvVariable(' ${HELLO} ${USER} ')).toBe('${HELLO} ${USER}');
|
|
});
|
|
|
|
it('should handle undefined variables', () => {
|
|
expect(extractEnvVariable(' ${UNDEFINED_VAR} ')).toBe('${UNDEFINED_VAR}');
|
|
});
|
|
|
|
it('should handle mixed content correctly', () => {
|
|
expect(extractEnvVariable('Welcome, ${USER}!\nYour message: ${HELLO}')).toBe(
|
|
'Welcome, testuser!\nYour message: world',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('isSensitiveEnvVar', () => {
|
|
it('should flag infrastructure secrets', () => {
|
|
expect(isSensitiveEnvVar('JWT_SECRET')).toBe(true);
|
|
expect(isSensitiveEnvVar('JWT_REFRESH_SECRET')).toBe(true);
|
|
expect(isSensitiveEnvVar('CREDS_KEY')).toBe(true);
|
|
expect(isSensitiveEnvVar('CREDS_IV')).toBe(true);
|
|
expect(isSensitiveEnvVar('MEILI_MASTER_KEY')).toBe(true);
|
|
expect(isSensitiveEnvVar('MONGO_URI')).toBe(true);
|
|
expect(isSensitiveEnvVar('REDIS_URI')).toBe(true);
|
|
expect(isSensitiveEnvVar('REDIS_PASSWORD')).toBe(true);
|
|
});
|
|
|
|
it('should allow non-infrastructure vars through (including operator-configured secrets)', () => {
|
|
expect(isSensitiveEnvVar('OPENAI_API_KEY')).toBe(false);
|
|
expect(isSensitiveEnvVar('ANTHROPIC_API_KEY')).toBe(false);
|
|
expect(isSensitiveEnvVar('GOOGLE_KEY')).toBe(false);
|
|
expect(isSensitiveEnvVar('PROXY')).toBe(false);
|
|
expect(isSensitiveEnvVar('DEBUG_LOGGING')).toBe(false);
|
|
expect(isSensitiveEnvVar('DOMAIN_CLIENT')).toBe(false);
|
|
expect(isSensitiveEnvVar('APP_TITLE')).toBe(false);
|
|
expect(isSensitiveEnvVar('OPENID_CLIENT_SECRET')).toBe(false);
|
|
expect(isSensitiveEnvVar('DISCORD_CLIENT_SECRET')).toBe(false);
|
|
expect(isSensitiveEnvVar('MY_CUSTOM_SECRET')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('extractEnvVariable sensitive var blocklist', () => {
|
|
beforeEach(() => {
|
|
process.env.JWT_SECRET = 'super-secret-jwt';
|
|
process.env.JWT_REFRESH_SECRET = 'super-secret-refresh';
|
|
process.env.CREDS_KEY = 'encryption-key';
|
|
process.env.CREDS_IV = 'encryption-iv';
|
|
process.env.MEILI_MASTER_KEY = 'meili-key';
|
|
process.env.MONGO_URI = 'mongodb://user:pass@host/db';
|
|
process.env.REDIS_URI = 'redis://:pass@host:6379';
|
|
process.env.REDIS_PASSWORD = 'redis-pass';
|
|
process.env.OPENAI_API_KEY = 'sk-legit-key';
|
|
});
|
|
|
|
it('should refuse to resolve sensitive vars (single-match path)', () => {
|
|
expect(extractEnvVariable('${JWT_SECRET}')).toBe('${JWT_SECRET}');
|
|
expect(extractEnvVariable('${JWT_REFRESH_SECRET}')).toBe('${JWT_REFRESH_SECRET}');
|
|
expect(extractEnvVariable('${CREDS_KEY}')).toBe('${CREDS_KEY}');
|
|
expect(extractEnvVariable('${CREDS_IV}')).toBe('${CREDS_IV}');
|
|
expect(extractEnvVariable('${MEILI_MASTER_KEY}')).toBe('${MEILI_MASTER_KEY}');
|
|
expect(extractEnvVariable('${MONGO_URI}')).toBe('${MONGO_URI}');
|
|
expect(extractEnvVariable('${REDIS_URI}')).toBe('${REDIS_URI}');
|
|
expect(extractEnvVariable('${REDIS_PASSWORD}')).toBe('${REDIS_PASSWORD}');
|
|
});
|
|
|
|
it('should refuse to resolve sensitive vars in composite strings (multi-match path)', () => {
|
|
expect(extractEnvVariable('key=${JWT_SECRET}&more')).toBe('key=${JWT_SECRET}&more');
|
|
expect(extractEnvVariable('db=${MONGO_URI}/extra')).toBe('db=${MONGO_URI}/extra');
|
|
});
|
|
|
|
it('should still resolve non-sensitive vars normally', () => {
|
|
expect(extractEnvVariable('${OPENAI_API_KEY}')).toBe('sk-legit-key');
|
|
expect(extractEnvVariable('Bearer ${OPENAI_API_KEY}')).toBe('Bearer sk-legit-key');
|
|
});
|
|
|
|
it('should resolve non-sensitive vars while blocking sensitive ones in the same string', () => {
|
|
expect(extractEnvVariable('key=${OPENAI_API_KEY}&secret=${JWT_SECRET}')).toBe(
|
|
'key=sk-legit-key&secret=${JWT_SECRET}',
|
|
);
|
|
});
|
|
});
|
|
});
|