mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat: Prevent webhook url takeover (#14783)
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
DELETE_FOLDER_MODAL_KEY,
|
||||
MOVE_FOLDER_MODAL_KEY,
|
||||
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from '@/components/AboutModal.vue';
|
||||
@@ -71,6 +72,7 @@ import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceM
|
||||
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
||||
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
</script>
|
||||
|
||||
@@ -294,5 +296,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
||||
<MoveToFolderModal :modal-name="modalName" :active-id="activeId" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<WorkflowActivationConflictingWebhookModal :data="data" :modal-name="modalName" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</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 { useI18n } from '@/composables/useI18n';
|
||||
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 { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import type { INodeUi, IUsedCredential } from '@/Interface';
|
||||
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<{
|
||||
workflowActive: boolean;
|
||||
@@ -26,6 +34,11 @@ const emit = defineEmits<{
|
||||
const { showMessage } = useToast();
|
||||
const workflowActivate = useWorkflowActivate();
|
||||
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const router = useRouter();
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
|
||||
const i18n = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
@@ -47,9 +60,12 @@ const isCurrentWorkflow = computed((): boolean => {
|
||||
return workflowsStore.workflowId === props.workflowId;
|
||||
});
|
||||
|
||||
const foundTriggers = computed(() =>
|
||||
getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes),
|
||||
);
|
||||
|
||||
const containsTrigger = computed((): boolean => {
|
||||
const foundTriggers = getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes);
|
||||
return foundTriggers.length > 0;
|
||||
return foundTriggers.value.length > 0;
|
||||
});
|
||||
|
||||
const containsOnlyExecuteWorkflowTrigger = computed((): boolean => {
|
||||
@@ -114,10 +130,31 @@ const shouldShowFreeAiCreditsWarning = computed((): 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(
|
||||
props.workflowId,
|
||||
newActiveState,
|
||||
);
|
||||
|
||||
emit('update:workflowActive', { id: props.workflowId, active: newState });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user