mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
chore(core): Http Header Extractor Hook (#22404)
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+367
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+171
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user