mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-21 11:49:59 +00:00
feat: Prevent webhook url takeover (#14783)
This commit is contained in:
@@ -69,6 +69,7 @@ import '@/evaluation.ee/test-definitions.controller.ee';
|
|||||||
import '@/evaluation.ee/test-runs.controller.ee';
|
import '@/evaluation.ee/test-runs.controller.ee';
|
||||||
import '@/workflows/workflow-history.ee/workflow-history.controller.ee';
|
import '@/workflows/workflow-history.ee/workflow-history.controller.ee';
|
||||||
import '@/workflows/workflows.controller';
|
import '@/workflows/workflows.controller';
|
||||||
|
import '@/webhooks/webhooks.controller';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class Server extends AbstractServer {
|
export class Server extends AbstractServer {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { WebhookRepository } from '@/databases/repositories/webhook.repository';
|
|||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
import { CacheService } from '@/services/cache/cache.service';
|
import { CacheService } from '@/services/cache/cache.service';
|
||||||
|
|
||||||
type Method = NonNullable<IHttpRequestMethods>;
|
import type { Method } from './webhook.types';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class WebhookService {
|
export class WebhookService {
|
||||||
|
|||||||
@@ -35,3 +35,5 @@ export interface IWebhookResponseCallbackData {
|
|||||||
noWebhookResponse?: boolean;
|
noWebhookResponse?: boolean;
|
||||||
responseCode?: number;
|
responseCode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Method = NonNullable<IHttpRequestMethods>;
|
||||||
|
|||||||
23
packages/cli/src/webhooks/webhooks.controller.ts
Normal file
23
packages/cli/src/webhooks/webhooks.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Post, RestController } from '@n8n/decorators';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import get from 'lodash/get';
|
||||||
|
|
||||||
|
import { WebhookService } from './webhook.service';
|
||||||
|
import type { Method } from './webhook.types';
|
||||||
|
|
||||||
|
@RestController('/webhooks')
|
||||||
|
export class WebhooksController {
|
||||||
|
constructor(private readonly webhookService: WebhookService) {}
|
||||||
|
|
||||||
|
@Post('/find')
|
||||||
|
async findWebhook(req: Request) {
|
||||||
|
const body = get(req, 'body', {}) as { path: string; method: Method };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webhook = await this.webhookService.findWebhook(body.method, body.path);
|
||||||
|
return webhook;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/frontend/editor-ui/src/api/webhooks.ts
Normal file
17
packages/frontend/editor-ui/src/api/webhooks.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
|
import type { IRestApiContext } from '@/Interface';
|
||||||
|
import type { IHttpRequestMethods } from 'n8n-workflow';
|
||||||
|
|
||||||
|
type WebhookData = {
|
||||||
|
workflowId: string;
|
||||||
|
webhookPath: string;
|
||||||
|
method: IHttpRequestMethods;
|
||||||
|
node: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findWebhook = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
data: { path: string; method: string },
|
||||||
|
): Promise<WebhookData | null> => {
|
||||||
|
return await makeRestApiRequest(context, 'POST', '/webhooks/find', data);
|
||||||
|
};
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
DELETE_FOLDER_MODAL_KEY,
|
DELETE_FOLDER_MODAL_KEY,
|
||||||
MOVE_FOLDER_MODAL_KEY,
|
MOVE_FOLDER_MODAL_KEY,
|
||||||
|
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from '@/components/AboutModal.vue';
|
import AboutModal from '@/components/AboutModal.vue';
|
||||||
@@ -71,6 +72,7 @@ import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceM
|
|||||||
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
||||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||||
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
||||||
|
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
|
||||||
import type { EventBus } from '@n8n/utils/event-bus';
|
import type { EventBus } from '@n8n/utils/event-bus';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -294,5 +296,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
|||||||
<MoveToFolderModal :modal-name="modalName" :active-id="activeId" :data="data" />
|
<MoveToFolderModal :modal-name="modalName" :active-id="activeId" :data="data" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY">
|
||||||
|
<template #default="{ modalName, data }">
|
||||||
|
<WorkflowActivationConflictingWebhookModal :data="data" :modal-name="modalName" />
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
|
||||||
|
import { WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY } from '@/constants';
|
||||||
|
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
|
||||||
|
vi.mock('@/stores/ui.store', () => {
|
||||||
|
return {
|
||||||
|
useUIStore: vi.fn(() => ({
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('@/stores/root.store', () => {
|
||||||
|
return {
|
||||||
|
useRootStore: vi.fn(() => ({
|
||||||
|
webhookUrl: 'http://webhook-base',
|
||||||
|
urlBaseEditor: 'http://editor-base',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(WorkflowActivationConflictingWebhookModal, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Modal: {
|
||||||
|
template:
|
||||||
|
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WorkflowActivationConflictingWebhookModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createTestingPinia();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render modal', async () => {
|
||||||
|
const props = {
|
||||||
|
modalName: WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
triggerName: 'Trigger in this workflow',
|
||||||
|
workflowName: 'Test Workflow',
|
||||||
|
workflowId: '123',
|
||||||
|
webhookPath: 'webhook-path',
|
||||||
|
method: 'GET',
|
||||||
|
node: 'Node in workflow',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = renderComponent({ props });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(wrapper.queryByTestId('conflicting-webhook-callout')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.getByTestId('conflicting-webhook-callout')).toHaveTextContent(
|
||||||
|
"A webhook trigger 'Node in workflow' in the workflow 'Test Workflow' uses a conflicting URL path, so this workflow cannot be activated",
|
||||||
|
);
|
||||||
|
expect(wrapper.getByTestId('conflicting-webhook-path')).toHaveTextContent(
|
||||||
|
'http://webhook-base/webhook-path',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
|
import Modal from '@/components/Modal.vue';
|
||||||
|
import { WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY } from '@/constants';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const modalBus = createEventBus();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: {
|
||||||
|
workflowName: string;
|
||||||
|
workflowId: string;
|
||||||
|
webhookPath: string;
|
||||||
|
node: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { data } = props;
|
||||||
|
|
||||||
|
const webhookUrl = computed(() => {
|
||||||
|
return rootStore.webhookUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowUrl = computed(() => {
|
||||||
|
return rootStore.urlBaseEditor + 'workflow/' + data.workflowId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
uiStore.closeModal(WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
width="540px"
|
||||||
|
:name="WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY"
|
||||||
|
title="Conflicting Webhook Path"
|
||||||
|
:event-bus="modalBus"
|
||||||
|
:center="true"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<n8n-callout theme="danger" data-test-id="conflicting-webhook-callout">
|
||||||
|
A webhook trigger '{{ data.node }}' in the workflow '{{ data.workflowName }}' uses a
|
||||||
|
conflicting URL path, so this workflow cannot be activated
|
||||||
|
</n8n-callout>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<div>
|
||||||
|
<n8n-text color="text-base"> You can deactivate </n8n-text>
|
||||||
|
<n8n-link :to="workflowUrl" underline="true"> '{{ data.workflowName }}' </n8n-link>
|
||||||
|
<n8n-text color="text-base">
|
||||||
|
and activate this one, or adjust the following URL path in either workflow:
|
||||||
|
</n8n-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-test-id="conflicting-webhook-path">
|
||||||
|
<n8n-text color="text-light"> {{ webhookUrl }}/</n8n-text>
|
||||||
|
<n8n-text color="text-dark" bold>
|
||||||
|
{{ data.webhookPath }}
|
||||||
|
</n8n-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<n8n-button
|
||||||
|
label="Done"
|
||||||
|
size="medium"
|
||||||
|
float="right"
|
||||||
|
data-test-id="close-button"
|
||||||
|
@click="onClick"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.container {
|
||||||
|
margin-top: var(--spacing-m);
|
||||||
|
margin-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,11 +7,19 @@ import type { VNode } from 'vue';
|
|||||||
import { computed, h, watch } from 'vue';
|
import { computed, h, watch } from 'vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import type { PermissionsRecord } from '@/permissions';
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
import {
|
||||||
|
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||||
|
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||||
|
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||||
|
} from '@/constants';
|
||||||
import WorkflowActivationErrorMessage from './WorkflowActivationErrorMessage.vue';
|
import WorkflowActivationErrorMessage from './WorkflowActivationErrorMessage.vue';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import type { INodeUi, IUsedCredential } from '@/Interface';
|
import type { INodeUi, IUsedCredential } from '@/Interface';
|
||||||
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
workflowActive: boolean;
|
workflowActive: boolean;
|
||||||
@@ -26,6 +34,11 @@ const emit = defineEmits<{
|
|||||||
const { showMessage } = useToast();
|
const { showMessage } = useToast();
|
||||||
const workflowActivate = useWorkflowActivate();
|
const workflowActivate = useWorkflowActivate();
|
||||||
|
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
@@ -47,9 +60,12 @@ const isCurrentWorkflow = computed((): boolean => {
|
|||||||
return workflowsStore.workflowId === props.workflowId;
|
return workflowsStore.workflowId === props.workflowId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const foundTriggers = computed(() =>
|
||||||
|
getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes),
|
||||||
|
);
|
||||||
|
|
||||||
const containsTrigger = computed((): boolean => {
|
const containsTrigger = computed((): boolean => {
|
||||||
const foundTriggers = getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes);
|
return foundTriggers.value.length > 0;
|
||||||
return foundTriggers.length > 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const containsOnlyExecuteWorkflowTrigger = computed((): boolean => {
|
const containsOnlyExecuteWorkflowTrigger = computed((): boolean => {
|
||||||
@@ -114,10 +130,31 @@ const shouldShowFreeAiCreditsWarning = computed((): boolean => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function activeChanged(newActiveState: boolean) {
|
async function activeChanged(newActiveState: boolean) {
|
||||||
|
if (!isWorkflowActive.value) {
|
||||||
|
const conflictData = await workflowHelpers.checkConflictingWebhooks(props.workflowId);
|
||||||
|
|
||||||
|
if (conflictData) {
|
||||||
|
const { trigger, conflict } = conflictData;
|
||||||
|
const conflictingWorkflow = await workflowsStore.fetchWorkflow(conflict.workflowId);
|
||||||
|
|
||||||
|
uiStore.openModalWithData({
|
||||||
|
name: WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
triggerName: trigger.name,
|
||||||
|
workflowName: conflictingWorkflow.name,
|
||||||
|
...conflict,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newState = await workflowActivate.updateWorkflowActivation(
|
const newState = await workflowActivate.updateWorkflowActivation(
|
||||||
props.workflowId,
|
props.workflowId,
|
||||||
newActiveState,
|
newActiveState,
|
||||||
);
|
);
|
||||||
|
|
||||||
emit('update:workflowActive', { id: props.workflowId, active: newState });
|
emit('update:workflowActive', { id: props.workflowId, active: newState });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IWorkflowDataUpdate } from '@/Interface';
|
import type { IWorkflowData, IWorkflowDataUpdate, IWorkflowDb } from '@/Interface';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
@@ -6,8 +6,10 @@ import { setActivePinia } from 'pinia';
|
|||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
|
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
|
||||||
import { useTagsStore } from '@/stores/tags.store';
|
import { useTagsStore } from '@/stores/tags.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { createTestWorkflow } from '@/__tests__/mocks';
|
import { createTestWorkflow } from '@/__tests__/mocks';
|
||||||
import type { AssignmentCollectionValue } from 'n8n-workflow';
|
import { WEBHOOK_NODE_TYPE, type AssignmentCollectionValue } from 'n8n-workflow';
|
||||||
|
import * as apiWebhooks from '../api/webhooks';
|
||||||
|
|
||||||
const getDuplicateTestWorkflow = (): IWorkflowDataUpdate => ({
|
const getDuplicateTestWorkflow = (): IWorkflowDataUpdate => ({
|
||||||
name: 'Duplicate webhook test',
|
name: 'Duplicate webhook test',
|
||||||
@@ -59,12 +61,14 @@ describe('useWorkflowHelpers', () => {
|
|||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
let workflowsEEStore: ReturnType<typeof useWorkflowsEEStore>;
|
let workflowsEEStore: ReturnType<typeof useWorkflowsEEStore>;
|
||||||
let tagsStore: ReturnType<typeof useTagsStore>;
|
let tagsStore: ReturnType<typeof useTagsStore>;
|
||||||
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
setActivePinia(createTestingPinia());
|
setActivePinia(createTestingPinia());
|
||||||
workflowsStore = useWorkflowsStore();
|
workflowsStore = useWorkflowsStore();
|
||||||
workflowsEEStore = useWorkflowsEEStore();
|
workflowsEEStore = useWorkflowsEEStore();
|
||||||
tagsStore = useTagsStore();
|
tagsStore = useTagsStore();
|
||||||
|
uiStore = useUIStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -370,4 +374,84 @@ describe('useWorkflowHelpers', () => {
|
|||||||
expect(upsertTagsSpy).toHaveBeenCalledWith([]);
|
expect(upsertTagsSpy).toHaveBeenCalledWith([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('checkConflictingWebhooks', () => {
|
||||||
|
it('should return null if no conflicts', async () => {
|
||||||
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
|
uiStore.stateIsDirty = false;
|
||||||
|
vi.spyOn(workflowsStore, 'fetchWorkflow').mockResolvedValue({
|
||||||
|
nodes: [],
|
||||||
|
} as unknown as IWorkflowDb);
|
||||||
|
expect(await workflowHelpers.checkConflictingWebhooks('12345')).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return conflicting webhook data and workflow id is different', async () => {
|
||||||
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
|
uiStore.stateIsDirty = false;
|
||||||
|
vi.spyOn(workflowsStore, 'fetchWorkflow').mockResolvedValue({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: WEBHOOK_NODE_TYPE,
|
||||||
|
parameters: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'test-path',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as IWorkflowDb);
|
||||||
|
vi.spyOn(apiWebhooks, 'findWebhook').mockResolvedValue({
|
||||||
|
method: 'GET',
|
||||||
|
webhookPath: 'test-path',
|
||||||
|
node: 'Webhook 1',
|
||||||
|
workflowId: '456',
|
||||||
|
});
|
||||||
|
expect(await workflowHelpers.checkConflictingWebhooks('123')).toEqual({
|
||||||
|
conflict: {
|
||||||
|
method: 'GET',
|
||||||
|
node: 'Webhook 1',
|
||||||
|
webhookPath: 'test-path',
|
||||||
|
workflowId: '456',
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
parameters: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'test-path',
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if webhook already exist but workflow id is the same', async () => {
|
||||||
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
|
uiStore.stateIsDirty = false;
|
||||||
|
vi.spyOn(workflowsStore, 'fetchWorkflow').mockResolvedValue({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: WEBHOOK_NODE_TYPE,
|
||||||
|
parameters: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'test-path',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as IWorkflowDb);
|
||||||
|
vi.spyOn(apiWebhooks, 'findWebhook').mockResolvedValue({
|
||||||
|
method: 'GET',
|
||||||
|
webhookPath: 'test-path',
|
||||||
|
node: 'Webhook 1',
|
||||||
|
workflowId: '123',
|
||||||
|
});
|
||||||
|
expect(await workflowHelpers.checkConflictingWebhooks('123')).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call getWorkflowDataToSave if state is dirty', async () => {
|
||||||
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
|
uiStore.stateIsDirty = true;
|
||||||
|
vi.spyOn(workflowHelpers, 'getWorkflowDataToSave').mockResolvedValue({
|
||||||
|
nodes: [],
|
||||||
|
} as unknown as IWorkflowData);
|
||||||
|
expect(await workflowHelpers.checkConflictingWebhooks('12345')).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ import type {
|
|||||||
NodeParameterValue,
|
NodeParameterValue,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionTypes, ExpressionEvaluatorProxy, NodeHelpers } from 'n8n-workflow';
|
import {
|
||||||
|
NodeConnectionTypes,
|
||||||
|
ExpressionEvaluatorProxy,
|
||||||
|
NodeHelpers,
|
||||||
|
WEBHOOK_NODE_TYPE,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ICredentialsResponse,
|
ICredentialsResponse,
|
||||||
@@ -71,6 +76,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
|||||||
import { useTagsStore } from '@/stores/tags.store';
|
import { useTagsStore } from '@/stores/tags.store';
|
||||||
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
|
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
|
||||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||||
|
import { findWebhook } from '../api/webhooks';
|
||||||
|
|
||||||
export type ResolveParameterOptions = {
|
export type ResolveParameterOptions = {
|
||||||
targetItem?: TargetItem;
|
targetItem?: TargetItem;
|
||||||
@@ -851,6 +857,22 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
|||||||
|
|
||||||
workflowDataRequest.versionId = workflowsStore.workflowVersionId;
|
workflowDataRequest.versionId = workflowsStore.workflowVersionId;
|
||||||
|
|
||||||
|
// workflow should not be active if there is live webhook with the same path
|
||||||
|
const conflictData = await checkConflictingWebhooks(currentWorkflow);
|
||||||
|
if (conflictData) {
|
||||||
|
workflowDataRequest.active = false;
|
||||||
|
|
||||||
|
if (workflowsStore.isWorkflowActive) {
|
||||||
|
toast.showMessage({
|
||||||
|
title: 'Conflicting Webhook Path',
|
||||||
|
message: `Workflow set to inactive: Live webhook in another workflow uses same path as node '${conflictData.trigger.name}'.`,
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowsStore.setWorkflowInactive(currentWorkflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const workflowData = await workflowsStore.updateWorkflow(
|
const workflowData = await workflowsStore.updateWorkflow(
|
||||||
currentWorkflow,
|
currentWorkflow,
|
||||||
workflowDataRequest,
|
workflowDataRequest,
|
||||||
@@ -993,6 +1015,20 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// workflow should not be active if there is live webhook with the same path
|
||||||
|
if (workflowData.active) {
|
||||||
|
const conflict = await checkConflictingWebhooks(workflowData.id);
|
||||||
|
if (conflict) {
|
||||||
|
workflowData.active = false;
|
||||||
|
|
||||||
|
toast.showMessage({
|
||||||
|
title: 'Conflicting Webhook Path',
|
||||||
|
message: `Workflow set to inactive: Live webhook in another workflow uses same path as node '${conflict.trigger.name}'.`,
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
workflowsStore.setActive(workflowData.active || false);
|
workflowsStore.setActive(workflowData.active || false);
|
||||||
workflowsStore.setWorkflowId(workflowData.id);
|
workflowsStore.setWorkflowId(workflowData.id);
|
||||||
workflowsStore.setWorkflowVersionId(workflowData.versionId);
|
workflowsStore.setWorkflowVersionId(workflowData.versionId);
|
||||||
@@ -1221,6 +1257,36 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
|||||||
return workflow.nodes.some((node) => node.type.startsWith(packageName));
|
return workflow.nodes.some((node) => node.type.startsWith(packageName));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function checkConflictingWebhooks(workflowId: string) {
|
||||||
|
let data;
|
||||||
|
if (uiStore.stateIsDirty) {
|
||||||
|
data = await getWorkflowDataToSave();
|
||||||
|
} else {
|
||||||
|
data = await workflowsStore.fetchWorkflow(workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookTriggers = data.nodes.filter(
|
||||||
|
(node) => node.disabled !== true && node.type === WEBHOOK_NODE_TYPE,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const trigger of webhookTriggers) {
|
||||||
|
const method = (trigger.parameters.method as string) ?? 'GET';
|
||||||
|
|
||||||
|
const path = trigger.parameters.path as string;
|
||||||
|
|
||||||
|
const conflict = await findWebhook(rootStore.restApiContext, {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflict && conflict.workflowId !== workflowId) {
|
||||||
|
return { trigger, conflict };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setDocumentTitle,
|
setDocumentTitle,
|
||||||
resolveParameter,
|
resolveParameter,
|
||||||
@@ -1248,5 +1314,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
|||||||
initState,
|
initState,
|
||||||
getNodeParametersWithResolvedExpressions,
|
getNodeParametersWithResolvedExpressions,
|
||||||
containsNodeFromPackage,
|
containsNodeFromPackage,
|
||||||
|
checkConflictingWebhooks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
|||||||
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
|
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
|
||||||
export const DELETE_FOLDER_MODAL_KEY = 'deleteFolder';
|
export const DELETE_FOLDER_MODAL_KEY = 'deleteFolder';
|
||||||
export const MOVE_FOLDER_MODAL_KEY = 'moveFolder';
|
export const MOVE_FOLDER_MODAL_KEY = 'moveFolder';
|
||||||
|
export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY =
|
||||||
|
'workflowActivationConflictingWebhook';
|
||||||
|
|
||||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||||
UNINSTALL: 'uninstall',
|
UNINSTALL: 'uninstall',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
||||||
DELETE_FOLDER_MODAL_KEY,
|
DELETE_FOLDER_MODAL_KEY,
|
||||||
MOVE_FOLDER_MODAL_KEY,
|
MOVE_FOLDER_MODAL_KEY,
|
||||||
|
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
INodeUi,
|
INodeUi,
|
||||||
@@ -182,6 +183,15 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
customHeading: undefined,
|
customHeading: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY]: {
|
||||||
|
open: false,
|
||||||
|
data: {
|
||||||
|
workflowName: '',
|
||||||
|
workflowId: '',
|
||||||
|
webhookPath: '',
|
||||||
|
node: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const modalStack = ref<string[]>([]);
|
const modalStack = ref<string[]>([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user