chore(core): Http Header Extractor Hook (#22404)

This commit is contained in:
Andreas Fitzek
2025-12-02 09:05:50 +01:00
committed by GitHub
parent c9e4d2b1c8
commit 624eb09b07
16 changed files with 720 additions and 133 deletions
@@ -30,6 +30,7 @@ describe('eligibleModules', () => {
'mcp',
'provisioning',
'breaking-changes',
'dynamic-credentials',
]);
});
@@ -43,6 +44,7 @@ describe('eligibleModules', () => {
'mcp',
'provisioning',
'breaking-changes',
'dynamic-credentials',
]);
});
@@ -37,6 +37,7 @@ export class ModuleRegistry {
'mcp',
'provisioning',
'breaking-changes',
'dynamic-credentials',
];
private readonly activeModules: string[] = [];
@@ -11,6 +11,7 @@ export const MODULE_NAMES = [
'chat-hub',
'provisioning',
'breaking-changes',
'dynamic-credentials',
] as const;
export type ModuleName = (typeof MODULE_NAMES)[number];
+1 -2
View File
@@ -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<F = never> {
readonly flags: F;
@@ -99,7 +99,6 @@ export abstract class BaseCommand<F = never> {
this.nodeTypes = Container.get(NodeTypes);
await this.executionContextHookRegistry.init();
await Container.get(LoadNodesAndCredentials).init();
await this.dbConnection
+4
View File
@@ -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<z.infer<typeof flagsSchema>> {
Container.get(MultiMainSetup).registerEventHandlers();
}
await this.executionContextHookRegistry.init();
await Container.get(LoadNodesAndCredentials).postProcessLoaders();
}
async initOrchestration() {
+4
View File
@@ -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<z.infer<typeof flagsSchema>> {
);
await this.moduleRegistry.initModules(this.instanceSettings.instanceType);
await this.executionContextHookRegistry.init();
await Container.get(LoadNodesAndCredentials).postProcessLoaders();
}
async initEventBus() {
+110 -94
View File
@@ -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. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/hooks/" target="_blank">Learn more</a>',
};
],
description:
'Add and configure context establishment hooks to extract data from trigger items. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/hooks/" target="_blank">Learn more</a>',
};
// 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. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/hooks/" target="_blank">Learn more</a>',
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. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/hooks/" target="_blank">Learn more</a>',
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<ICredentialType> {
@@ -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<Logger>;
// Factory functions for test data
const createTriggerItem = (headers?: Record<string, unknown>): INodeExecutionData => ({
json: { headers },
pairedItem: { item: 0 },
});
const createOptions = (
overrides?: Partial<ContextEstablishmentOptions>,
): 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<Logger>;
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');
});
});
});
});
@@ -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<string, unknown> {
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<ContextEstablishmentResult> {
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 {};
}
}
@@ -0,0 +1 @@
import './http-header-extractor';
@@ -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() {}
}
@@ -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"}');
@@ -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);
});
}
}
@@ -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) {
@@ -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 });
}
};
@@ -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);
};