import { ActivateWorkflowDto, ArchiveWorkflowDto, CreateWorkflowDto, DeactivateWorkflowDto, ExecutionRedactionQueryDtoSchema, ImportWorkflowFromUrlDto, ROLE, TransferWorkflowBodyDto, UpdateWorkflowDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig, SsrfProtectionConfig } from '@n8n/config'; import { SharedWorkflow, WorkflowEntity, ProjectRelationRepository, ProjectRepository, WorkflowRepository, AuthenticatedRequest, } from '@n8n/db'; import { Body, Delete, Get, Licensed, Param, Patch, Post, ProjectScope, Put, Query, RestController, } from '@n8n/decorators'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type FindOptionsRelations } from '@n8n/typeorm'; import axios, { type AxiosRequestConfig } from 'axios'; import express from 'express'; import { calculateWorkflowChecksum, ensureError } from 'n8n-workflow'; import { CollaborationService } from '../collaboration/collaboration.service'; import { WorkflowCreationService } from './workflow-creation.service'; import { WorkflowExecutionService } from './workflow-execution.service'; import { WorkflowFinderService } from './workflow-finder.service'; import { WorkflowRequest } from './workflow.request'; import { WorkflowService } from './workflow.service'; import { EnterpriseWorkflowService } from './workflow.service.ee'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { ExecutionService } from '@/executions/execution.service'; import type { IWorkflowResponse } from '@/interfaces'; import { License } from '@/license'; import { listQueryMiddleware } from '@/middlewares'; import { userHasScopes } from '@/permissions.ee/check-access'; import * as ResponseHelper from '@/response-helper'; import { NamingService } from '@/services/naming.service'; import { ProjectService } from '@/services/project.service.ee'; import { SsrfBlockedIpError } from '@/services/ssrf/ssrf-blocked-ip.error'; import { SsrfProtectionService } from '@/services/ssrf/ssrf-protection.service'; import { UserManagementMailer } from '@/user-management/email'; import * as utils from '@/utils'; @RestController('/workflows') export class WorkflowsController { constructor( private readonly logger: Logger, private readonly enterpriseWorkflowService: EnterpriseWorkflowService, private readonly namingService: NamingService, private readonly workflowRepository: WorkflowRepository, private readonly workflowService: WorkflowService, private readonly workflowCreationService: WorkflowCreationService, private readonly workflowExecutionService: WorkflowExecutionService, private readonly license: License, private readonly mailer: UserManagementMailer, private readonly projectRepository: ProjectRepository, private readonly projectService: ProjectService, private readonly projectRelationRepository: ProjectRelationRepository, private readonly eventService: EventService, private readonly globalConfig: GlobalConfig, private readonly workflowFinderService: WorkflowFinderService, private readonly executionService: ExecutionService, private readonly collaborationService: CollaborationService, private readonly ssrfConfig: SsrfProtectionConfig, private readonly ssrfProtectionService: SsrfProtectionService, ) {} @Post('/') async create(req: AuthenticatedRequest, _res: unknown, @Body body: CreateWorkflowDto) { if (body.id) { const workflowExists = await this.workflowRepository.existsBy({ id: body.id }); if (workflowExists) { throw new BadRequestError(`Workflow with id ${body.id} exists already.`); } } const newWorkflow = new WorkflowEntity(); // Security: Object.assign is now safe because the DTO validates and filters all input // Only fields defined in CreateWorkflowDto are assigned; internal fields like // triggerCount, versionCounter, isArchived, etc. are never set from user input Object.assign(newWorkflow, body); const savedWorkflow = await this.workflowCreationService.createWorkflow(req.user, newWorkflow, { tagIds: body.tags, parentFolderId: body.parentFolderId, projectId: body.projectId, autosaved: body.autosaved, uiContext: body.uiContext, }); const savedWorkflowWithMetaData = this.enterpriseWorkflowService.addOwnerAndSharings(savedWorkflow); // @ts-expect-error: This is added as part of addOwnerAndSharings but // shouldn't be returned to the frontend delete savedWorkflowWithMetaData.shared; const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id); const checksum = await calculateWorkflowChecksum(savedWorkflow); return { ...savedWorkflowWithMetaData, scopes, checksum }; } @Get('/', { middlewares: listQueryMiddleware }) async getAll(req: WorkflowRequest.GetMany, res: express.Response) { try { const userCanListProjectFolders = req.listQueryOptions?.filter?.projectId ? await userHasScopes(req.user, ['folder:list'], false, { projectId: req.listQueryOptions?.filter?.projectId as string, }) : true; const { workflows: data, count } = await this.workflowService.getMany( req.user, req.listQueryOptions, !!req.query.includeScopes, userCanListProjectFolders && !!req.query.includeFolders, !!req.query.onlySharedWithMe, ); res.json({ count, data }); } catch (maybeError) { const error = utils.toError(maybeError); ResponseHelper.reportError(error); ResponseHelper.sendErrorResponse(res, error); } } @Get('/new') async getNewName(req: WorkflowRequest.NewName) { const projectId = req.query.projectId; if ( !(await this.projectService.getProjectWithScope(req.user, projectId, ['workflow:create'])) ) { throw new ForbiddenError( "You don't have the permissions to create a workflow in this project.", ); } const requestedName = req.query.name ?? this.globalConfig.workflows.defaultName; const name = await this.namingService.getUniqueWorkflowName(requestedName); return { name }; } @Get('/from-url') async getFromUrl( req: AuthenticatedRequest, _res: express.Response, @Query query: ImportWorkflowFromUrlDto, ) { const projectId = query.projectId; if ( !(await this.projectService.getProjectWithScope(req.user, projectId, ['workflow:create'])) ) { throw new ForbiddenError( "You don't have the permissions to create a workflow in this project.", ); } const workflowData = await this.fetchWorkflowFromUrl(query.url); // Do a very basic check if it is really a n8n-workflow-json if ( workflowData?.nodes === undefined || !Array.isArray(workflowData.nodes) || workflowData.connections === undefined || typeof workflowData.connections !== 'object' || Array.isArray(workflowData.connections) ) { throw new BadRequestError( 'The data in the file does not seem to be a n8n workflow JSON file!', ); } return workflowData; } @Get('/:workflowId') @ProjectScope('workflow:read') async getWorkflow(req: WorkflowRequest.Get) { const { workflowId } = req.params; if (this.license.isSharingEnabled()) { const relations: FindOptionsRelations = { shared: { project: { projectRelations: true, }, }, }; if (!this.globalConfig.tags.disabled) { relations.tags = true; } const workflow = await this.workflowFinderService.findWorkflowForUser( workflowId, req.user, ['workflow:read'], { includeTags: !this.globalConfig.tags.disabled, includeParentFolder: true, includeActiveVersion: true, }, ); if (!workflow) { throw new NotFoundError(`Workflow with ID "${workflowId}" does not exist`); } const enterpriseWorkflowService = this.enterpriseWorkflowService; const workflowWithMetaData = enterpriseWorkflowService.addOwnerAndSharings(workflow); await enterpriseWorkflowService.addCredentialsToWorkflow(workflowWithMetaData, req.user); // @ts-expect-error: This is added as part of addOwnerAndSharings but // shouldn't be returned to the frontend delete workflowWithMetaData.shared; const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); const checksum = await calculateWorkflowChecksum(workflow); return { ...workflowWithMetaData, scopes, checksum }; } // sharing disabled const workflow = await this.workflowFinderService.findWorkflowForUser( workflowId, req.user, ['workflow:read'], { includeTags: !this.globalConfig.tags.disabled, includeParentFolder: true, includeActiveVersion: true, }, ); if (!workflow) { this.logger.warn('User attempted to access a workflow without permissions', { workflowId, userId: req.user.id, }); throw new NotFoundError( 'Could not load the workflow - you can only access workflows owned by you', ); } const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); const checksum = await calculateWorkflowChecksum(workflow); return { ...workflow, scopes, checksum }; } /** * Checks whether a workflow with the given ID exists. * * @note We cannot use @ProjectScope here because we want to check for the id's existence * Adding a scope would disable the route if the user didn't have access to the workflow */ @Get('/:workflowId/exists') async exists(req: WorkflowRequest.Get) { const exists = await this.workflowRepository.existsBy({ id: req.params.workflowId }); return { exists }; } @Patch('/:workflowId') @ProjectScope('workflow:update') async update( req: WorkflowRequest.Update, _res: unknown, @Param('workflowId') workflowId: string, @Body body: UpdateWorkflowDto, ) { const forceSave = req.query.forceSave === 'true'; const clientId = req.headers['push-ref']; await this.collaborationService.validateWriteLock(req.user.id, clientId, workflowId, 'update'); let updateData = new WorkflowEntity(); const { tags, parentFolderId, aiBuilderAssisted, expectedChecksum, autosaved, ...rest } = body; // Validate timeSavedMode if present if ( body.settings?.timeSavedMode !== undefined && !['fixed', 'dynamic'].includes(body.settings.timeSavedMode) ) { throw new BadRequestError('Invalid timeSavedMode'); } // Security: Object.assign is now safe because the DTO validates and filters all input // Only fields defined in UpdateWorkflowDto are assigned; internal fields like // triggerCount, versionCounter, isArchived, active, activeVersionId, etc. are never set from user input Object.assign(updateData, rest); const isSharingEnabled = this.license.isSharingEnabled(); if (isSharingEnabled) { updateData = await this.enterpriseWorkflowService.preventTampering( updateData, workflowId, req.user, ); } const updatedWorkflow = await this.workflowService.update(req.user, updateData, workflowId, { tagIds: tags, parentFolderId, forceSave: isSharingEnabled ? forceSave : true, expectedChecksum, aiBuilderAssisted, autosaved, }); const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); const checksum = await calculateWorkflowChecksum(updatedWorkflow); await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id); return { ...updatedWorkflow, scopes, checksum }; } @Get('/:workflowId/collaboration/write-lock') @ProjectScope('workflow:read') async getWriteLock( req: AuthenticatedRequest, _res: Response, @Param('workflowId') workflowId: string, ) { const writeLock = await this.collaborationService.getWriteLock(req.user.id, workflowId); return writeLock; } @Delete('/:workflowId') @ProjectScope('workflow:delete') async delete(req: AuthenticatedRequest, _res: Response, @Param('workflowId') workflowId: string) { const clientId = req.headers['push-ref']; await this.collaborationService.validateWriteLock(req.user.id, clientId, workflowId, 'delete'); const workflow = await this.workflowService.delete(req.user, workflowId); if (!workflow) { this.logger.warn('User attempted to delete a workflow without permissions', { workflowId, userId: req.user.id, }); throw new ForbiddenError( 'Could not delete the workflow - workflow was not found in your projects', ); } return true; } @Post('/:workflowId/archive') @ProjectScope('workflow:delete') async archive( req: AuthenticatedRequest, _res: Response, @Param('workflowId') workflowId: string, @Body body: ArchiveWorkflowDto, ) { const clientId = req.headers['push-ref']; await this.collaborationService.validateWriteLock(req.user.id, clientId, workflowId, 'archive'); const { expectedChecksum } = body; const workflow = await this.workflowService.archive(req.user, workflowId, { expectedChecksum, }); if (!workflow) { this.logger.warn('User attempted to archive a workflow without permissions', { workflowId, userId: req.user.id, }); throw new ForbiddenError( 'Could not archive the workflow - workflow was not found in your projects', ); } const checksum = await calculateWorkflowChecksum(workflow); await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id); return { ...workflow, checksum }; } @Post('/:workflowId/unarchive') @ProjectScope('workflow:delete') async unarchive( req: AuthenticatedRequest, _res: Response, @Param('workflowId') workflowId: string, ) { const clientId = req.headers['push-ref']; await this.collaborationService.validateWriteLock( req.user.id, clientId, workflowId, 'unarchive', ); const workflow = await this.workflowService.unarchive(req.user, workflowId); if (!workflow) { this.logger.warn('User attempted to unarchive a workflow without permissions', { workflowId, userId: req.user.id, }); throw new ForbiddenError( 'Could not unarchive the workflow - workflow was not found in your projects', ); } const checksum = await calculateWorkflowChecksum(workflow); await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id); return { ...workflow, checksum }; } @Post('/:workflowId/activate') @ProjectScope('workflow:publish') async activate( req: WorkflowRequest.Activate, _res: unknown, @Param('workflowId') workflowId: string, @Body body: ActivateWorkflowDto, ) { const clientId = req.headers['push-ref']; await this.collaborationService.validateWriteLock( req.user.id, clientId, workflowId, 'activate', ); const { versionId, name, description, expectedChecksum } = body; const workflow = await this.workflowService.activateWorkflow(req.user, workflowId, { versionId, name, description, expectedChecksum, }); const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); const checksum = await calculateWorkflowChecksum(workflow); await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id); return { ...workflow, scopes, checksum }; } @Post('/:workflowId/deactivate') @ProjectScope('workflow:unpublish') async deactivate( req: WorkflowRequest.Deactivate, _res: unknown, @Param('workflowId') workflowId: string, @Body body: DeactivateWorkflowDto, ) { const clientId = req.headers['push-ref'] as string | undefined; await this.collaborationService.validateWriteLock( req.user.id, clientId, workflowId, 'deactivate', ); const { expectedChecksum } = body; const workflow = await this.workflowService.deactivateWorkflow(req.user, workflowId, { expectedChecksum, }); const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); const checksum = await calculateWorkflowChecksum(workflow); await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id); return { ...workflow, scopes, checksum }; } @Post('/:workflowId/run') @ProjectScope('workflow:execute') async runManually(req: WorkflowRequest.ManualRun, _res: unknown) { const workflowId = req.params.workflowId; // Always load the stored workflow from the database. // This prevents execution of arbitrary workflow definitions — // users can only execute workflows as they exist in the DB. const dbWorkflow = await this.workflowRepository.get({ id: workflowId }); if (!dbWorkflow) { throw new NotFoundError(`Workflow with ID "${workflowId}" not found`); } const result = await this.workflowExecutionService.executeManually( dbWorkflow, req.body, req.user, req.headers['push-ref'], ); if ('executionId' in result) { this.eventService.emit('workflow-executed', { user: { id: req.user.id, email: req.user.email, firstName: req.user.firstName, lastName: req.user.lastName, role: req.user.role, }, workflowId: dbWorkflow.id, workflowName: dbWorkflow.name, executionId: result.executionId, source: 'user-manual', }); } return result; } @Licensed('feat:sharing') @Put('/:workflowId/share') async share(req: WorkflowRequest.Share) { const { workflowId } = req.params; const { shareWithIds } = req.body; if ( !Array.isArray(shareWithIds) || !shareWithIds.every((userId) => typeof userId === 'string') ) { throw new BadRequestError('Bad request'); } const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, req.user, [ 'workflow:read', ]); if (!workflow) { throw new ForbiddenError(); } const currentPersonalProjectIDs = workflow.shared .filter((sw) => sw.role === 'workflow:editor') .map((sw) => sw.projectId); const newPersonalProjectIDs = shareWithIds; const toShare = utils.rightDiff( [currentPersonalProjectIDs, (id) => id], [newPersonalProjectIDs, (id) => id], ); const toUnshare = utils.rightDiff( [newPersonalProjectIDs, (id) => id], [currentPersonalProjectIDs, (id) => id], ); if (toShare.length > 0) { const canShare = await userHasScopes(req.user, ['workflow:share'], false, { workflowId }); if (!canShare) { throw new ForbiddenError(); } } if (toUnshare.length > 0) { const canUnshare = await userHasScopes(req.user, ['workflow:unshare'], false, { workflowId, }); if (!canUnshare) { throw new ForbiddenError(); } } let newShareeIds: string[] = []; const { manager: dbManager } = this.projectRepository; await dbManager.transaction(async (trx) => { await trx.delete(SharedWorkflow, { workflowId, projectId: In(toUnshare), }); await this.enterpriseWorkflowService.shareWithProjects(workflow.id, toShare, trx); newShareeIds = toShare; }); this.eventService.emit('workflow-sharing-updated', { workflowId, userIdSharer: req.user.id, userIdList: shareWithIds, }); const projectsRelations = await this.projectRelationRepository.findBy({ projectId: In(newShareeIds), role: { slug: PROJECT_OWNER_ROLE_SLUG }, }); await this.mailer.notifyWorkflowShared({ sharer: req.user, newShareeIds: projectsRelations.map((pr) => pr.userId), workflow, }); } @Put('/:workflowId/transfer') @ProjectScope('workflow:move') async transfer( req: AuthenticatedRequest, _res: unknown, @Param('workflowId') workflowId: string, @Body body: TransferWorkflowBodyDto, ) { return await this.enterpriseWorkflowService.transferWorkflow( req.user, workflowId, body.destinationProjectId, body.shareCredentials, body.destinationParentFolderId, ); } @Get('/:workflowId/executions/last-successful') @ProjectScope('workflow:read') async getLastSuccessfulExecution( req: AuthenticatedRequest, _res: unknown, @Param('workflowId') workflowId: string, ) { const redactQuery = ExecutionRedactionQueryDtoSchema.safeParse(req.query); const redactExecutionData = redactQuery.success ? redactQuery.data.redactExecutionData : undefined; const lastExecution = await this.executionService.getLastSuccessfulExecution( workflowId, req.user, redactExecutionData, ); return lastExecution ?? null; } @Post('/with-node-types') async getWorkflowsWithNodesIncluded(req: AuthenticatedRequest, res: express.Response) { try { const hasPermission = req.user.role.slug === ROLE.Owner || req.user.role.slug === ROLE.Admin; if (!hasPermission) { res.json({ data: [], count: 0 }); return; } const { nodeTypes } = req.body as { nodeTypes: string[] }; const workflows = await this.workflowService.getWorkflowsWithNodesIncluded( req.user, nodeTypes, ); res.json({ data: workflows, count: workflows.length, }); } catch (maybeError) { const error = utils.toError(maybeError); ResponseHelper.reportError(error); ResponseHelper.sendErrorResponse(res, error); } } private async fetchWorkflowFromUrl(url: string) { try { if (!this.ssrfConfig.enabled) { const { data } = await axios.get(url); return data; } const result = await this.ssrfProtectionService.validateUrl(url); if (!result.ok) throw result.error; const config: AxiosRequestConfig = { lookup: this.ssrfProtectionService.createSecureLookup() as AxiosRequestConfig['lookup'], beforeRedirect: (redirectedRequest: Record) => { this.ssrfProtectionService.validateRedirectSync(redirectedRequest.href); }, }; const { data } = await axios.get(url, config); return data; } catch (error) { const blockedError = this.findSsrfBlockedError(error); if (blockedError) throw blockedError; throw new BadRequestError('The URL does not point to valid JSON file!'); } } /** * Walk the error cause chain to find a {@link SsrfBlockedIpError} buried * inside axios/redirect wrappers (AxiosError → RedirectionError → SsrfBlockedIpError). */ private findSsrfBlockedError(error: unknown): SsrfBlockedIpError | undefined { let current = ensureError(error); for (let depth = 0; depth < 4 && current; depth++) { if (current instanceof SsrfBlockedIpError) return current; current = ensureError(current.cause); } return undefined; } }