Root cause
Injection & unsafe execution introduced
feat(workflow-sdk): integrate Zod schema validation into validate()
How AI contributed: Direct introductionPublished Aug 5, 2026 in
n8n-io/n8nAll 6 findings in this repository
Path traversal reaches dynamic require. CNA metadata says 2.0.0, but Git containment first proves the unsafe loader carrier in [email protected] and the advisory-specific MCP source-to-sink in [email protected]; [email protected] and [email protected] are verified affected endpoints.
Root cause
feat(workflow-sdk): integrate Zod schema validation into validate()
Fix
Rejects separators, NULs, and dot path components in node types, then resolves each schema module's real path and loads it only when it remains inside the configured nodes directory.
Fix by n8n-assistant[bot] · no AI marker found
Case facts
@@ -0,0 +1,273 @@+/**+ * Schema Validator+ *+ * Dynamically loads and applies Zod schemas for node configuration validation.+ * Uses generated schemas from ~/.n8n/generated-types/ by default.+ */++import type { ZodSchema, ZodIssue } from 'zod';+import * as os from 'os';+import * as path from 'path';++// Default path to generated schemas+const DEFAULT_SCHEMA_BASE_PATH = path.join(os.homedir(), '.n8n', 'generated-types');+let schemaBasePath = DEFAULT_SCHEMA_BASE_PATH;++// Cache for loaded schemas+const schemaCache = new Map<string, ZodSchema | null>();++/**+ * Validation result from schema validation+ */+export interface SchemaValidationResult {+ valid: boolean;+ errors: Array<{+ path: string;+ message: string;+ }>;+}++/**+ * Get the current schema base path+ */+export function getSchemaBasePath(): string {+ return schemaBasePath;+}++/**+ * Set a custom schema base path (useful for testing)+ */+export function setSchemaBasePath(basePath: string): void {+ schemaBasePath = basePath;+ // Clear cache when base path changes+ schemaCache.clear();+}++/**+ * Convert node type to schema file path components+ *+ * Examples:+ * - "n8n-nodes-base.httpRequest" -> { pkg: "n8n-nodes-base", nodeName: "httpRequest" }+ * - "@n8n/n8n-nodes-langchain.agent" -> { pkg: "n8n-nodes-langchain", nodeName: "agent" }+ */+function nodeTypeToPathComponents(nodeType: string): { pkg: string; nodeName: string } {+ // Handle @n8n/ prefix for langchain nodes+ const normalized = nodeType.replace(/^@n8n\//, '');+ const [pkg, ...rest] = normalized.split('.');+ const nodeName = rest.join('.'); // Handle multi-part names (shouldn't happen, but be safe)+ return { pkg, nodeName };+}++/**+ * Convert version number to version string for file path+ *+ * Examples:+ * - 1 -> "v1"+ * - 4.2 -> "v42"+ * - 1.7 -> "v17"+ */+function versionToString(version: number): string {+ // Remove decimal point: 4.2 -> "42", 1.7 -> "17"+ const versionStr = String(version).replace('.', '');+ return `v${versionStr}`;+}++/**+ * Build the expected ConfigSchema export name for a node+ *+ * Schema naming conventions:+ * - n8n-nodes-base.set v2 -> SetV2ConfigSchema+ * - n8n-nodes-langchain.agent v1 -> LcAgentV1ConfigSchema (Lc prefix for langchain)+ * - n8n-nodes-base.httpRequest v4.2 -> HttpRequestV42ConfigSchema+ *+ * @param nodeName - Node name from node type (e.g., "set", "agent", "httpRequest")+ * @param versionStr - Version string (e.g., "v2", "v42")+ * @param isLangchain - Whether this is a langchain node+ * @returns Expected schema export name+ */+function buildExpectedSchemaName(+ nodeName: string,@@ -1,4 +1,5 @@ import type { WorkflowBuilder, WorkflowJSON } from '../types/base';+import { validateNodeConfig } from './schema-validator'; /** * Validation error codes@@ -18,7 +19,8 @@ export type ValidationErrorCode = | 'MERGE_SINGLE_INPUT' | 'TOOL_NO_PARAMETERS' | 'FROM_AI_IN_NON_TOOL'- | 'MISSING_EXPRESSION_PREFIX';+ | 'MISSING_EXPRESSION_PREFIX'+ | 'INVALID_PARAMETER'; /** * Validation error class@@ -79,6 +81,8 @@ export interface ValidationOptions { allowDisconnectedNodes?: boolean; /** Skip trigger requirement */ allowNoTrigger?: boolean;+ /** Enable/disable Zod schema validation (default: true) */+ validateSchema?: boolean; } /**@@ -257,6 +261,43 @@ export function validateWorkflow( } } + // Schema validation (enabled by default)+ if (options.validateSchema !== false) {+ for (const node of json.nodes) {+ // Get version number (handle both number and string versions)+ const version =+ typeof node.typeVersion === 'string'+ ? parseFloat(node.typeVersion)+ : (node.typeVersion ?? 1);++ // Build config object for validation+ const config: { parameters?: unknown; subnodes?: unknown } = {};+ if (node.parameters !== undefined) {+ config.parameters = node.parameters;+ }+ // Include subnodes if present (for AI nodes)+ const nodeWithSubnodes = node as typeof node & { subnodes?: unknown };+ if (nodeWithSubnodes.subnodes !== undefined) {+ config.subnodes = nodeWithSubnodes.subnodes;+ }++ const schemaResult = validateNodeConfig(node.type, version, config);++ if (!schemaResult.valid) {+ for (const error of schemaResult.errors) {+ // Report as WARNING (non-blocking) to maintain backwards compatibility+ warnings.push(+ new ValidationWarning(+ 'INVALID_PARAMETER',+ `Invalid parameter at ${error.path}: ${error.message}`,+ node.name,+ ),+ );+ }+ }+ }+ }+ return { valid: errors.length === 0, errors,@@ -0,0 +1,157 @@+import {+ validateNodeConfig,+ loadSchema,+ setSchemaBasePath,+ getSchemaBasePath,+} from '../validation/schema-validator';++describe('schema-validator', () => {+ // Store original path to restore after tests+ let originalBasePath: string;++ beforeAll(() => {+ originalBasePath = getSchemaBasePath();+ });++ afterAll(() => {+ setSchemaBasePath(originalBasePath);+ });++ describe('loadSchema', () => {+ it('returns null when schema file does not exist', () => {+ const schema = loadSchema('non-existent.node', 1);+ expect(schema).toBeNull();+ });++ it('loads schema for flat version structure (e.g., set v2)', () => {+ // Uses generated schemas at ~/.n8n/generated-types/+ // SetV2ConfigSchema in nodes/n8n-nodes-base/set/v2.schema.ts+ const schema = loadSchema('n8n-nodes-base.set', 2);+ expect(schema).not.toBeNull();+ });++ it('loads schema for version with decimal (e.g., httpRequest v4.2)', () => {+ // HttpRequestV42ConfigSchema in nodes/n8n-nodes-base/httpRequest/v42.schema.ts+ const schema = loadSchema('n8n-nodes-base.httpRequest', 4.2);+ expect(schema).not.toBeNull();+ });++ it('loads schema for langchain nodes with @n8n prefix', () => {+ // LcAgentV1ConfigSchema in nodes/n8n-nodes-langchain/agent/v1.schema.ts+ const schema = loadSchema('@n8n/n8n-nodes-langchain.agent', 1);+ expect(schema).not.toBeNull();+ });++ it('caches schema after first load', () => {+ // First load+ const schema1 = loadSchema('n8n-nodes-base.set', 2);+ // Second load should return same cached instance+ const schema2 = loadSchema('n8n-nodes-base.set', 2);+ // Should be the exact same object reference (cached)+ expect(schema1).toBe(schema2);+ });+ });++ describe('validateNodeConfig', () => {+ it('returns valid:true when no schema exists (graceful fallback)', () => {+ const result = validateNodeConfig('non-existent.node', 1, { parameters: {} });+ expect(result.valid).toBe(true);+ expect(result.errors).toEqual([]);+ });++ it('returns valid:true for valid config with correct parameter types', () => {+ // Set v2 has keepOnlySet: boolean+ const result = validateNodeConfig('n8n-nodes-base.set', 2, {+ parameters: { keepOnlySet: true },+ });+ expect(result.valid).toBe(true);+ expect(result.errors).toEqual([]);+ });++ it('accepts expressions as valid parameter values', () => {+ // Expressions like ={{ $json.value }} are always valid+ const result = validateNodeConfig('n8n-nodes-base.set', 2, {+ parameters: { keepOnlySet: '={{ $json.flag }}' },+ });+ expect(result.valid).toBe(true);+ });++ it('returns errors for invalid parameter type', () => {+ // keepOnlySet should be boolean or expression, not a plain string+ const result = validateNodeConfig('n8n-nodes-base.set', 2, {+ parameters: { keepOnlySet: 'not-a-boolean' },+ });+ expect(result.valid).toBe(false);+ expect(result.errors.length).toBeGreaterThan(0);+ expect(result.errors[0].path).toContain('keepOnlySet');+ });++ it('validates AI node with valid subnode config', () => {@@ -5,6 +5,7 @@ * Schemas are loaded from `nodeDefinitionDirs` configured via `setSchemaBaseDirs()`. */ +import { realpathSync } from 'fs'; import * as path from 'path'; import type { ZodSchema, ZodIssue } from 'zod'; @@ -64,6 +65,31 @@ function nodeTypeToPathComponents(nodeType: string): { pkg: string; nodeName: st return { pkg, nodeName }; } +/**+ * Whether a node-type-derived path component is a single, safe path segment.+ * A legitimate `pkg`/`nodeName` never contains separators or parent references.+ */+function isSafePathComponent(component: string): boolean {+ if (component.includes('\0')) return false;+ if (component.includes('/') || component.includes('\\')) return false;+ if (component === '.' || component === '..') return false;+ return true;+}++/**+ * Real (symlink-resolved) path of `child` if it lands on `parent` itself or+ * somewhere beneath it, otherwise null. Comparing fully resolved paths means+ * neither a `..` sequence nor a symlinked directory or file can point outside+ * `parent`. Both paths must already exist. Callers should use the returned path+ * so the resolved real file is what gets loaded.+ */+function realPathWithin(parent: string, child: string): string | null {+ const realParent = realpathSync(parent);+ const realChild = realpathSync(child);+ const contained = realChild === realParent || realChild.startsWith(realParent + path.sep);+ return contained ? realChild : null;+}+ /** * Convert version number to version string for file path *@@ -132,13 +158,23 @@ function buildExpectedFactoryName( } /**- * Try to load a schema module from a given path- * @returns The module if found, null otherwise+ * Try to load a schema module from a given path, but only if the file `require()`+ * would actually open resolves inside `nodesRoot`. Resolving the module first+ * (with its real extension, following any symlinks) and checking containment on+ * the realpath closes both `..` traversal and symlinked directory/leaf escapes.+ * @returns The module if found and contained, null otherwise */-function tryLoadSchemaModule(schemaPath: string): Record<string, unknown> | null {+function tryLoadSchemaModule(+ schemaPath: string,+ nodesRoot: string,+): Record<string, unknown> | null { try {+ const safePath = realPathWithin(nodesRoot, require.resolve(schemaPath));+ if (!safePath) {+ return null;+ } // eslint-disable-next-line @typescript-eslint/no-require-imports -- Dynamic module loading requires CommonJS require- return require(schemaPath) as Record<string, unknown>;+ return require(safePath) as Record<string, unknown>; } catch { return null; }@@ -214,15 +250,25 @@ function tryLoadSchemaForNodeType(nodeType: string, version: number): SchemaOrFa const versionStr = versionToString(version); const isLangchain = pkg === 'n8n-nodes-langchain'; + // Reject node types whose path components could escape the schema directory.+ // Only built-in nodes have generated schemas, so a rejected type would resolve+ // to nothing anyway; returning null falls back to skip-validation.+ if (!isSafePathComponent(pkg) || !isSafePathComponent(nodeName)) {+ return null;+ }+ for (const baseDir of schemaBaseDirs) {+ const nodesRoot = path.resolve(baseDir, 'nodes'); // Try flat structure first: nodes/{pkg}/{nodeName}/{version}.schema.js- const flatSchemaPath = path.join(baseDir, 'nodes', pkg, nodeName, `${versionStr}.schema`);+ const flatSchemaPath = path.join(nodesRoot, pkg, nodeName, `${versionStr}.schema`); // Try split structure: nodes/{pkg}/{nodeName}/{version}/index.schema.js- const splitSchemaPath = path.join(baseDir, 'nodes', pkg, nodeName, versionStr, 'index.schema');+ const splitSchemaPath = path.join(nodesRoot, pkg, nodeName, versionStr, 'index.schema'); - // Try flat structure first, then split structure+ // Try flat structure first, then split structure. `tryLoadSchemaModule`AI-assisted change e21502ef8d9e5450291dc4ac5a2e4f81e08baef57dfdadb5a803f28565e56014 · Fix 8bc6848ff4d68b4f3f6b729ab8cf0261b5286d9634b09ee69f68b798d7ac3a3b