This commit adds comprehensive support for JSON configuration files in Docker containers, addressing the issue where the Docker image fails to start in server mode and ignores configuration files. ## Changes ### Docker Configuration Support - Added parse-config.js to safely parse JSON configs and export as shell variables - Implemented secure shell quoting to prevent command injection - Added dangerous environment variable blocking for security - Support for all JSON data types with proper edge case handling ### Docker Server Mode Fix - Added support for "n8n-mcp serve" command in entrypoint - Properly transforms serve command to HTTP mode - Fixed missing n8n-mcp binary issue in Docker image ### Security Enhancements - POSIX-compliant shell quoting without eval - Blocked dangerous variables (PATH, LD_PRELOAD, etc.) - Sanitized configuration keys to prevent invalid shell variables - Protection against shell metacharacters in values ### Testing - Added 53 comprehensive tests for Docker configuration - Unit tests for parsing, security, and edge cases - Integration tests for Docker entrypoint behavior - Security-focused tests for injection prevention ### Documentation - Updated Docker README with config file mounting examples - Enhanced troubleshooting guide with config file issues - Added version bump to 2.8.2 ### Additional Files - Included deployment-engineer and technical-researcher agent files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
|
|
describe('Config File Security Tests', () => {
|
|
let tempDir: string;
|
|
let configPath: string;
|
|
const parseConfigPath = path.resolve(__dirname, '../../../docker/parse-config.js');
|
|
|
|
// Clean environment for tests - only include essential variables
|
|
const cleanEnv = {
|
|
PATH: process.env.PATH,
|
|
HOME: process.env.HOME,
|
|
NODE_ENV: process.env.NODE_ENV
|
|
};
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-security-test-'));
|
|
configPath = path.join(tempDir, 'config.json');
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
describe('Command injection prevention', () => {
|
|
it('should prevent basic command injection attempts', () => {
|
|
const maliciousConfigs = [
|
|
{ cmd: "'; echo 'hacked' > /tmp/hacked.txt; '" },
|
|
{ cmd: '"; echo "hacked" > /tmp/hacked.txt; "' },
|
|
{ cmd: '`echo hacked > /tmp/hacked.txt`' },
|
|
{ cmd: '$(echo hacked > /tmp/hacked.txt)' },
|
|
{ cmd: '| echo hacked > /tmp/hacked.txt' },
|
|
{ cmd: '|| echo hacked > /tmp/hacked.txt' },
|
|
{ cmd: '& echo hacked > /tmp/hacked.txt' },
|
|
{ cmd: '&& echo hacked > /tmp/hacked.txt' },
|
|
{ cmd: '; echo hacked > /tmp/hacked.txt' },
|
|
{ cmd: '\n echo hacked > /tmp/hacked.txt \n' },
|
|
{ cmd: '\r\n echo hacked > /tmp/hacked.txt \r\n' }
|
|
];
|
|
|
|
maliciousConfigs.forEach((config, index) => {
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// The output should safely quote the malicious content
|
|
expect(output).toContain("export CMD='");
|
|
|
|
// Verify that the output contains a properly quoted export
|
|
expect(output).toContain("export CMD='");
|
|
|
|
// Create a test script to verify safety
|
|
const testScript = `#!/bin/sh
|
|
set -e
|
|
${output}
|
|
# If command injection worked, this would fail
|
|
test -f /tmp/hacked.txt && exit 1
|
|
echo "SUCCESS: No injection occurred"
|
|
`;
|
|
|
|
const tempScript = path.join(tempDir, `test-injection-${index}.sh`);
|
|
fs.writeFileSync(tempScript, testScript);
|
|
fs.chmodSync(tempScript, '755');
|
|
|
|
const result = execSync(tempScript, { encoding: 'utf8', env: cleanEnv });
|
|
expect(result.trim()).toBe('SUCCESS: No injection occurred');
|
|
|
|
// Double-check no files were created
|
|
expect(fs.existsSync('/tmp/hacked.txt')).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('should handle complex nested injection attempts', () => {
|
|
const config = {
|
|
database: {
|
|
host: "localhost'; DROP TABLE users; --",
|
|
port: 5432,
|
|
credentials: {
|
|
password: "$( cat /etc/passwd )",
|
|
backup_cmd: "`rm -rf /`"
|
|
}
|
|
},
|
|
scripts: {
|
|
init: "#!/bin/bash\nrm -rf /\nexit 0"
|
|
}
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// All values should be safely quoted
|
|
expect(output).toContain("DATABASE_HOST='localhost'\"'\"'; DROP TABLE users; --'");
|
|
expect(output).toContain("DATABASE_CREDENTIALS_PASSWORD='$( cat /etc/passwd )'");
|
|
expect(output).toContain("DATABASE_CREDENTIALS_BACKUP_CMD='`rm -rf /`'");
|
|
expect(output).toContain("SCRIPTS_INIT='#!/bin/bash\nrm -rf /\nexit 0'");
|
|
});
|
|
|
|
it('should handle Unicode and special characters safely', () => {
|
|
const config = {
|
|
unicode: "Hello 世界 🌍",
|
|
emoji: "🚀 Deploy! 🎉",
|
|
special: "Line1\nLine2\tTab\rCarriage",
|
|
quotes_mix: `It's a "test" with 'various' quotes`,
|
|
backslash: "C:\\Users\\test\\path",
|
|
regex: "^[a-zA-Z0-9]+$",
|
|
json_string: '{"key": "value"}',
|
|
xml_string: '<tag attr="value">content</tag>',
|
|
sql_injection: "1' OR '1'='1",
|
|
null_byte: "test\x00null",
|
|
escape_sequences: "test\\n\\r\\t\\b\\f"
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// All special characters should be preserved within quotes
|
|
expect(output).toContain("UNICODE='Hello 世界 🌍'");
|
|
expect(output).toContain("EMOJI='🚀 Deploy! 🎉'");
|
|
expect(output).toContain("SPECIAL='Line1\nLine2\tTab\rCarriage'");
|
|
expect(output).toContain("BACKSLASH='C:\\Users\\test\\path'");
|
|
expect(output).toContain("REGEX='^[a-zA-Z0-9]+$'");
|
|
expect(output).toContain("SQL_INJECTION='1'\"'\"' OR '\"'\"'1'\"'\"'='\"'\"'1'");
|
|
});
|
|
});
|
|
|
|
describe('Shell metacharacter handling', () => {
|
|
it('should safely handle all shell metacharacters', () => {
|
|
const config = {
|
|
dollar: "$HOME $USER ${PATH}",
|
|
backtick: "`date` `whoami`",
|
|
parentheses: "$(date) $(whoami)",
|
|
semicolon: "cmd1; cmd2; cmd3",
|
|
ampersand: "cmd1 & cmd2 && cmd3",
|
|
pipe: "cmd1 | cmd2 || cmd3",
|
|
redirect: "cmd > file < input >> append",
|
|
glob: "*.txt ?.log [a-z]*",
|
|
tilde: "~/home ~/.config",
|
|
exclamation: "!history !!",
|
|
question: "file? test?",
|
|
asterisk: "*.* *",
|
|
brackets: "[abc] [0-9]",
|
|
braces: "{a,b,c} ${var}",
|
|
caret: "^pattern^replacement^",
|
|
hash: "#comment # another",
|
|
at: "@variable @{array}"
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// Verify all metacharacters are safely quoted
|
|
const lines = output.trim().split('\n');
|
|
lines.forEach(line => {
|
|
// Each line should be in the format: export KEY='value'
|
|
expect(line).toMatch(/^export [A-Z_]+='.*'$/);
|
|
});
|
|
|
|
// Test that the values are safe when evaluated
|
|
const testScript = `
|
|
#!/bin/sh
|
|
set -e
|
|
${output}
|
|
# If any metacharacters were unescaped, these would fail
|
|
test "\$DOLLAR" = '\$HOME \$USER \${PATH}'
|
|
test "\$BACKTICK" = '\`date\` \`whoami\`'
|
|
test "\$PARENTHESES" = '\$(date) \$(whoami)'
|
|
test "\$SEMICOLON" = 'cmd1; cmd2; cmd3'
|
|
test "\$PIPE" = 'cmd1 | cmd2 || cmd3'
|
|
echo "SUCCESS: All metacharacters safely contained"
|
|
`;
|
|
|
|
const tempScript = path.join(tempDir, 'test-metachar.sh');
|
|
fs.writeFileSync(tempScript, testScript);
|
|
fs.chmodSync(tempScript, '755');
|
|
|
|
const result = execSync(tempScript, { encoding: 'utf8', env: cleanEnv });
|
|
expect(result.trim()).toBe('SUCCESS: All metacharacters safely contained');
|
|
});
|
|
});
|
|
|
|
describe('Escaping edge cases', () => {
|
|
it('should handle consecutive single quotes', () => {
|
|
const config = {
|
|
test1: "'''",
|
|
test2: "It'''s",
|
|
test3: "start'''middle'''end",
|
|
test4: "''''''''",
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// Verify the escaping is correct
|
|
expect(output).toContain(`TEST1=''"'"''"'"''"'"'`);
|
|
expect(output).toContain(`TEST2='It'"'"''"'"''"'"'s'`);
|
|
});
|
|
|
|
it('should handle empty and whitespace-only values', () => {
|
|
const config = {
|
|
empty: "",
|
|
space: " ",
|
|
spaces: " ",
|
|
tab: "\t",
|
|
newline: "\n",
|
|
mixed_whitespace: " \t\n\r "
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
expect(output).toContain("EMPTY=''");
|
|
expect(output).toContain("SPACE=' '");
|
|
expect(output).toContain("SPACES=' '");
|
|
expect(output).toContain("TAB='\t'");
|
|
expect(output).toContain("NEWLINE='\n'");
|
|
expect(output).toContain("MIXED_WHITESPACE=' \t\n\r '");
|
|
});
|
|
|
|
it('should handle very long values', () => {
|
|
const longString = 'a'.repeat(10000) + "'; echo 'injection'; '" + 'b'.repeat(10000);
|
|
const config = {
|
|
long_value: longString
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
expect(output).toContain('LONG_VALUE=');
|
|
expect(output.length).toBeGreaterThan(20000);
|
|
// The injection attempt should be safely quoted
|
|
expect(output).toContain("'\"'\"'; echo '\"'\"'injection'\"'\"'; '\"'\"'");
|
|
});
|
|
});
|
|
|
|
describe('Environment variable name security', () => {
|
|
it('should handle potentially dangerous key names', () => {
|
|
const config = {
|
|
"PATH": "should-not-override",
|
|
"LD_PRELOAD": "dangerous",
|
|
"valid_key": "safe_value",
|
|
"123invalid": "should-be-skipped",
|
|
"key-with-dash": "should-work",
|
|
"key.with.dots": "should-work",
|
|
"KEY WITH SPACES": "should-work"
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// Dangerous variables should be blocked
|
|
expect(output).not.toContain("export PATH=");
|
|
expect(output).not.toContain("export LD_PRELOAD=");
|
|
|
|
// Valid keys should be converted to safe names
|
|
expect(output).toContain("export VALID_KEY='safe_value'");
|
|
expect(output).toContain("export KEY_WITH_DASH='should-work'");
|
|
expect(output).toContain("export KEY_WITH_DOTS='should-work'");
|
|
expect(output).toContain("export KEY_WITH_SPACES='should-work'");
|
|
|
|
// Invalid starting with number should be prefixed with _
|
|
expect(output).toContain("export _123INVALID='should-be-skipped'");
|
|
});
|
|
});
|
|
|
|
describe('Real-world attack scenarios', () => {
|
|
it('should prevent path traversal attempts', () => {
|
|
const config = {
|
|
file_path: "../../../etc/passwd",
|
|
backup_location: "../../../../../../tmp/evil",
|
|
template: "${../../secret.key}",
|
|
include: "<?php include('/etc/passwd'); ?>"
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// Path traversal attempts should be preserved as strings, not resolved
|
|
expect(output).toContain("FILE_PATH='../../../etc/passwd'");
|
|
expect(output).toContain("BACKUP_LOCATION='../../../../../../tmp/evil'");
|
|
expect(output).toContain("TEMPLATE='${../../secret.key}'");
|
|
expect(output).toContain("INCLUDE='<?php include('\"'\"'/etc/passwd'\"'\"'); ?>'");
|
|
});
|
|
|
|
it('should handle polyglot payloads safely', () => {
|
|
const config = {
|
|
// JavaScript/Shell polyglot
|
|
polyglot1: "';alert(String.fromCharCode(88,83,83))//';alert(String.fromCharCode(88,83,83))//\";alert(String.fromCharCode(88,83,83))//\";alert(String.fromCharCode(88,83,83))//--></SCRIPT>\">'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>",
|
|
// SQL/Shell polyglot
|
|
polyglot2: "1' OR '1'='1' /*' or 1=1 # ' or 1=1-- ' or 1=1;--",
|
|
// XML/Shell polyglot
|
|
polyglot3: "<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><foo>&xxe;</foo>"
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// All polyglot payloads should be safely quoted
|
|
const lines = output.trim().split('\n');
|
|
lines.forEach(line => {
|
|
if (line.startsWith('export POLYGLOT')) {
|
|
// Should be safely wrapped in single quotes with proper escaping
|
|
expect(line).toMatch(/^export POLYGLOT[0-9]='.*'$/);
|
|
// The dangerous content is there but safely quoted
|
|
// What matters is that when evaluated, it's just a string
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Stress testing', () => {
|
|
it('should handle deeply nested malicious structures', () => {
|
|
const createNestedMalicious = (depth: number): any => {
|
|
if (depth === 0) {
|
|
return "'; rm -rf /; '";
|
|
}
|
|
return {
|
|
[`level${depth}`]: createNestedMalicious(depth - 1),
|
|
[`inject${depth}`]: "$( echo 'level " + depth + "' )"
|
|
};
|
|
};
|
|
|
|
const config = createNestedMalicious(10);
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// Should handle deep nesting without issues
|
|
expect(output).toContain("LEVEL10_LEVEL9_LEVEL8");
|
|
expect(output).toContain("'\"'\"'; rm -rf /; '\"'\"'");
|
|
|
|
// All injection attempts should be quoted
|
|
const lines = output.trim().split('\n');
|
|
lines.forEach(line => {
|
|
if (line.includes('INJECT')) {
|
|
expect(line).toContain("$( echo '\"'\"'level");
|
|
}
|
|
});
|
|
});
|
|
|
|
it('should handle mixed attack vectors in single config', () => {
|
|
const config = {
|
|
normal_value: "This is safe",
|
|
sql_injection: "1' OR '1'='1",
|
|
cmd_injection: "; cat /etc/passwd",
|
|
xxe_attempt: '<!ENTITY xxe SYSTEM "file:///etc/passwd">',
|
|
code_injection: "${constructor.constructor('return process')().exit()}",
|
|
format_string: "%s%s%s%s%s%s%s%s%s%s",
|
|
buffer_overflow: "A".repeat(10000),
|
|
null_injection: "test\x00admin",
|
|
ldap_injection: "*)(&(1=1",
|
|
xpath_injection: "' or '1'='1",
|
|
template_injection: "{{7*7}}",
|
|
ssti: "${7*7}",
|
|
crlf_injection: "test\r\nSet-Cookie: admin=true",
|
|
host_header: "evil.com\r\nX-Forwarded-Host: evil.com",
|
|
cache_poisoning: "index.html%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK"
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const output = execSync(`node "${parseConfigPath}" "${configPath}"`, {
|
|
encoding: 'utf8',
|
|
env: cleanEnv
|
|
});
|
|
|
|
// Verify each attack vector is safely handled
|
|
expect(output).toContain("NORMAL_VALUE='This is safe'");
|
|
expect(output).toContain("SQL_INJECTION='1'\"'\"' OR '\"'\"'1'\"'\"'='\"'\"'1'");
|
|
expect(output).toContain("CMD_INJECTION='; cat /etc/passwd'");
|
|
expect(output).toContain("XXE_ATTEMPT='<!ENTITY xxe SYSTEM \"file:///etc/passwd\">'");
|
|
expect(output).toContain("CODE_INJECTION='${constructor.constructor('\"'\"'return process'\"'\"')().exit()}'");
|
|
|
|
// Verify no actual code execution occurs
|
|
const evalTest = `${output}\necho "Test completed successfully"`;
|
|
const result = execSync(evalTest, { shell: '/bin/sh', encoding: 'utf8' });
|
|
expect(result).toContain("Test completed successfully");
|
|
});
|
|
});
|
|
}); |