🧯 fix: Prevent Env-Variable Exfil. via Placeholder Injection (#12260)

* 🔒 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
This commit is contained in:
Danny Avila 2026-03-16 08:48:24 -04:00 committed by GitHub
parent 85e24e4c61
commit 951d261f5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 200 additions and 24 deletions

View file

@ -111,12 +111,12 @@ describe('encodeHeaderValue', () => {
describe('resolveHeaders', () => {
beforeEach(() => {
process.env.TEST_API_KEY = 'test-api-key-value';
process.env.ANOTHER_SECRET = 'another-secret-value';
process.env.ANOTHER_VALUE = 'another-test-value';
});
afterEach(() => {
delete process.env.TEST_API_KEY;
delete process.env.ANOTHER_SECRET;
delete process.env.ANOTHER_VALUE;
});
it('should return empty object when headers is undefined', () => {
@ -139,7 +139,7 @@ describe('resolveHeaders', () => {
it('should process environment variables in headers', () => {
const headers = {
Authorization: '${TEST_API_KEY}',
'X-Secret': '${ANOTHER_SECRET}',
'X-Secret': '${ANOTHER_VALUE}',
'Content-Type': 'application/json',
};
@ -147,7 +147,7 @@ describe('resolveHeaders', () => {
expect(result).toEqual({
Authorization: 'test-api-key-value',
'X-Secret': 'another-secret-value',
'X-Secret': 'another-test-value',
'Content-Type': 'application/json',
});
});
@ -526,6 +526,40 @@ describe('resolveHeaders', () => {
expect(result['X-Conversation']).toBe('conv-123');
});
it('should not resolve env vars introduced via LIBRECHAT_BODY placeholders', () => {
const body = {
conversationId: '${TEST_API_KEY}',
parentMessageId: '${TEST_API_KEY}',
messageId: '${TEST_API_KEY}',
};
const headers = {
'X-Conv': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
'X-Parent': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}',
'X-Msg': '{{LIBRECHAT_BODY_MESSAGEID}}',
};
const result = resolveHeaders({ headers, body });
expect(result['X-Conv']).toBe('${TEST_API_KEY}');
expect(result['X-Parent']).toBe('${TEST_API_KEY}');
expect(result['X-Msg']).toBe('${TEST_API_KEY}');
});
it('should not resolve env vars introduced via LIBRECHAT_USER placeholders', () => {
const user = createTestUser({ name: '${TEST_API_KEY}' });
const headers = { 'X-Name': '{{LIBRECHAT_USER_NAME}}' };
const result = resolveHeaders({ headers, user });
expect(result['X-Name']).toBe('${TEST_API_KEY}');
});
it('should not resolve env vars introduced via customUserVars', () => {
const customUserVars = { MY_TOKEN: '${TEST_API_KEY}' };
const headers = { Authorization: 'Bearer {{MY_TOKEN}}' };
const result = resolveHeaders({ headers, customUserVars });
expect(result.Authorization).toBe('Bearer ${TEST_API_KEY}');
});
describe('non-string header values (type guard tests)', () => {
it('should handle numeric header values without crashing', () => {
const headers = {
@ -657,12 +691,12 @@ describe('resolveHeaders', () => {
describe('resolveNestedObject', () => {
beforeEach(() => {
process.env.TEST_API_KEY = 'test-api-key-value';
process.env.ANOTHER_SECRET = 'another-secret-value';
process.env.ANOTHER_VALUE = 'another-test-value';
});
afterEach(() => {
delete process.env.TEST_API_KEY;
delete process.env.ANOTHER_SECRET;
delete process.env.ANOTHER_VALUE;
});
it('should preserve nested object structure', () => {
@ -952,7 +986,7 @@ describe('resolveNestedObject', () => {
describe('processMCPEnv', () => {
beforeEach(() => {
process.env.TEST_API_KEY = 'test-api-key-value';
process.env.ANOTHER_SECRET = 'another-secret-value';
process.env.ANOTHER_VALUE = 'another-test-value';
process.env.OAUTH_CLIENT_ID = 'oauth-client-id-value';
process.env.OAUTH_CLIENT_SECRET = 'oauth-client-secret-value';
process.env.MCP_SERVER_URL = 'https://mcp.example.com';
@ -960,7 +994,7 @@ describe('processMCPEnv', () => {
afterEach(() => {
delete process.env.TEST_API_KEY;
delete process.env.ANOTHER_SECRET;
delete process.env.ANOTHER_VALUE;
delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_CLIENT_SECRET;
delete process.env.MCP_SERVER_URL;
@ -977,7 +1011,7 @@ describe('processMCPEnv', () => {
command: 'mcp-server',
env: {
API_KEY: '${TEST_API_KEY}',
SECRET: '${ANOTHER_SECRET}',
SECRET: '${ANOTHER_VALUE}',
PLAIN_VALUE: 'plain-text',
},
args: ['--key', '${TEST_API_KEY}', '--url', '${MCP_SERVER_URL}'],
@ -990,7 +1024,7 @@ describe('processMCPEnv', () => {
command: 'mcp-server',
env: {
API_KEY: 'test-api-key-value',
SECRET: 'another-secret-value',
SECRET: 'another-test-value',
PLAIN_VALUE: 'plain-text',
},
args: ['--key', 'test-api-key-value', '--url', 'https://mcp.example.com'],
@ -1137,6 +1171,49 @@ describe('processMCPEnv', () => {
});
});
it('should not resolve env vars introduced via body placeholders in MCP headers', () => {
const body = {
conversationId: '${TEST_API_KEY}',
parentMessageId: '${TEST_API_KEY}',
messageId: '${TEST_API_KEY}',
};
const options: MCPOptions = {
type: 'streamable-http',
url: 'https://api.example.com',
headers: {
'X-Conv': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
'X-Parent': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}',
},
};
const result = processMCPEnv({ options, body });
if (!isStreamableHTTPOptions(result)) {
throw new Error('Expected streamable-http options');
}
expect(result.headers?.['X-Conv']).toBe('${TEST_API_KEY}');
expect(result.headers?.['X-Parent']).toBe('${TEST_API_KEY}');
});
it('should not resolve env vars introduced via customUserVars in MCP headers', () => {
const customUserVars = { MY_TOKEN: '${TEST_API_KEY}' };
const options: MCPOptions = {
type: 'streamable-http',
url: 'https://api.example.com',
headers: {
Authorization: 'Bearer {{MY_TOKEN}}',
},
};
const result = processMCPEnv({ options, customUserVars });
if (!isStreamableHTTPOptions(result)) {
throw new Error('Expected streamable-http options');
}
expect(result.headers?.Authorization).toBe('Bearer ${TEST_API_KEY}');
});
it('should handle mixed placeholders in OAuth configuration', () => {
const user = createTestUser({
id: 'user-123',

View file

@ -226,9 +226,20 @@ function processSingleValue({
let value = originalValue;
/**
* SECURITY INVARIANT ordering matters:
* Resolve env vars on the admin-authored template BEFORE any user-controlled
* data is substituted (customUserVars, user fields, OIDC tokens, body placeholders).
* This prevents second-order injection where user values containing ${VAR}
* patterns would otherwise be expanded against process.env.
*/
if (!dbSourced) {
value = extractEnvVariable(value);
}
/** Runs for both dbSourced and non-dbSourced — it is the only resolution DB-stored servers get */
if (customUserVars) {
for (const [varName, varVal] of Object.entries(customUserVars)) {
/** Escaped varName for use in regex to avoid issues with special characters */
const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const placeholderRegex = new RegExp(`\\{\\{${escapedVarName}\\}\\}`, 'g');
value = value.replace(placeholderRegex, varVal);
@ -250,8 +261,6 @@ function processSingleValue({
value = processBodyPlaceholders(value, body);
}
value = extractEnvVariable(value);
return value;
}