fix: add Docker configuration file support (fixes #105)

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>
This commit is contained in:
czlonkowski
2025-07-31 11:48:31 +02:00
parent dce2d9d83b
commit 903a49d3b0
20 changed files with 3130 additions and 3 deletions

View File

@@ -0,0 +1,393 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { execSync, spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { promisify } from 'util';
const exec = promisify(require('child_process').exec);
// Skip tests if not in CI or if Docker is not available
const SKIP_DOCKER_TESTS = process.env.CI !== 'true' && !process.env.RUN_DOCKER_TESTS;
const describeDocker = SKIP_DOCKER_TESTS ? describe.skip : describe;
// Helper to check if Docker is available
async function isDockerAvailable(): Promise<boolean> {
try {
await exec('docker --version');
return true;
} catch {
return false;
}
}
// Helper to generate unique container names
function generateContainerName(suffix: string): string {
return `n8n-mcp-test-${Date.now()}-${suffix}`;
}
// Helper to clean up containers
async function cleanupContainer(containerName: string) {
try {
await exec(`docker stop ${containerName}`);
await exec(`docker rm ${containerName}`);
} catch {
// Ignore errors - container might not exist
}
}
describeDocker('Docker Config File Integration', () => {
let tempDir: string;
let dockerAvailable: boolean;
const imageName = 'n8n-mcp-test:latest';
const containers: string[] = [];
beforeAll(async () => {
dockerAvailable = await isDockerAvailable();
if (!dockerAvailable) {
console.warn('Docker not available, skipping Docker integration tests');
return;
}
// Build test image
const projectRoot = path.resolve(__dirname, '../../../');
console.log('Building Docker image for tests...');
execSync(`docker build -t ${imageName} .`, {
cwd: projectRoot,
stdio: 'inherit'
});
});
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-config-test-'));
});
afterEach(async () => {
// Clean up containers
for (const container of containers) {
await cleanupContainer(container);
}
containers.length = 0;
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
});
describe('Config file loading', () => {
it('should load config.json and set environment variables', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('config-load');
containers.push(containerName);
// Create config file
const configPath = path.join(tempDir, 'config.json');
const config = {
mcp_mode: 'http',
auth_token: 'test-token-from-config',
port: 3456,
database: {
path: '/data/custom.db'
}
};
fs.writeFileSync(configPath, JSON.stringify(config));
// Run container with config file mounted
const { stdout } = await exec(
`docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep -E '^(MCP_MODE|AUTH_TOKEN|PORT|DATABASE_PATH)=' | sort"`
);
const envVars = stdout.trim().split('\n').reduce((acc, line) => {
const [key, value] = line.split('=');
acc[key] = value;
return acc;
}, {} as Record<string, string>);
expect(envVars.MCP_MODE).toBe('http');
expect(envVars.AUTH_TOKEN).toBe('test-token-from-config');
expect(envVars.PORT).toBe('3456');
expect(envVars.DATABASE_PATH).toBe('/data/custom.db');
});
it('should give precedence to environment variables over config file', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('env-precedence');
containers.push(containerName);
// Create config file
const configPath = path.join(tempDir, 'config.json');
const config = {
mcp_mode: 'stdio',
auth_token: 'config-token',
custom_var: 'from-config'
};
fs.writeFileSync(configPath, JSON.stringify(config));
// Run container with both env vars and config file
const { stdout } = await exec(
`docker run --name ${containerName} ` +
`-e MCP_MODE=http ` +
`-e AUTH_TOKEN=env-token ` +
`-v "${configPath}:/app/config.json:ro" ` +
`${imageName} sh -c "env | grep -E '^(MCP_MODE|AUTH_TOKEN|CUSTOM_VAR)=' | sort"`
);
const envVars = stdout.trim().split('\n').reduce((acc, line) => {
const [key, value] = line.split('=');
acc[key] = value;
return acc;
}, {} as Record<string, string>);
expect(envVars.MCP_MODE).toBe('http'); // From env var
expect(envVars.AUTH_TOKEN).toBe('env-token'); // From env var
expect(envVars.CUSTOM_VAR).toBe('from-config'); // From config file
});
it('should handle missing config file gracefully', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('no-config');
containers.push(containerName);
// Run container without config file
const { stdout, stderr } = await exec(
`docker run --name ${containerName} ${imageName} echo "Container started successfully"`
);
expect(stdout.trim()).toBe('Container started successfully');
expect(stderr).toBe('');
});
it('should handle invalid JSON in config file gracefully', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('invalid-json');
containers.push(containerName);
// Create invalid config file
const configPath = path.join(tempDir, 'config.json');
fs.writeFileSync(configPath, '{ invalid json }');
// Container should still start despite invalid config
const { stdout } = await exec(
`docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} echo "Started despite invalid config"`
);
expect(stdout.trim()).toBe('Started despite invalid config');
});
});
describe('n8n-mcp serve command', () => {
it('should automatically set MCP_MODE=http for "n8n-mcp serve" command', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('serve-command');
containers.push(containerName);
// Run container with n8n-mcp serve command
const { stdout } = await exec(
`docker run --name ${containerName} -e AUTH_TOKEN=test-token ${imageName} sh -c "export DEBUG_COMMAND=true; n8n-mcp serve & sleep 1; env | grep MCP_MODE"`
);
expect(stdout.trim()).toContain('MCP_MODE=http');
});
it('should preserve additional arguments when using "n8n-mcp serve"', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('serve-args');
containers.push(containerName);
// Test that additional arguments are passed through
// Note: This test is checking the command construction, not actual execution
const result = await exec(
`docker run --name ${containerName} ${imageName} sh -c "set -x; n8n-mcp serve --port 8080 2>&1 | grep -E 'node.*index.js.*--port.*8080' || echo 'Pattern not found'"`
);
// The serve command should transform to node command with arguments preserved
expect(result.stdout).toBeTruthy();
});
});
describe('Database initialization', () => {
it('should initialize database when not present', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('db-init');
containers.push(containerName);
// Run container and check database initialization
const { stdout } = await exec(
`docker run --name ${containerName} ${imageName} sh -c "ls -la /app/data/nodes.db && echo 'Database initialized'"`
);
expect(stdout).toContain('nodes.db');
expect(stdout).toContain('Database initialized');
});
it('should respect NODE_DB_PATH from config file', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('custom-db-path');
containers.push(containerName);
// Create config with custom database path
const configPath = path.join(tempDir, 'config.json');
const config = {
node_db_path: '/custom/path/custom.db'
};
fs.writeFileSync(configPath, JSON.stringify(config));
// Run container with custom database path
const { stdout, stderr } = await exec(
`docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "mkdir -p /custom/path && env | grep NODE_DB_PATH"`
);
expect(stdout.trim()).toBe('NODE_DB_PATH=/custom/path/custom.db');
});
});
describe('Authentication configuration', () => {
it('should enforce AUTH_TOKEN requirement in HTTP mode', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('auth-required');
containers.push(containerName);
// Try to run in HTTP mode without auth token
try {
await exec(
`docker run --name ${containerName} -e MCP_MODE=http ${imageName} echo "Should not reach here"`
);
expect.fail('Container should have exited with error');
} catch (error: any) {
expect(error.stderr).toContain('AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode');
}
});
it('should accept AUTH_TOKEN from config file', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('auth-config');
containers.push(containerName);
// Create config with auth token
const configPath = path.join(tempDir, 'config.json');
const config = {
mcp_mode: 'http',
auth_token: 'config-auth-token'
};
fs.writeFileSync(configPath, JSON.stringify(config));
// Run container with config file
const { stdout } = await exec(
`docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep AUTH_TOKEN"`
);
expect(stdout.trim()).toBe('AUTH_TOKEN=config-auth-token');
});
});
describe('Security and permissions', () => {
it('should handle malicious config values safely', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('security-test');
containers.push(containerName);
// Create config with potentially malicious values
const configPath = path.join(tempDir, 'config.json');
const config = {
malicious1: "'; echo 'hacked' > /tmp/hacked.txt; '",
malicious2: "$( touch /tmp/command-injection.txt )",
malicious3: "`touch /tmp/backtick-injection.txt`"
};
fs.writeFileSync(configPath, JSON.stringify(config));
// Run container and check that no files were created
const { stdout } = await exec(
`docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "ls -la /tmp/ | grep -E '(hacked|injection)' || echo 'No malicious files created'"`
);
expect(stdout.trim()).toBe('No malicious files created');
});
it('should run as non-root user by default', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('non-root');
containers.push(containerName);
// Check user inside container
const { stdout } = await exec(
`docker run --name ${containerName} ${imageName} whoami`
);
expect(stdout.trim()).toBe('nodejs');
});
});
describe('Complex configuration scenarios', () => {
it('should handle nested configuration with all supported types', async () => {
if (!dockerAvailable) return;
const containerName = generateContainerName('complex-config');
containers.push(containerName);
// Create complex config
const configPath = path.join(tempDir, 'config.json');
const config = {
server: {
http: {
port: 8080,
host: '0.0.0.0',
ssl: {
enabled: true,
cert_path: '/certs/server.crt'
}
}
},
features: {
debug: false,
metrics: true,
logging: {
level: 'info',
format: 'json'
}
},
limits: {
max_connections: 100,
timeout_seconds: 30
}
};
fs.writeFileSync(configPath, JSON.stringify(config));
// Run container and verify all variables
const { stdout } = await exec(
`docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep -E '^(SERVER_|FEATURES_|LIMITS_)' | sort"`
);
const lines = stdout.trim().split('\n');
const envVars = lines.reduce((acc, line) => {
const [key, value] = line.split('=');
acc[key] = value;
return acc;
}, {} as Record<string, string>);
// Verify nested values are correctly flattened
expect(envVars.SERVER_HTTP_PORT).toBe('8080');
expect(envVars.SERVER_HTTP_HOST).toBe('0.0.0.0');
expect(envVars.SERVER_HTTP_SSL_ENABLED).toBe('true');
expect(envVars.SERVER_HTTP_SSL_CERT_PATH).toBe('/certs/server.crt');
expect(envVars.FEATURES_DEBUG).toBe('false');
expect(envVars.FEATURES_METRICS).toBe('true');
expect(envVars.FEATURES_LOGGING_LEVEL).toBe('info');
expect(envVars.FEATURES_LOGGING_FORMAT).toBe('json');
expect(envVars.LIMITS_MAX_CONNECTIONS).toBe('100');
expect(envVars.LIMITS_TIMEOUT_SECONDS).toBe('30');
});
});
});