feat: Prevent webhook url takeover (#14783)

This commit is contained in:
Michael Kret
2025-04-28 14:29:32 +03:00
committed by GitHub
parent bc6f98928e
commit be53453def
13 changed files with 407 additions and 7 deletions

View File

@@ -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>

View File

@@ -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',
);
});
});

View File

@@ -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>

View File

@@ -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 });
}