diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py index 31c478f7d9..aa49b3f3e2 100644 --- a/api/core/workflow/nodes/trigger_webhook/node.py +++ b/api/core/workflow/nodes/trigger_webhook/node.py @@ -85,15 +85,30 @@ class TriggerWebhookNode(Node): # Get the raw webhook data (should be injected by Celery task) webhook_data = webhook_inputs.get("webhook_data", {}) + def _to_sanitized(name: str) -> str: + return name.replace("-", "_") + + def _get_normalized(mapping: dict[str, Any], key: str) -> Any: + if not isinstance(mapping, dict): + return None + if key in mapping: + return mapping[key] + alternate = key.replace("-", "_") if "-" in key else key.replace("_", "-") + if alternate in mapping: + return mapping[alternate] + return None + # Extract configured headers (case-insensitive) webhook_headers = webhook_data.get("headers", {}) webhook_headers_lower = {k.lower(): v for k, v in webhook_headers.items()} for header in self._node_data.headers: header_name = header.name - # Try exact match first, then case-insensitive match - value = webhook_headers.get(header_name) or webhook_headers_lower.get(header_name.lower()) - outputs[header_name] = value + value = _get_normalized(webhook_headers, header_name) + if value is None: + value = _get_normalized(webhook_headers_lower, header_name.lower()) + sanitized_name = _to_sanitized(header_name) + outputs[sanitized_name] = value # Extract configured query parameters for param in self._node_data.params: diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 4038107899..e69886f38c 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -35,6 +35,13 @@ class WebhookService: __WEBHOOK_NODE_CACHE_KEY__ = "webhook_nodes" MAX_WEBHOOK_NODES_PER_WORKFLOW = 5 # Maximum allowed webhook nodes per workflow + @staticmethod + def _sanitize_key(key: str) -> str: + """Normalize external keys (headers/params) to workflow-safe variables.""" + if not isinstance(key, str): + return key + return key.replace("-", "_") + @classmethod def get_webhook_trigger_and_workflow( cls, webhook_id: str, is_debug: bool = False @@ -590,10 +597,12 @@ class WebhookService: ValueError: If required headers are missing """ headers_lower = {k.lower(): v for k, v in headers.items()} + headers_sanitized = {cls._sanitize_key(k).lower(): v for k, v in headers.items()} for header_config in header_configs: if header_config.get("required", False): header_name = header_config.get("name", "") - if header_name.lower() not in headers_lower: + sanitized_name = cls._sanitize_key(header_name).lower() + if header_name.lower() not in headers_lower and sanitized_name not in headers_sanitized: raise ValueError(f"Required header missing: {header_name}") @classmethod diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts index 58cfe428fc..7b4bf74eaf 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -70,7 +70,12 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { if (!draft.variables) draft.variables = [] - const hasReservedConflict = newData.some(item => item.name === WEBHOOK_RAW_VARIABLE_NAME) + const sanitizedEntries = newData.map(item => ({ + item, + sanitizedName: sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name, + })) + + const hasReservedConflict = sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME) if (hasReservedConflict) { Toast.notify({ type: 'error', @@ -80,25 +85,24 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { }) return false } - const existingOtherVarNames = new Set( draft.variables .filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME) .map(v => v.variable), ) - const crossScopeConflict = newData.find(item => existingOtherVarNames.has(item.name)) + const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName)) if (crossScopeConflict) { Toast.notify({ type: 'error', message: t('appDebug.varKeyError.keyAlreadyExists', { - key: crossScopeConflict.name, + key: crossScopeConflict.sanitizedName, }), }) return false } - if(hasDuplicateStr(newData.map(item => item.name))) { + if(hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) { Toast.notify({ type: 'error', message: t('appDebug.varKeyError.keyAlreadyExists', { @@ -108,8 +112,8 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { return false } - for (const item of newData) { - const { isValid, errorMessageKey } = checkKeys([item.name], false) + for (const { sanitizedName } of sanitizedEntries) { + const { isValid, errorMessageKey } = checkKeys([sanitizedName], false) if (!isValid) { Toast.notify({ type: 'error', @@ -122,7 +126,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { } // Create set of new variable names for this source - const newVarNames = new Set(newData.map(item => item.name)) + const newVarNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName)) // Find variables from current source that will be deleted and clean up references draft.variables @@ -141,9 +145,8 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { }) // Add or update variables - newData.forEach((item) => { - const varName = item.name - const existingVarIndex = draft.variables.findIndex(v => v.variable === varName) + sanitizedEntries.forEach(({ item, sanitizedName }) => { + const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName) const inputVarType = 'type' in item ? item.type @@ -152,7 +155,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => { const newVar: Variable = { value_type: inputVarType, label: sourceType, // Use sourceType as label to identify source - variable: varName, + variable: sanitizedName, value_selector: [], required: item.required, }