Files
ruvnet__ruflo/v3/@claude-flow/cli/__tests__/validate-input-env.test.ts
Reuven bbe53a21cd test(security): regression tests for validateEnv + fs-secure (audit_1776853149979)
Pin the recent audit fixes so they can't silently regress.

__tests__/validate-input-env.test.ts (19 cases):
  - happy path: undefined/null/empty/valid env passes through
  - loader-hijack denylist: each of the 9 denylisted names (LD_PRELOAD,
    LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH,
    DYLD_FALLBACK_LIBRARY_PATH, DYLD_FORCE_FLAT_NAMESPACE, NODE_OPTIONS,
    NODE_PATH) gets its own assertion — adding an exemption to that list
    becomes a real CVE that future me/anyone needs to consciously delete
    a test for
  - shape: name with =, digit-first name, whitespace, non-object value,
    non-string value, null byte in value, value over 32K — all rejected

__tests__/fs-secure.test.ts (8 cases):
  - writeFileRestricted: writes string + Buffer correctly, sets mode
    0600, preserves mode on overwrite
  - mkdirRestricted: creates new + nested dirs, idempotent on existing,
    sets mode 0700
  - mode-bit assertions skipped on Windows (process.platform === 'win32')
    because chmod is a no-op there and POSIX permission bits don't apply

All 27 new tests pass. Sibling validate-input-agent-spawn.test.ts also
re-run as a regression check on the shared source file — still 4/4 passing.
2026-05-03 20:30:08 -04:00

103 lines
3.4 KiB
TypeScript

/**
* Regression test for audit_1776853149979: terminal_create previously merged
* caller-supplied env straight into execSync's environment for every command
* in the session. Setting LD_PRELOAD or NODE_OPTIONS via that path was
* functionally RCE on the host. validateEnv enforces a denylist + POSIX
* shape — these tests pin the contract so the denylist can't silently
* regress.
*/
import { describe, it, expect } from 'vitest';
import { validateEnv } from '../src/mcp-tools/validate-input.js';
describe('validateEnv (audit_1776853149979)', () => {
describe('happy path', () => {
it('accepts undefined / null as empty env', () => {
expect(validateEnv(undefined)).toEqual({ valid: true, sanitized: {} });
expect(validateEnv(null)).toEqual({ valid: true, sanitized: {} });
});
it('accepts an empty object', () => {
expect(validateEnv({})).toEqual({ valid: true, sanitized: {} });
});
it('passes through a valid env unchanged', () => {
const r = validateEnv({ FOO: 'bar', MY_VAR: '1', _UNDERSCORE: 'ok' });
expect(r.valid).toBe(true);
expect(r.sanitized).toEqual({ FOO: 'bar', MY_VAR: '1', _UNDERSCORE: 'ok' });
});
});
describe('loader-hijack denylist', () => {
// Names that let an attacker hijack a child process before any user code
// runs. Each MUST be rejected — adding an exemption is a real CVE.
const denylisted = [
'LD_PRELOAD',
'LD_LIBRARY_PATH',
'LD_AUDIT',
'DYLD_INSERT_LIBRARIES',
'DYLD_LIBRARY_PATH',
'DYLD_FALLBACK_LIBRARY_PATH',
'DYLD_FORCE_FLAT_NAMESPACE',
'NODE_OPTIONS',
'NODE_PATH',
];
for (const name of denylisted) {
it(`rejects ${name}`, () => {
const r = validateEnv({ [name]: '/tmp/whatever' });
expect(r.valid).toBe(false);
expect(r.error).toContain(name);
expect(r.error).toContain('denylisted');
expect(r.sanitized).toEqual({});
});
}
});
describe('shape validation', () => {
it('rejects a name containing =', () => {
const r = validateEnv({ 'A=B': 'x' });
expect(r.valid).toBe(false);
expect(r.error).toContain('not a valid POSIX env name');
});
it('rejects a digit-first name', () => {
const r = validateEnv({ '1FOO': 'x' });
expect(r.valid).toBe(false);
expect(r.error).toContain('not a valid POSIX env name');
});
it('rejects a name with whitespace', () => {
const r = validateEnv({ 'BAD NAME': 'x' });
expect(r.valid).toBe(false);
expect(r.error).toContain('not a valid POSIX env name');
});
it('rejects an array (not an object)', () => {
const r = validateEnv(['FOO', 'BAR'] as unknown);
expect(r.valid).toBe(false);
expect(r.error).toContain('object of string→string');
});
it('rejects a non-string value', () => {
const r = validateEnv({ FOO: 42 });
expect(r.valid).toBe(false);
expect(r.error).toContain('FOO');
expect(r.error).toContain('must be a string');
});
it('rejects a value with a null byte', () => {
const value = ['a', String.fromCharCode(0), 'b'].join('');
const r = validateEnv({ FOO: value });
expect(r.valid).toBe(false);
expect(r.error).toContain('null byte');
});
it('rejects a value over 32K', () => {
const r = validateEnv({ FOO: 'x'.repeat(40_000) });
expect(r.valid).toBe(false);
expect(r.error).toContain('exceeds 32768');
});
});
});