diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index 4d5ab6a50d7..81e097c5760 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -30,6 +30,7 @@ describe('eligibleModules', () => { 'mcp', 'provisioning', 'breaking-changes', + 'dynamic-credentials', ]); }); @@ -43,6 +44,7 @@ describe('eligibleModules', () => { 'mcp', 'provisioning', 'breaking-changes', + 'dynamic-credentials', ]); }); diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index 21234be3f01..7fc9e591686 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -37,6 +37,7 @@ export class ModuleRegistry { 'mcp', 'provisioning', 'breaking-changes', + 'dynamic-credentials', ]; private readonly activeModules: string[] = []; diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index 5b4a6ff42de..6c2cb14dd85 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -11,6 +11,7 @@ export const MODULE_NAMES = [ 'chat-hub', 'provisioning', 'breaking-changes', + 'dynamic-credentials', ] as const; export type ModuleName = (typeof MODULE_NAMES)[number]; diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index ea0ed8d4c54..716acdbf608 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -31,12 +31,12 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; -import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { ShutdownService } from '@/shutdown/shutdown.service'; import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; export abstract class BaseCommand { readonly flags: F; @@ -99,7 +99,6 @@ export abstract class BaseCommand { this.nodeTypes = Container.get(NodeTypes); - await this.executionContextHookRegistry.init(); await Container.get(LoadNodesAndCredentials).init(); await this.dbConnection diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index f9e2472b7d5..920d8974968 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -35,6 +35,7 @@ import { WorkflowRunner } from '@/workflow-runner'; import { BaseCommand } from './base-command'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { DeprecationService } from '@/deprecation/deprecation.service'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const open = require('open'); @@ -262,6 +263,9 @@ export class Start extends BaseCommand> { Container.get(MultiMainSetup).registerEventHandlers(); } + + await this.executionContextHookRegistry.init(); + await Container.get(LoadNodesAndCredentials).postProcessLoaders(); } async initOrchestration() { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 447b9308b77..ecf6b66370c 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -17,6 +17,7 @@ import type { WorkerServerEndpointsConfig } from '@/scaling/worker-server'; import { WorkerStatusService } from '@/scaling/worker-status.service.ee'; import { BaseCommand } from './base-command'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; const flagsSchema = z.object({ concurrency: z.number().int().default(10).describe('How many jobs can run in parallel.'), @@ -115,6 +116,9 @@ export class Worker extends BaseCommand> { ); await this.moduleRegistry.initModules(this.instanceSettings.instanceType); + + await this.executionContextHookRegistry.init(); + await Container.get(LoadNodesAndCredentials).postProcessLoaders(); } async initEventBus() { diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 0829e8ad845..6b59e5633e1 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -281,9 +281,13 @@ export class LoadNodesAndCredentials { }); } + private shouldInjectContextEstablishmentHooks() { + return process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS === 'true'; + } + private injectContextEstablishmentHooks() { // Check if the feature is enabled via environment variable - const isEnabled = process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS === 'true'; + const isEnabled = this.shouldInjectContextEstablishmentHooks(); if (!isEnabled) { this.logger.debug('Context establishment hooks feature is disabled'); @@ -298,110 +302,115 @@ export class LoadNodesAndCredentials { `Injecting context establishment hooks for ${triggerNodes.length} trigger nodes`, ); - triggerNodes.forEach((node: INodeTypeDescription) => { - const hooks = this.executionContextHookRegistry.getHookForTriggerType(node.name); + triggerNodes.forEach(this.augmentNodeTypeDescription); + } - if (hooks.length > 0) { - this.logger.debug(`Found ${hooks.length} hooks for trigger node: ${node.name}`); - } + private augmentNodeTypeDescription = (node: INodeTypeDescription) => { + const hooks = this.executionContextHookRegistry.getHookForTriggerType(node.name); - // Only inject hook properties if there are applicable hooks - if (hooks.length === 0) return; + if (hooks.length > 0) { + this.logger.debug(`Found ${hooks.length} hooks for trigger node: ${node.name}`); + } - // Create a fixedCollection with multipleValues for multiple hook selection - // Each hook becomes a separate item that can be added multiple times - const allHookValues: INodeProperties[] = [ - { - displayName: 'Hook', - name: 'hookName', - type: 'options', - options: hooks.map((hook) => { - const displayName = hook.hookDescription.displayName ?? hook.hookDescription.name; - return { - name: displayName, - value: hook.hookDescription.name, - description: `Use ${displayName} hook`, - }; - }), - // No default - force user to explicitly select a hook - // This ensures hookName is always serialized in the workflow JSON - default: '', - description: 'Select which context establishment hook to use', - required: true, - }, - { - displayName: 'Allow Failure', - name: 'isAllowedToFail', - type: 'boolean', - default: false, - description: 'Whether to continue workflow execution if this hook fails', - }, - ]; + // Only inject hook properties if there are applicable hooks + if (hooks.length === 0) return; - // Add all hook-specific options with display conditions - for (const hook of hooks) { - const hookOptions = hook.hookDescription.options ?? []; - if (hookOptions.length > 0) { - for (const hookOption of hookOptions) { - // Add display condition to show only when this specific hook is selected - const enhancedOption: INodeProperties = { - ...hookOption, - displayOptions: { - ...hookOption.displayOptions, - show: { - ...hookOption.displayOptions?.show, - hookName: [hook.hookDescription.name], - }, + // This prevents double-injection if the function is called multiple times on the same node + if (node.properties.some((p) => p.name === 'executionsHooksVersion')) return; + + // Create a fixedCollection with multipleValues for multiple hook selection + // Each hook becomes a separate item that can be added multiple times + const allHookValues: INodeProperties[] = [ + { + displayName: 'Hook', + name: 'hookName', + type: 'options', + options: hooks.map((hook) => { + const displayName = hook.hookDescription.displayName ?? hook.hookDescription.name; + return { + name: displayName, + value: hook.hookDescription.name, + description: `Use ${displayName} hook`, + }; + }), + // No default - force user to explicitly select a hook + // This ensures hookName is always serialized in the workflow JSON + default: '', + description: 'Select which context establishment hook to use', + required: true, + }, + { + displayName: 'Allow Failure', + name: 'isAllowedToFail', + type: 'boolean', + default: false, + description: 'Whether to continue workflow execution if this hook fails', + }, + ]; + + // Add all hook-specific options with display conditions + for (const hook of hooks) { + const hookOptions = hook.hookDescription.options ?? []; + if (hookOptions.length > 0) { + for (const hookOption of hookOptions) { + // Add display condition to show only when this specific hook is selected + const enhancedOption: INodeProperties = { + ...hookOption, + displayOptions: { + ...hookOption.displayOptions, + show: { + ...hookOption.displayOptions?.show, + hookName: [hook.hookDescription.name], }, - }; - allHookValues.push(enhancedOption); - } + }, + }; + allHookValues.push(enhancedOption); } } + } - // Create a hidden version property to track the hooks format version - const executionsHooksVersion: INodeProperties = { - displayName: 'Executions Hooks Version', - name: 'executionsHooksVersion', - type: 'hidden', - default: 1, - }; + // Create a hidden version property to track the hooks format version + const executionsHooksVersion: INodeProperties = { + displayName: 'Executions Hooks Version', + name: 'executionsHooksVersion', + type: 'hidden', + default: 1, + }; - // Create the main context establishment hooks property as a fixedCollection - const contextHooksProperty: INodeProperties = { - displayName: 'Context Establishment Hooks', - name: 'contextEstablishmentHooks', - type: 'fixedCollection', - placeholder: 'Add Hook', - default: {}, - typeOptions: { - multipleValues: true, + // Create the main context establishment hooks property as a fixedCollection + const contextHooksProperty: INodeProperties = { + displayName: 'Context Establishment Hooks', + name: 'contextEstablishmentHooks', + type: 'fixedCollection', + placeholder: 'Add Hook', + default: {}, + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'hooks', + displayName: 'Hooks', + values: allHookValues, }, - options: [ - { - name: 'hooks', - displayName: 'Hooks', - values: allHookValues, - }, - ], - description: - 'Add and configure context establishment hooks to extract data from trigger items. Learn more', - }; + ], + description: + 'Add and configure context establishment hooks to extract data from trigger items. Learn more', + }; - // Create a notice that always appears after the hooks collection - const contextHooksNotice: INodeProperties = { - displayName: - 'Context establishment hooks allow you to extract data from trigger items to use in subsequent nodes. Learn more', - name: 'contextHooksNotice', - type: 'notice', - default: '', - }; + // Create a notice that always appears after the hooks collection + const contextHooksNotice: INodeProperties = { + displayName: + 'Context establishment hooks allow you to extract data from trigger items to use in subsequent nodes. Learn more', + name: 'contextHooksNotice', + type: 'notice', + default: '', + }; - node.properties.push(executionsHooksVersion); - node.properties.push(contextHooksProperty); - node.properties.push(contextHooksNotice); - }); - } + node.properties.push(executionsHooksVersion); + node.properties.push(contextHooksProperty); + node.properties.push(contextHooksNotice); + }; /** * Run a loader of source files of nodes and credentials in a directory. @@ -560,7 +569,14 @@ export class LoadNodesAndCredentials { if (!loader) { throw new UnrecognizedNodeTypeError(packageName, nodeType); } - return loader.getNode(nodeType); + const loadedNode = loader.getNode(nodeType); + if ( + this.shouldInjectContextEstablishmentHooks() && + 'properties' in loadedNode.type.description + ) { + this.augmentNodeTypeDescription(loadedNode.type.description); + } + return loadedNode; } getCredential(credentialType: string): LoadedClass { diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/http-header-extractor.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/http-header-extractor.test.ts new file mode 100644 index 00000000000..2ab03b5bd07 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/http-header-extractor.test.ts @@ -0,0 +1,367 @@ +import type { Logger } from '@n8n/backend-common'; +import type { ContextEstablishmentOptions } from '@n8n/decorators'; +import type { INodeExecutionData } from 'n8n-workflow'; + +import { HttpHeaderExtractor } from '../http-header-extractor'; + +describe('HttpHeaderExtractor', () => { + let extractor: HttpHeaderExtractor; + let mockLogger: jest.Mocked; + + // Factory functions for test data + const createTriggerItem = (headers?: Record): INodeExecutionData => ({ + json: { headers }, + pairedItem: { item: 0 }, + }); + + const createOptions = ( + overrides?: Partial, + ): ContextEstablishmentOptions => + ({ + triggerItems: [createTriggerItem({ authorization: 'Bearer test-token-123' })], + options: {}, + ...overrides, + }) as ContextEstablishmentOptions; + + beforeAll(() => { + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + extractor = new HttpHeaderExtractor(mockLogger); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isApplicableToTriggerNode', () => { + it('returns true for n8n-nodes-base.webhook', () => { + expect(extractor.isApplicableToTriggerNode('n8n-nodes-base.webhook')).toBe(true); + }); + + it('returns true for shorthand webhook type', () => { + expect(extractor.isApplicableToTriggerNode('webhook')).toBe(true); + }); + + it.each([ + 'n8n-nodes-base.httpRequest', + 'n8n-nodes-base.cron', + '', + 'WEBHOOK', + 'n8n-nodes-base.Webhook', + ])('returns false for "%s"', (nodeType) => { + expect(extractor.isApplicableToTriggerNode(nodeType)).toBe(false); + }); + }); + + describe('execute', () => { + describe('input validation', () => { + it('returns empty when triggerItems is undefined', async () => { + const result = await extractor.execute(createOptions({ triggerItems: undefined })); + + expect(result).toEqual({}); + }); + + it('returns empty when triggerItems is empty', async () => { + const result = await extractor.execute(createOptions({ triggerItems: [] })); + + expect(result).toEqual({}); + }); + + it('returns empty when options validation fails', async () => { + const result = await extractor.execute( + createOptions({ + options: { + headerName: 123, // Invalid: should be string + }, + }), + ); + + expect(result).toEqual({}); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Invalid options for HttpHeaderExtractor hook.', + expect.objectContaining({ error: expect.anything() }), + ); + }); + }); + + describe('pattern safety', () => { + it.each([ + ['(a+)+', 'nested quantifier'], + ['(a*)+', 'nested quantifier variant'], + ['(a+)*', 'nested quantifier variant 2'], + ['(a|a)+', 'overlapping alternation'], + ['(foo|foo)*', 'overlapping alternation with words'], + ])('rejects unsafe pattern "%s" (%s)', async (pattern) => { + const result = await extractor.execute( + createOptions({ + options: { headerValue: pattern }, + }), + ); + + expect(result).toEqual({}); + expect(mockLogger.warn).toHaveBeenCalledWith('Potentially unsafe regex pattern rejected', { + pattern, + }); + }); + + it.each([ + 'Bearer (.+)', + '[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)', + '^token-(.*)$', + 'api_key=([^&]+)', + ])('accepts safe pattern "%s"', async (pattern) => { + await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer abc123' })], + options: { headerValue: pattern }, + }), + ); + + // Should not warn about unsafe pattern + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('regex compilation', () => { + it('returns triggerItems with masked header for invalid regex syntax', async () => { + const result = await extractor.execute( + createOptions({ + options: { headerValue: '[invalid' }, + }), + ); + + expect(result.triggerItems).toBeDefined(); + expect(result.triggerItems?.[0].json.headers).toEqual({ + authorization: '**********', + }); + expect(result.contextUpdate).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Invalid regex pattern', + expect.objectContaining({ + pattern: '[invalid', + error: expect.anything(), + }), + ); + }); + }); + + describe('header extraction', () => { + it('returns empty when headers is missing', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [{ json: {}, pairedItem: { item: 0 } }], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when headers is undefined', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem(undefined)], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when headers is not an object', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [{ json: { headers: 'not-an-object' }, pairedItem: { item: 0 } }], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when headers is an array', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [{ json: { headers: ['item1', 'item2'] }, pairedItem: { item: 0 } }], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when target header is not found', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ 'x-custom-header': 'value' })], + options: { headerName: 'authorization' }, + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when header value is not a string', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 12345 })], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns triggerItems with masked header when pattern does not match', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Basic dXNlcjpwYXNz' })], + options: { headerValue: 'Bearer (.+)' }, + }), + ); + + expect(result.triggerItems).toBeDefined(); + expect(result.triggerItems?.[0].json.headers).toEqual({ + authorization: '**********', + }); + expect(result.contextUpdate).toBeUndefined(); + }); + + it('returns triggerItems with masked header when pattern has no capture group', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer token123' })], + options: { headerValue: 'Bearer .+' }, // No capture group + }), + ); + + expect(result.triggerItems).toBeDefined(); + expect(result.triggerItems?.[0].json.headers).toEqual({ + authorization: '**********', + }); + expect(result.contextUpdate).toBeUndefined(); + }); + }); + + describe('successful extraction', () => { + it('extracts Bearer token with default pattern', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer my-jwt-token' })], + }), + ); + + expect(result.contextUpdate).toEqual({ + credentials: { + version: 1, + identity: 'my-jwt-token', + metadata: { source: 'http-header', headerName: 'authorization' }, + }, + }); + expect(result.triggerItems).toBeDefined(); + }); + + it('handles case-insensitive Bearer with default pattern', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'BEARER uppercase-token' })], + }), + ); + + expect(result.contextUpdate).toEqual({ + credentials: { + version: 1, + identity: 'uppercase-token', + metadata: { source: 'http-header', headerName: 'authorization' }, + }, + }); + expect(result.triggerItems).toBeDefined(); + }); + + it('extracts from custom header with custom pattern', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ 'x-api-key': 'key_abc123xyz' })], + options: { + headerName: 'x-api-key', + headerValue: 'key_(.+)', + }, + }), + ); + + expect(result.contextUpdate).toEqual({ + credentials: { + version: 1, + identity: 'abc123xyz', + metadata: { source: 'http-header', headerName: 'x-api-key' }, + }, + }); + expect(result.triggerItems).toBeDefined(); + }); + + it('normalizes header name to lowercase', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer token123' })], + options: { headerName: 'AUTHORIZATION' }, + }), + ); + + expect(result.contextUpdate).toEqual({ + credentials: { + version: 1, + identity: 'token123', + metadata: { source: 'http-header', headerName: 'authorization' }, + }, + }); + expect(result.triggerItems).toBeDefined(); + }); + + it('masks the extracted header value in returned triggerItems', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer secret-token' })], + }), + ); + + expect(result.triggerItems?.[0].json.headers).toEqual({ + authorization: '**********', + }); + }); + + it('truncates long header values before matching', async () => { + // Create a header value longer than MAX_HEADER_LENGTH (8192) + const longToken = 'x'.repeat(10000); + const headerValue = `Bearer ${longToken}`; + + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: headerValue })], + options: { headerValue: 'Bearer (.+)' }, + }), + ); + + // Should match but token should be truncated + expect(result.contextUpdate?.credentials?.identity).toBeDefined(); + // The extracted value should be less than original due to truncation + // 8192 - 7 (length of "Bearer ") = 8185 max for token + expect((result.contextUpdate?.credentials?.identity as string).length).toBeLessThanOrEqual( + 8185, + ); + }); + + it('uses first trigger item when multiple provided', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [ + createTriggerItem({ authorization: 'Bearer first-token' }), + createTriggerItem({ authorization: 'Bearer second-token' }), + ], + }), + ); + + expect(result.contextUpdate?.credentials?.identity).toBe('first-token'); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/http-header-extractor.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/http-header-extractor.ts new file mode 100644 index 00000000000..514847f8171 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/http-header-extractor.ts @@ -0,0 +1,171 @@ +import { Logger } from '@n8n/backend-common'; +import { + ContextEstablishmentHook, + ContextEstablishmentOptions, + ContextEstablishmentResult, + HookDescription, + IContextEstablishmentHook, +} from '@n8n/decorators'; +import { createContext, Script } from 'node:vm'; +import { z } from 'zod'; + +const HttpHeaderExtractorOptionsSchema = z.object({ + headerName: z.string().default('authorization'), + headerValue: z.string().default('[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)'), +}); + +const MAX_HEADER_LENGTH = 8192; // 8KB - reasonable limit for auth headers +const REGEX_TIMEOUT_MS = 100; // 100ms timeout for regex execution + +/** + * Detects potentially dangerous regex patterns that could cause ReDoS. + * This is a heuristic check - it catches common dangerous patterns but is not comprehensive. + */ +function isUnsafeRegexPattern(pattern: string): boolean { + // Detect nested quantifiers: (a+)+, (a*)+, (a+)*, etc. + const nestedQuantifier = /([+*?{]|\{\d+,?\d*\})\s*[)]\s*[+*?{]/; + // Detect overlapping alternation with quantifier: (a|a)+ + const overlappingAlt = /\([^)]*\|[^)]*\)[+*]/; + return nestedQuantifier.test(pattern) || overlappingAlt.test(pattern); +} + +function isHeaderObject(obj: unknown): obj is Record { + return obj !== null && obj !== undefined && typeof obj === 'object' && !Array.isArray(obj); +} + +// Reusable VM context and pre-compiled script for safe regex execution +// This avoids memory overhead of creating new contexts per call +const regexContext = createContext({ + RegExp, + pattern: '', + input: '', + result: null as RegExpExecArray | null, +}); +const regexScript = new Script('result = new RegExp(pattern).exec(input)'); + +/** + * Executes a regex with a timeout to prevent ReDoS attacks. + * Uses a reusable VM context to minimize memory overhead. + * + * @returns The match result, or null if no match or timeout occurred + * @throws Error if the pattern is invalid + */ +function safeRegexExec( + pattern: string, + input: string, + timeoutMs = REGEX_TIMEOUT_MS, +): RegExpExecArray | null { + regexContext.pattern = pattern; + regexContext.input = input; + regexContext.result = null; + + try { + regexScript.runInContext(regexContext, { timeout: timeoutMs }); + return regexContext.result as RegExpExecArray | null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') { + return null; + } + throw error; + } +} + +@ContextEstablishmentHook() +export class HttpHeaderExtractor implements IContextEstablishmentHook { + constructor(private readonly logger: Logger) {} + + hookDescription: HookDescription = { + name: 'HttpHeaderExtractor', + displayName: 'HTTP Header Extractor', + options: [ + { + name: 'headerName', + displayName: 'Header Name', + type: 'string', + default: 'authorization', + description: 'The name of the HTTP header to extract the value from.', + }, + { + name: 'headerValue', + displayName: 'Header Value Pattern', + type: 'string', + default: '[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)', + description: + 'A regular expression pattern to extract the identity from the header value. Use a capturing group to specify the identity part.', + }, + ], + }; + + isApplicableToTriggerNode(nodeType: string): boolean { + return nodeType === 'n8n-nodes-base.webhook' || nodeType === 'webhook'; + } + + async execute(options: ContextEstablishmentOptions): Promise { + if (!options.triggerItems || options.triggerItems.length === 0) { + this.logger.debug('No trigger items found, skipping HttpHeaderExtractor hook.'); + return {}; + } + + const httpHeaderOptions = await HttpHeaderExtractorOptionsSchema.safeParseAsync( + options.options ?? {}, + ); + + if (httpHeaderOptions.error) { + this.logger.error('Invalid options for HttpHeaderExtractor hook.', { + error: httpHeaderOptions.error, + }); + return {}; + } + + const normalizedHeaderName = httpHeaderOptions.data.headerName.toLowerCase(); + const pattern = httpHeaderOptions.data.headerValue; + + // Validate pattern safety to prevent ReDoS (defense in depth) + if (isUnsafeRegexPattern(pattern)) { + this.logger.warn('Potentially unsafe regex pattern rejected', { pattern }); + return {}; + } + + const [triggerItem] = options.triggerItems; + const headers = triggerItem.json['headers']; + + if (isHeaderObject(headers) && normalizedHeaderName in headers) { + const headerValue = headers[normalizedHeaderName]; + + if (typeof headerValue === 'string') { + headers[normalizedHeaderName] = '**********'; // Mask the header value in the trigger item + + // Limit input length to mitigate ReDoS on long inputs + const truncatedValue = headerValue.slice(0, MAX_HEADER_LENGTH); + + try { + const match = safeRegexExec(pattern, truncatedValue); + + if (match?.[1]) { + return { + triggerItems: options.triggerItems, + contextUpdate: { + credentials: { + version: 1, + identity: match[1], + metadata: { source: 'http-header', headerName: normalizedHeaderName }, + }, + }, + }; + } else { + return { + triggerItems: options.triggerItems, + }; + } + } catch (error) { + this.logger.error('Invalid regex pattern', { pattern, error }); + return { + triggerItems: options.triggerItems, + }; + } + } + } + + return {}; + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/index.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/index.ts new file mode 100644 index 00000000000..9988fb21e4b --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/index.ts @@ -0,0 +1 @@ +import './http-header-extractor'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts new file mode 100644 index 00000000000..e22fae642ac --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts @@ -0,0 +1,12 @@ +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule, OnShutdown } from '@n8n/decorators'; + +@BackendModule({ name: 'dynamic-credentials', licenseFlag: 'feat:externalSecrets' }) +export class DynamicCredentialsModule implements ModuleInterface { + async init() { + await import('./context-establishment-hooks'); + } + + @OnShutdown() + async shutdown() {} +} diff --git a/packages/core/src/execution-engine/__tests__/execution-context.service.test.ts b/packages/core/src/execution-engine/__tests__/execution-context.service.test.ts index 16d7725f2e8..69f0643d70d 100644 --- a/packages/core/src/execution-engine/__tests__/execution-context.service.test.ts +++ b/packages/core/src/execution-engine/__tests__/execution-context.service.test.ts @@ -297,8 +297,7 @@ describe('ExecutionContextService', () => { }); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockImplementation((name: string) => { @@ -362,8 +361,7 @@ describe('ExecutionContextService', () => { }); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockReturnValue(mockHook); mockCipher.decrypt.mockReturnValue('{}'); @@ -398,8 +396,7 @@ describe('ExecutionContextService', () => { mockHook2.execute.mockResolvedValue({ triggerItems: item3 }); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockImplementation((name: string) => { @@ -433,8 +430,7 @@ describe('ExecutionContextService', () => { const startItem = createMockStartItem(hookConfig); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockReturnValue(undefined); mockCipher.decrypt.mockReturnValue('{}'); @@ -472,8 +468,7 @@ describe('ExecutionContextService', () => { }); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockImplementation((name: string) => { @@ -514,8 +509,7 @@ describe('ExecutionContextService', () => { mockHook.execute.mockRejectedValue(hookError); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockReturnValue(mockHook); mockCipher.decrypt.mockReturnValue('{}'); @@ -551,8 +545,7 @@ describe('ExecutionContextService', () => { mockHook.execute.mockResolvedValue({}); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockReturnValue(mockHook); mockCipher.decrypt.mockReturnValue('{"version":1,"identity":"decrypted"}'); diff --git a/packages/core/src/execution-engine/execution-context-hook-registry.service.ts b/packages/core/src/execution-engine/execution-context-hook-registry.service.ts index d79f2e74d9e..89901f9bb9a 100644 --- a/packages/core/src/execution-engine/execution-context-hook-registry.service.ts +++ b/packages/core/src/execution-engine/execution-context-hook-registry.service.ts @@ -97,8 +97,8 @@ export class ExecutionContextHookRegistry { * @returns Array of applicable hooks (may be empty) */ getHookForTriggerType(triggerType: string): IContextEstablishmentHook[] { - return Array.from(this.hookMap.values()).filter((hook) => - hook.isApplicableToTriggerNode(triggerType), - ); + return Array.from(this.hookMap.values()).filter((hook) => { + return hook.isApplicableToTriggerNode(triggerType); + }); } } diff --git a/packages/core/src/execution-engine/execution-context.service.ts b/packages/core/src/execution-engine/execution-context.service.ts index 798460eeab2..b8f9370c992 100644 --- a/packages/core/src/execution-engine/execution-context.service.ts +++ b/packages/core/src/execution-engine/execution-context.service.ts @@ -68,7 +68,10 @@ export class ExecutionContextService { let currentTriggerItems = startItem.data['main'][0]; - const contextEstablishmentHookParameters = startItem.node.parameters?.contextEstablishmentHooks; + const contextEstablishmentHookParameters = { + ...(workflow.getNode(startItem.node.name)?.parameters ?? {}), + ...startItem.node.parameters, + }; const startNodeParametersResult = toExecutionContextEstablishmentHookParameter( contextEstablishmentHookParameters, @@ -97,7 +100,7 @@ export class ExecutionContextService { // based on startNodeParameters, startNodeType and currentTriggerItems we can now // iterate over the different hooks to extract specific data for the runtime context - for (const hookParameters of startNodeParameters.hooks) { + for (const hookParameters of startNodeParameters.contextEstablishmentHooks.hooks) { const hook = this.executionContextHookRegistry.getHookByName(hookParameters.hookName); if (!hook) { diff --git a/packages/core/src/execution-engine/execution-context.ts b/packages/core/src/execution-engine/execution-context.ts index 1af786c74b4..b312af8e1fc 100644 --- a/packages/core/src/execution-engine/execution-context.ts +++ b/packages/core/src/execution-engine/execution-context.ts @@ -1,3 +1,4 @@ +import { Logger } from '@n8n/backend-common'; import { Container } from '@n8n/di'; import { type IWorkflowExecuteAdditionalData, @@ -175,16 +176,22 @@ export const establishExecutionContext = async ( // Call the execution context service to augment the context with any hook-based data const executionContextService = Container.get(ExecutionContextService); - const { context, triggerItems } = await executionContextService.augmentExecutionContextWithHooks( - workflow, - startItem, - executionData.runtimeData, - ); + try { + const { context, triggerItems } = + await executionContextService.augmentExecutionContextWithHooks( + workflow, + startItem, + executionData.runtimeData, + ); - executionData.runtimeData = context; + executionData.runtimeData = context; - // If the trigger items were modified by hooks, update the start item accordingly - if (triggerItems) { - startItem.data['main'][0] = triggerItems; + // If the trigger items were modified by hooks, update the start item accordingly + if (triggerItems) { + startItem.data['main'][0] = triggerItems; + } + } catch (error) { + // Log the error but proceed with the established context + Container.get(Logger).error('Failed to augment execution context with hooks.', { error }); } }; diff --git a/packages/workflow/src/execution-context-establishment-hooks.ts b/packages/workflow/src/execution-context-establishment-hooks.ts index 9bac107004c..c19175b4dd0 100644 --- a/packages/workflow/src/execution-context-establishment-hooks.ts +++ b/packages/workflow/src/execution-context-establishment-hooks.ts @@ -2,14 +2,16 @@ import z from 'zod/v4'; const ExecutionContextEstablishmentHookParameterSchemaV1 = z.object({ executionsHooksVersion: z.literal(1), - hooks: z.array( - z - .object({ - hookName: z.string(), - isAllowedToFail: z.boolean().optional().default(false), - }) - .loose(), - ), + contextEstablishmentHooks: z.object({ + hooks: z.array( + z + .object({ + hookName: z.string(), + isAllowedToFail: z.boolean().optional().default(false), + }) + .loose(), + ), + }), }); export type ExecutionContextEstablishmentHookParameterV1 = z.output< @@ -17,7 +19,7 @@ export type ExecutionContextEstablishmentHookParameterV1 = z.output< >; export const ExecutionContextEstablishmentHookParameterSchema = z - .discriminatedUnion('contextEstablishmentHooks.executionsHooksVersion', [ + .discriminatedUnion('executionsHooksVersion', [ ExecutionContextEstablishmentHookParameterSchemaV1, ]) .meta({ @@ -37,5 +39,9 @@ export const toExecutionContextEstablishmentHookParameter = (value: unknown) => if (value === null || value === undefined || typeof value !== 'object') { return null; } + // Quick check to avoid unnecessary parsing attempts + if (!('executionsHooksVersion' in value)) { + return null; + } return ExecutionContextEstablishmentHookParameterSchema.safeParse(value); };