Root cause
SSRF & network boundaries introduced
The Claude-generated file created workflow, execution, and credential request paths by directly interpolating caller-supplied IDs.
How AI contributed: Direct introductionPublished May 8, 2026 in
czlonkowski/n8n-mcpAll 11 findings in this repository
Impact n8n-mcp versions before 2.50.1 contained three independently-reported issues affecting deployments that run the n8n API integration: 1. Caller-supplied identifiers were not validated before being used as URL path segments by the n8n API client. An authenticated MCP caller passing a crafted workflow id could cause outbound requests carrying the configured n8n API key to land on other same-origin endpoints, bypassing handler-level access controls (including DISABLED_TOOLS). 2. Validated webhook, form, and chat trigger URLs followed redirects. A URL that passed initial validation could redirect the outbound request to a host that would otherwise have been rejected, with the response body returned to the caller. Reachable as non-blind SSRF over authenticated MCP calls. 3. Mutation telemetry stored unredacted operation payloads. On instances running with the default opt-in telemetry, partial-update operation diffs were uploaded without redaction. Operation values can carry the same node-parameter values the workflow contains, including bearer tokens, API keys, and webhook secrets. Severity CVSS 8.3 (HIGH). Exploitation requires an authenticated MCP caller and an n8n API integration configured with an n8n API key. Patched versions Upgrade to n8n-mcp >= 2.50.1. Workarounds · For issues (1) and (2): restrict network access to the HTTP transport (firewall, reverse-proxy ACL, or VPN) so only trusted callers can reach the MCP HTTP port; or switch to stdio mode, which exposes no HTTP surface for these issues. · For issue (3): set N8N_MCP_TELEMETRY_DISABLED=true in the environment before starting the server, or run npx n8n-mcp telemetry disable once. Credit Reported by @cybercraftsolutionsllc.
The AI-created src/services/n8n-api-client.ts put workflow, execution and credential identifiers into request paths without validation or encoding, so a crafted value could aim a call carrying the configured n8n key at a different same-origin endpoint and slip past the handler checks. The fix routes each identifier through encodeApiPathSegment, which rejects empty, oversized or punctuation-bearing values before encoding them.
Root cause
The Claude-generated file created workflow, execution, and credential request paths by directly interpolating caller-supplied IDs.
Fix
For the AI-traced path-building flaw, the fix accepts only nonempty alphanumeric, hyphen, or underscore identifiers and encodes them before inserting them into n8n API URLs.
Fix by Romuald Członkowski · no AI marker found
Case facts
@@ -0,0 +1,390 @@+import axios, { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';+import { logger } from '../utils/logger';+import {+ Workflow,+ WorkflowListParams,+ WorkflowListResponse,+ Execution,+ ExecutionListParams,+ ExecutionListResponse,+ Credential,+ CredentialListParams,+ CredentialListResponse,+ Tag,+ TagListParams,+ TagListResponse,+ HealthCheckResponse,+ Variable,+ WebhookRequest,+ WorkflowExport,+ WorkflowImport,+ SourceControlStatus,+ SourceControlPullResult,+ SourceControlPushResult,+} from '../types/n8n-api';+import { handleN8nApiError, logN8nError } from '../utils/n8n-errors';+import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation';++export interface N8nApiClientConfig {+ baseUrl: string;+ apiKey: string;+ timeout?: number;+ maxRetries?: number;+}++export class N8nApiClient {+ private client: AxiosInstance;+ private maxRetries: number;++ constructor(config: N8nApiClientConfig) {+ const { baseUrl, apiKey, timeout = 30000, maxRetries = 3 } = config;++ this.maxRetries = maxRetries;++ // Ensure baseUrl ends with /api/v1+ const apiUrl = baseUrl.endsWith('/api/v1') + ? baseUrl + : `${baseUrl.replace(/\/$/, '')}/api/v1`;++ this.client = axios.create({+ baseURL: apiUrl,+ timeout,+ headers: {+ 'X-N8N-API-KEY': apiKey,+ 'Content-Type': 'application/json',+ },+ });++ // Request interceptor for logging+ this.client.interceptors.request.use(+ (config: InternalAxiosRequestConfig) => {+ logger.debug(`n8n API Request: ${config.method?.toUpperCase()} ${config.url}`, {+ params: config.params,+ data: config.data,+ });+ return config;+ },+ (error: unknown) => {+ logger.error('n8n API Request Error:', error);+ return Promise.reject(error);+ }+ );++ // Response interceptor for logging+ this.client.interceptors.response.use(+ (response: any) => {+ logger.debug(`n8n API Response: ${response.status} ${response.config.url}`);+ return response;+ },+ (error: unknown) => {+ const n8nError = handleN8nApiError(error);+ logN8nError(n8nError, 'n8n API Response');+ return Promise.reject(n8nError);+ }+ );+ }++ // Health check to verify API connectivity+ async healthCheck(): Promise<HealthCheckResponse> {+ try {+ // First try the health endpoint+ const response = await this.client.get('/health');+ return response.data;+ } catch (error) {+ // If health endpoint doesn't exist, try listing workflows with limit 1+ // This is a fallback for older n8n versions+ try {+ await this.client.get('/workflows', { params: { limit: 1 } });+ return { + status: 'ok',+ features: {} // We can't determine features without proper health endpoint+ };+ } catch (fallbackError) {+ throw handleN8nApiError(fallbackError);+ }+ }+ }++ // Workflow Management+ async createWorkflow(workflow: Partial<Workflow>): Promise<Workflow> {+ try {+ const cleanedWorkflow = cleanWorkflowForCreate(workflow);+ const response = await this.client.post('/workflows', cleanedWorkflow);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async getWorkflow(id: string): Promise<Workflow> {+ try {+ const response = await this.client.get(`/workflows/${id}`);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async updateWorkflow(id: string, workflow: Partial<Workflow>): Promise<Workflow> {+ try {+ // First, try PUT method (newer n8n versions)+ const cleanedWorkflow = cleanWorkflowForUpdate(workflow as Workflow);+ try {+ const response = await this.client.put(`/workflows/${id}`, cleanedWorkflow);+ return response.data;+ } catch (putError: any) {+ // If PUT fails with 405 (Method Not Allowed), try PATCH+ if (putError.response?.status === 405) {+ logger.debug('PUT method not supported, falling back to PATCH');+ const response = await this.client.patch(`/workflows/${id}`, cleanedWorkflow);+ return response.data;+ }+ throw putError;+ }+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async deleteWorkflow(id: string): Promise<void> {+ try {+ await this.client.delete(`/workflows/${id}`);+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async listWorkflows(params: WorkflowListParams = {}): Promise<WorkflowListResponse> {+ try {+ const response = await this.client.get('/workflows', { params });+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ // Execution Management+ async getExecution(id: string, includeData = false): Promise<Execution> {+ try {+ const response = await this.client.get(`/executions/${id}`, {+ params: { includeData },+ });+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async listExecutions(params: ExecutionListParams = {}): Promise<ExecutionListResponse> {+ try {+ const response = await this.client.get('/executions', { params });+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async deleteExecution(id: string): Promise<void> {+ try {+ await this.client.delete(`/executions/${id}`);+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ // Webhook Execution+ async triggerWebhook(request: WebhookRequest): Promise<any> {+ try {+ const { webhookUrl, httpMethod, data, headers, waitForResponse = true } = request;+ + // Extract path from webhook URL+ const url = new URL(webhookUrl);+ const webhookPath = url.pathname;+ + // Make request directly to webhook endpoint+ const config: AxiosRequestConfig = {+ method: httpMethod,+ url: webhookPath,+ headers: {+ ...headers,+ // Don't override API key header for webhook endpoints+ 'X-N8N-API-KEY': undefined,+ },+ data: httpMethod !== 'GET' ? data : undefined,+ params: httpMethod === 'GET' ? data : undefined,+ // Webhooks might take longer+ timeout: waitForResponse ? 120000 : 30000,+ };++ // Create a new axios instance for webhook requests to avoid API interceptors+ const webhookClient = axios.create({+ baseURL: new URL('/', webhookUrl).toString(),+ validateStatus: (status) => status < 500, // Don't throw on 4xx+ });++ const response = await webhookClient.request(config);+ + return {+ status: response.status,+ statusText: response.statusText,+ data: response.data,+ headers: response.headers,+ };+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ // Credential Management+ async listCredentials(params: CredentialListParams = {}): Promise<CredentialListResponse> {+ try {+ const response = await this.client.get('/credentials', { params });+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async getCredential(id: string): Promise<Credential> {+ try {+ const response = await this.client.get(`/credentials/${id}`);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async createCredential(credential: Partial<Credential>): Promise<Credential> {+ try {+ const response = await this.client.post('/credentials', credential);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async updateCredential(id: string, credential: Partial<Credential>): Promise<Credential> {+ try {+ const response = await this.client.patch(`/credentials/${id}`, credential);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async deleteCredential(id: string): Promise<void> {+ try {+ await this.client.delete(`/credentials/${id}`);+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ // Tag Management+ async listTags(params: TagListParams = {}): Promise<TagListResponse> {+ try {+ const response = await this.client.get('/tags', { params });+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async createTag(tag: Partial<Tag>): Promise<Tag> {+ try {+ const response = await this.client.post('/tags', tag);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async updateTag(id: string, tag: Partial<Tag>): Promise<Tag> {+ try {+ const response = await this.client.patch(`/tags/${id}`, tag);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async deleteTag(id: string): Promise<void> {+ try {+ await this.client.delete(`/tags/${id}`);+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ // Source Control Management (Enterprise feature)+ async getSourceControlStatus(): Promise<SourceControlStatus> {+ try {+ const response = await this.client.get('/source-control/status');+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async pullSourceControl(force = false): Promise<SourceControlPullResult> {+ try {+ const response = await this.client.post('/source-control/pull', { force });+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async pushSourceControl(+ message: string,+ fileNames?: string[]+ ): Promise<SourceControlPushResult> {+ try {+ const response = await this.client.post('/source-control/push', {+ message,+ fileNames,+ });+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ // Variable Management (via Source Control API)+ async getVariables(): Promise<Variable[]> {+ try {+ const response = await this.client.get('/variables');+ return response.data.data || [];+ } catch (error) {+ // Variables might not be available in all n8n versions+ logger.warn('Variables API not available, returning empty array');+ return [];+ }+ }++ async createVariable(variable: Partial<Variable>): Promise<Variable> {+ try {+ const response = await this.client.post('/variables', variable);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async updateVariable(id: string, variable: Partial<Variable>): Promise<Variable> {+ try {+ const response = await this.client.patch(`/variables/${id}`, variable);+ return response.data;+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }++ async deleteVariable(id: string): Promise<void> {+ try {+ await this.client.delete(`/variables/${id}`);+ } catch (error) {+ throw handleN8nApiError(error);+ }+ }+}\ No newline at end of file@@ -219,7 +220,7 @@ export class N8nApiClient { async getWorkflow(id: string): Promise<Workflow> { try {- const response = await this.client.get(`/workflows/${id}`);+ const response = await this.client.get(`/workflows/${encodeApiPathSegment(id, 'workflowId')}`); return response.data; } catch (error) { throw handleN8nApiError(error);@@ -358,7 +360,7 @@ export class N8nApiClient { // Execution Management async getExecution(id: string, includeData = false): Promise<Execution> { try {- const response = await this.client.get(`/executions/${id}`, {+ const response = await this.client.get(`/executions/${encodeApiPathSegment(id, 'executionId')}`, { params: { includeData }, }); return response.data;@@ -474,7 +478,7 @@ export class N8nApiClient { async getCredential(id: string): Promise<Credential> { try {- const response = await this.client.get(`/credentials/${id}`);+ const response = await this.client.get(`/credentials/${encodeApiPathSegment(id, 'credentialId')}`); return response.data; } catch (error) { throw handleN8nApiError(error);@@ -13,6 +13,34 @@ export class ValidationError extends Error { } } +// SECURITY (GHSA-8g7g-hmwm-6rv2): validate caller-supplied URL path segment.+const API_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/;+const API_PATH_SEGMENT_MAX_LENGTH = 128;++export function encodeApiPathSegment(value: unknown, fieldName: string): string {+ if (typeof value !== 'string' || value.length === 0) {+ throw new ValidationError(+ `${fieldName} is required and must be a non-empty string`,+ fieldName,+ value+ );+ }+ if (value.length > API_PATH_SEGMENT_MAX_LENGTH) {+ throw new ValidationError(+ `${fieldName} exceeds maximum length of ${API_PATH_SEGMENT_MAX_LENGTH} characters`,+ fieldName+ );+ }+ if (!API_PATH_SEGMENT_PATTERN.test(value)) {+ throw new ValidationError(+ `Invalid ${fieldName}: must contain only alphanumeric characters, hyphens, or underscores`,+ fieldName,+ value+ );+ }+ return encodeURIComponent(value);+}+ export interface ValidationResult { valid: boolean; errors: Array<{AI-assisted change 2fb93cb2e3356b86dfb0df4a214fdf731e138da2e90228d42e6a9888eac985f5 · Fix 6c3f5ed71c963dfd560505f2ed1a5c8ed75b50e10992cdfe4095ca51809e7cd0