import type { ZodClass } from '@n8n/api-types'; import { inProduction } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { type BooleanLicenseFeature } from '@n8n/constants'; import { isAuthenticatedRequest } from '@n8n/db'; import { ControllerRegistryMetadata } from '@n8n/decorators'; import type { AccessScope, Controller, RateLimiterLimits, StaticRouterMetadata, KeyedRateLimiterConfig, } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { Router } from 'express'; import type { Application, Request, Response, RequestHandler } from 'express'; import { UnexpectedError } from 'n8n-workflow'; import assert from 'node:assert'; import { AuthService } from '@/auth/auth.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; import { License } from '@/license'; import { userHasScopes } from '@/permissions.ee/check-access'; import { reportError, send, sendErrorResponse } from '@/response-helper'; import { AbstractServer } from './abstract-server'; import { NotFoundError } from './errors/response-errors/not-found.error'; import { CorsService } from './services/cors-service'; import { LastActiveAtService } from './services/last-active-at.service'; import { RateLimitService } from './services/rate-limit.service'; @Service() export class ControllerRegistry { constructor( private readonly license: License, private readonly authService: AuthService, private readonly globalConfig: GlobalConfig, private readonly metadata: ControllerRegistryMetadata, private readonly lastActiveAtService: LastActiveAtService, private readonly rateLimitService: RateLimitService, ) {} activate(app: Application) { for (const controllerClass of this.metadata.controllerClasses) { const metadata = this.metadata.getControllerMetadata(controllerClass); if (metadata.isPublicApi) continue; this.activateController(app, controllerClass); } } private activateController(app: Application, controllerClass: Controller) { const metadata = this.metadata.getControllerMetadata(controllerClass); const router = Router({ mergeParams: true }); const basePath = metadata.registerOnRootPath ? metadata.basePath : `/${this.globalConfig.endpoints.rest}/${metadata.basePath}`; const prefix = basePath.replace(/\/+/g, '/').replace(/\/$/, ''); app.use(prefix === '' ? '/' : prefix, router); const controller = Container.get(controllerClass) as Controller; const controllerMiddlewares = metadata.middlewares.map( (handlerName) => controller[handlerName].bind(controller) as RequestHandler, ); const staticRouters = (controllerClass as any).routers as StaticRouterMetadata[] | undefined; if (staticRouters) { for (const routerConfig of staticRouters) { if (!routerConfig.router) { throw new UnexpectedError( `Router is undefined for path "${routerConfig.path}" in controller "${controllerClass.name}"`, ); } const middlewares = this.buildMiddlewares(routerConfig, controllerMiddlewares); router.use(routerConfig.path, ...middlewares, routerConfig.router); } } // Register regular routes for (const [handlerName, route] of metadata.routes) { // Original handler logic for non-router routes const argTypes = Reflect.getMetadata( 'design:paramtypes', controller, handlerName, ) as unknown[]; const handler = async (req: Request, res: Response) => { if (route.cors) { const corsService = Container.get(CorsService); const corsOptions = route.cors === true ? {} : route.cors; corsService.applyCorsHeaders(req, res, corsOptions); } const args: unknown[] = [req, res]; for (let index = 0; index < route.args.length; index++) { const arg = route.args[index]; if (!arg) continue; if (arg.type === 'param') args.push(req.params[arg.key]); else if (['body', 'query'].includes(arg.type)) { const paramType = argTypes[index] as ZodClass; if (paramType && 'safeParse' in paramType) { const output = paramType.safeParse(req[arg.type]); if (output.success) args.push(output.data); else { return res.status(400).json(output.error.errors[0]); } } } else throw new UnexpectedError('Unknown arg type: ' + arg.type); } return await controller[handlerName](...args); }; const bodyArgIdx = route.args.findIndex((arg) => arg?.type === 'body'); const bodyArgType = bodyArgIdx !== -1 ? (argTypes[bodyArgIdx] as ZodClass) : undefined; const middlewares = this.buildMiddlewares(route, controllerMiddlewares, bodyArgType); const finalHandler = route.usesTemplates ? async (req: Request, res: Response) => { try { await handler(req, res); } catch (e) { // Template routes skip `send()`, so without this a thrown error // reaches Express's default handler, which cannot read // `httpStatusCode` off a ResponseError and answers 500 — and in // production with a body of just "Internal Server Error". const error = ensureError(e); reportError(error, { extra: { method: req.method, path: req.path } }); if (res.headersSent) throw error; sendErrorResponse(res, error); } } : send(handler); router[route.method](route.path, ...middlewares, finalHandler); // Register bot-allowed routes so the global bot filter can exempt them. // Store the full path pattern (prefix + route path) as a regex so the // global middleware can match against the actual resolved request path. if (route.allowBots) { const fullPattern = (prefix + route.path).replace(/\/+/g, '/'); // Convert Express params (:name) to regex wildcards const regexStr = fullPattern.replace(/:[^/]+/g, '[^/]+'); AbstractServer.botAllowedPaths.push(regexStr); } } } /** * Builds middleware array based on route configuration. * Used for both static routers and inline router definitions. */ private buildMiddlewares( route: { skipAuth?: boolean; allowSkipMFA?: boolean; allowSkipPreviewAuth?: boolean; allowUnauthenticated?: boolean; ipRateLimit?: boolean | RateLimiterLimits; keyedRateLimit?: KeyedRateLimiterConfig; licenseFeature?: BooleanLicenseFeature; accessScope?: AccessScope; middlewares?: RequestHandler[]; }, controllerMiddlewares: RequestHandler[], bodyDtoClass?: ZodClass, ): RequestHandler[] { const middlewares: RequestHandler[] = []; // LAYER 1: IP-based rate limiting (always before auth) if (inProduction && route.ipRateLimit) { middlewares.push(this.rateLimitService.createIpRateLimitMiddleware(route.ipRateLimit)); } // LAYER 2a: Keyed rate limiting with body source (BEFORE auth) if (inProduction && route.keyedRateLimit?.source === 'body') { assert( bodyDtoClass, 'Body argument type (@Body decorator) is required for body-based rate limiting', ); middlewares.push( this.rateLimitService.createBodyKeyedRateLimitMiddleware( bodyDtoClass, route.keyedRateLimit, ), ); } if (!route.skipAuth) { middlewares.push( this.authService.createAuthMiddleware({ allowSkipMFA: route.allowSkipMFA ?? false, allowSkipPreviewAuth: route.allowSkipPreviewAuth ?? false, allowUnauthenticated: route.allowUnauthenticated ?? false, }), this.lastActiveAtService.middleware.bind(this.lastActiveAtService), ); } // LAYER 2b: User-based rate limiting with user source (AFTER auth) if (route.keyedRateLimit?.source === 'user') { assert( !route.skipAuth, `User-based rate limiting is only supported for authenticated endpoints. Route: ${JSON.stringify(route)}`, ); // Separate ifs intentionally to prevent configuration errors in development if (inProduction) { middlewares.push( this.rateLimitService.createUserKeyedRateLimitMiddleware(route.keyedRateLimit), ); } } if (route.licenseFeature) { middlewares.push(this.createLicenseMiddleware(route.licenseFeature)); } if (route.accessScope) { middlewares.push(this.createScopedMiddleware(route.accessScope)); } middlewares.push(...controllerMiddlewares); if (route.middlewares) { middlewares.push(...route.middlewares); } return middlewares; } private createLicenseMiddleware(feature: BooleanLicenseFeature): RequestHandler { return (_req, res, next) => { if (!this.license.isLicensed(feature)) { res.status(403).json({ status: 'error', message: 'Plan lacks license for this feature' }); return; } next(); }; } private createScopedMiddleware(accessScope: AccessScope): RequestHandler { return async (req, res, next) => { if (!isAuthenticatedRequest(req)) throw new UnauthenticatedError(); if (!req.user) throw new UnauthenticatedError(); const { scope, globalOnly } = accessScope; try { if (!(await userHasScopes(req.user, [scope], globalOnly, req.params))) { res.status(403).json({ status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, }); return; } } catch (error) { if (error instanceof NotFoundError) { res.status(404).json({ status: 'error', message: error.message }); return; } throw error; } next(); }; } }