fix(env): use single quotes to prevent shell injection via eval

Switch formatExportLine from double quotes to single quotes to prevent
shell metacharacter expansion ($(), backticks, etc.) when output is
consumed via eval. Also fix parseFlag to handle values containing =,
remove unused test imports, and add empty-output guard after format
transformation.
This commit is contained in:
Tam Nhu Tran
2026-02-11 06:26:52 +07:00
parent 2e85064b8a
commit a5dc15d174
2 changed files with 31 additions and 17 deletions
+16 -7
View File
@@ -30,17 +30,18 @@ export function detectShell(flag?: string): ShellType {
return 'bash'; return 'bash';
} }
/** Format a single env var export for the target shell */ /** Format a single env var export for the target shell (single-quoted to prevent injection) */
export function formatExportLine(shell: ShellType, key: string, value: string): string { export function formatExportLine(shell: ShellType, key: string, value: string): string {
// Escape double quotes in value
const escaped = value.replace(/"/g, '\\"');
switch (shell) { switch (shell) {
case 'fish': case 'fish':
return `set -gx ${key} "${escaped}"`; // Fish: single quotes prevent expansion; escape embedded single quotes with \'
return `set -gx ${key} '${value.replace(/'/g, "\\'")}'`;
case 'powershell': case 'powershell':
return `$env:${key} = "${escaped}"`; // PowerShell: single quotes prevent expansion; escape embedded single quotes with ''
return `$env:${key} = '${value.replace(/'/g, "''")}'`;
default: default:
return `export ${key}="${escaped}"`; // Bash/zsh: single quotes prevent all expansion; handle embedded single quotes
return `export ${key}='${value.replace(/'/g, "'\\''")}'`;
} }
} }
@@ -59,7 +60,7 @@ export function transformToOpenAI(envVars: Record<string, string>): Record<strin
function parseFlag(args: string[], flag: string): string | undefined { function parseFlag(args: string[], flag: string): string | undefined {
// --flag=value style // --flag=value style
const eqMatch = args.find((a) => a.startsWith(`--${flag}=`)); const eqMatch = args.find((a) => a.startsWith(`--${flag}=`));
if (eqMatch) return eqMatch.split('=')[1]; if (eqMatch) return eqMatch.split('=').slice(1).join('=');
// --flag value style // --flag value style
const idx = args.indexOf(`--${flag}`); const idx = args.indexOf(`--${flag}`);
if (idx >= 0 && idx + 1 < args.length && !args[idx + 1].startsWith('-')) { if (idx >= 0 && idx + 1 < args.length && !args[idx + 1].startsWith('-')) {
@@ -220,6 +221,14 @@ export async function handleEnvCommand(args: string[]): Promise<void> {
break; break;
} }
// Guard: format transformation may filter out all vars
if (Object.keys(output).filter((k) => output[k]).length === 0) {
console.error(
warn(`No ${format}-format vars found for profile '${profile}'. Try --format raw`)
);
process.exit(1);
}
// Output shell-formatted exports to stdout // Output shell-formatted exports to stdout
for (const [key, value] of Object.entries(output)) { for (const [key, value] of Object.entries(output)) {
if (value) { if (value) {
+15 -10
View File
@@ -3,13 +3,12 @@
* *
* Tests pure utility functions: detectShell, formatExportLine, transformToOpenAI * Tests pure utility functions: detectShell, formatExportLine, transformToOpenAI
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { describe, it, expect, afterEach } from 'bun:test';
import { detectShell, formatExportLine, transformToOpenAI } from '../../../src/commands/env-command'; import { detectShell, formatExportLine, transformToOpenAI } from '../../../src/commands/env-command';
describe('env-command', () => { describe('env-command', () => {
describe('detectShell', () => { describe('detectShell', () => {
const originalShell = process.env['SHELL']; const originalShell = process.env['SHELL'];
const originalPlatform = process.platform;
afterEach(() => { afterEach(() => {
if (originalShell !== undefined) { if (originalShell !== undefined) {
@@ -59,32 +58,38 @@ describe('env-command', () => {
describe('formatExportLine', () => { describe('formatExportLine', () => {
it('formats bash export', () => { it('formats bash export', () => {
expect(formatExportLine('bash', 'API_KEY', 'sk-123')).toBe('export API_KEY="sk-123"'); expect(formatExportLine('bash', 'API_KEY', 'sk-123')).toBe("export API_KEY='sk-123'");
}); });
it('formats fish export', () => { it('formats fish export', () => {
expect(formatExportLine('fish', 'API_KEY', 'sk-123')).toBe('set -gx API_KEY "sk-123"'); expect(formatExportLine('fish', 'API_KEY', 'sk-123')).toBe("set -gx API_KEY 'sk-123'");
}); });
it('formats powershell export', () => { it('formats powershell export', () => {
expect(formatExportLine('powershell', 'API_KEY', 'sk-123')).toBe( expect(formatExportLine('powershell', 'API_KEY', 'sk-123')).toBe(
'$env:API_KEY = "sk-123"' "$env:API_KEY = 'sk-123'"
); );
}); });
it('escapes double quotes in values', () => { it('escapes single quotes in values', () => {
expect(formatExportLine('bash', 'VAL', 'has "quotes"')).toBe( expect(formatExportLine('bash', 'VAL', "it's here")).toBe(
'export VAL="has \\"quotes\\""' "export VAL='it'\\''s here'"
); );
}); });
it('handles empty values', () => { it('handles empty values', () => {
expect(formatExportLine('bash', 'EMPTY', '')).toBe('export EMPTY=""'); expect(formatExportLine('bash', 'EMPTY', '')).toBe("export EMPTY=''");
}); });
it('handles URLs with special characters', () => { it('handles URLs with special characters', () => {
const url = 'http://127.0.0.1:8317/api/provider/gemini'; const url = 'http://127.0.0.1:8317/api/provider/gemini';
expect(formatExportLine('bash', 'BASE_URL', url)).toBe(`export BASE_URL="${url}"`); expect(formatExportLine('bash', 'BASE_URL', url)).toBe(`export BASE_URL='${url}'`);
});
it('prevents shell injection with $() in values', () => {
expect(formatExportLine('bash', 'TOKEN', 'safe$(whoami)')).toBe(
"export TOKEN='safe$(whoami)'"
);
}); });
}); });