From 2013ceb9d2b4e5b8725e8aed74a97b32eb0dcd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 3 Sep 2025 15:49:07 +0800 Subject: [PATCH] chore: validate param type of application/json when call a webhook (#25074) --- api/controllers/trigger/webhook.py | 4 +- .../nodes/trigger_webhook/entities.py | 12 +- api/services/webhook_service.py | 134 +++++++++- .../services/test_webhook_service.py | 253 ++++++++++++++++-- 4 files changed, 363 insertions(+), 40 deletions(-) diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py index f157d28320..30d170d388 100644 --- a/api/controllers/trigger/webhook.py +++ b/api/controllers/trigger/webhook.py @@ -1,7 +1,7 @@ import logging from flask import jsonify -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import NotFound from controllers.trigger import bp from services.webhook_service import WebhookService @@ -28,7 +28,7 @@ def handle_webhook(webhook_id: str): # Validate request against node configuration validation_result = WebhookService.validate_webhook_request(webhook_data, node_config) if not validation_result["valid"]: - raise BadRequest(validation_result["error"]) + return jsonify({"error": "Bad Request", "message": validation_result["error"]}), 400 # Process webhook call (send to Celery) WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) diff --git a/api/core/workflow/nodes/trigger_webhook/entities.py b/api/core/workflow/nodes/trigger_webhook/entities.py index 2c05fe2298..ef456d757d 100644 --- a/api/core/workflow/nodes/trigger_webhook/entities.py +++ b/api/core/workflow/nodes/trigger_webhook/entities.py @@ -35,7 +35,17 @@ class WebhookBodyParameter(BaseModel): """Body parameter with type information.""" name: str - type: Literal["string", "number", "boolean", "object", "array", "file"] = "string" + type: Literal[ + "string", + "number", + "boolean", + "object", + "array[string]", + "array[number]", + "array[boolean]", + "array[object]", + "file", + ] = "string" required: bool = False diff --git a/api/services/webhook_service.py b/api/services/webhook_service.py index 1e76308da8..1f737d2eb1 100644 --- a/api/services/webhook_service.py +++ b/api/services/webhook_service.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from core.file.models import FileTransferMethod from core.tools.tool_file_manager import ToolFileManager +from core.variables.types import SegmentType from extensions.ext_database import db from factories import file_factory from models.account import Account, TenantAccountJoin, TenantAccountRole @@ -186,7 +187,7 @@ class WebhookService: param_name = param.get("name", "") if param_name not in webhook_data["query_params"]: return {"valid": False, "error": f"Required query parameter missing: {param_name}"} - + if configured_content_type == "text/plain": # For text/plain, just validate that we have a body if any body params are configured as required body_params = node_data.get("body", []) @@ -195,21 +196,52 @@ class WebhookService: raw_content = body_data.get("raw", "") if not raw_content or not isinstance(raw_content, str): return {"valid": False, "error": "Required body content missing for text/plain request"} + + elif configured_content_type == "application/json": + # For application/json, validate both existence and types of parameters + body_params = node_data.get("body", []) + body_data = webhook_data.get("body", {}) + + for body_param in body_params: + param_name = body_param.get("name", "") + param_type = body_param.get("type", SegmentType.STRING) + is_required = body_param.get("required", False) + + # Handle regular JSON parameters + param_exists = param_name in body_data + + # Check if required parameter exists + if is_required and not param_exists: + return {"valid": False, "error": f"Required body parameter missing: {param_name}"} + + # Validate parameter type if it exists + if param_exists: + param_value = body_data[param_name] + validation_result = cls._validate_json_parameter_type(param_name, param_value, param_type) + if not validation_result["valid"]: + return validation_result + else: + # For other content types (multipart/form-data, application/x-www-form-urlencoded, etc.) + # Only validate existence of required parameters, no type validation body_params = node_data.get("body", []) for body_param in body_params: - if body_param.get("required", False): - param_name = body_param.get("name", "") - param_type = body_param.get("type", "string") + param_name = body_param.get("name", "") + param_type = body_param.get("type", SegmentType.STRING) + is_required = body_param.get("required", False) - # Check if parameter exists - if param_type == "file": - file_obj = webhook_data.get("files", {}).get(param_name) - if not file_obj: - return {"valid": False, "error": f"Required file parameter missing: {param_name}"} - else: - if param_name not in webhook_data.get("body", {}): - return {"valid": False, "error": f"Required body parameter missing: {param_name}"} + if not is_required: + continue + + # Check if parameter exists + if param_type == SegmentType.FILE: + file_obj = webhook_data.get("files", {}).get(param_name) + if not file_obj: + return {"valid": False, "error": f"Required file parameter missing: {param_name}"} + else: + body_data = webhook_data.get("body", {}) + if param_name not in body_data: + return {"valid": False, "error": f"Required body parameter missing: {param_name}"} return {"valid": True} @@ -217,6 +249,84 @@ class WebhookService: logger.exception("Validation error") return {"valid": False, "error": "Validation failed"} + @classmethod + def _validate_json_parameter_type(cls, param_name: str, param_value: Any, param_type: str) -> dict[str, Any]: + """Validate JSON parameter type against expected type.""" + try: + if param_type == SegmentType.STRING: + if not isinstance(param_value, str): + return { + "valid": False, + "error": f"Parameter '{param_name}' must be a string, got {type(param_value).__name__}", + } + + elif param_type == SegmentType.NUMBER: + if not isinstance(param_value, (int, float)): + return { + "valid": False, + "error": f"Parameter '{param_name}' must be a number, got {type(param_value).__name__}", + } + + elif param_type == SegmentType.BOOLEAN: + if not isinstance(param_value, bool): + return { + "valid": False, + "error": f"Parameter '{param_name}' must be a boolean, got {type(param_value).__name__}", + } + + elif param_type == SegmentType.OBJECT: + if not isinstance(param_value, dict): + return { + "valid": False, + "error": f"Parameter '{param_name}' must be an object, got {type(param_value).__name__}", + } + + elif param_type == SegmentType.ARRAY_STRING: + if not isinstance(param_value, list): + return { + "valid": False, + "error": f"Parameter '{param_name}' must be an array, got {type(param_value).__name__}", + } + if not all(isinstance(item, str) for item in param_value): + return {"valid": False, "error": f"Parameter '{param_name}' must be an array of strings"} + + elif param_type == SegmentType.ARRAY_NUMBER: + if not isinstance(param_value, list): + return { + "valid": False, + "error": f"Parameter '{param_name}' must be an array, got {type(param_value).__name__}", + } + if not all(isinstance(item, (int, float)) for item in param_value): + return {"valid": False, "error": f"Parameter '{param_name}' must be an array of numbers"} + + elif param_type == SegmentType.ARRAY_BOOLEAN: + if not isinstance(param_value, list): + return { + "valid": False, + "error": f"Parameter '{param_name}' must be an array, got {type(param_value).__name__}", + } + if not all(isinstance(item, bool) for item in param_value): + return {"valid": False, "error": f"Parameter '{param_name}' must be an array of booleans"} + + elif param_type == SegmentType.ARRAY_OBJECT: + if not isinstance(param_value, list): + return { + "valid": False, + "error": f"Parameter '{param_name}' must be an array, got {type(param_value).__name__}", + } + if not all(isinstance(item, dict) for item in param_value): + return {"valid": False, "error": f"Parameter '{param_name}' must be an array of objects"} + + else: + # Unknown type, skip validation + logger.warning("Unknown parameter type: %s for parameter %s", param_type, param_name) + + return {"valid": True} + + except Exception: + logger.exception("Type validation error for parameter %s", param_name) + return {"valid": False, "error": f"Type validation failed for parameter '{param_name}'"} + @classmethod def trigger_workflow_execution( cls, webhook_trigger: WorkflowWebhookTrigger, webhook_data: dict[str, Any], workflow: Workflow diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index 112b86c242..dbb9092ac5 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -202,24 +202,24 @@ class TestWebhookServiceUnit: result = WebhookService.validate_webhook_request(webhook_data, node_config) assert result["valid"] is False - assert "Required file parameter missing: upload" in result["error"] + assert "Required body parameter missing: upload" in result["error"] def test_validate_webhook_request_text_plain_with_required_body(self): """Test webhook validation for text/plain content type with required body content.""" # Test case 1: text/plain with raw content - should pass webhook_data = { - "method": "POST", - "headers": {"content-type": "text/plain"}, - "query_params": {}, - "body": {"raw": "Hello World"}, - "files": {} + "method": "POST", + "headers": {"content-type": "text/plain"}, + "query_params": {}, + "body": {"raw": "Hello World"}, + "files": {}, } node_config = { "data": { - "method": "post", + "method": "post", "content_type": "text/plain", - "body": [{"name": "message", "type": "string", "required": True}] + "body": [{"name": "message", "type": "string", "required": True}], } } @@ -228,11 +228,11 @@ class TestWebhookServiceUnit: # Test case 2: text/plain without raw content but required - should fail webhook_data_no_body = { - "method": "POST", - "headers": {"content-type": "text/plain"}, - "query_params": {}, - "body": {}, - "files": {} + "method": "POST", + "headers": {"content-type": "text/plain"}, + "query_params": {}, + "body": {}, + "files": {}, } result = WebhookService.validate_webhook_request(webhook_data_no_body, node_config) @@ -241,11 +241,11 @@ class TestWebhookServiceUnit: # Test case 3: text/plain with empty raw content but required - should fail webhook_data_empty_body = { - "method": "POST", - "headers": {"content-type": "text/plain"}, - "query_params": {}, - "body": {"raw": ""}, - "files": {} + "method": "POST", + "headers": {"content-type": "text/plain"}, + "query_params": {}, + "body": {"raw": ""}, + "files": {}, } result = WebhookService.validate_webhook_request(webhook_data_empty_body, node_config) @@ -255,18 +255,18 @@ class TestWebhookServiceUnit: def test_validate_webhook_request_text_plain_no_body_params(self): """Test webhook validation for text/plain content type with no body params configured.""" webhook_data = { - "method": "POST", - "headers": {"content-type": "text/plain"}, - "query_params": {}, - "body": {"raw": "Hello World"}, - "files": {} + "method": "POST", + "headers": {"content-type": "text/plain"}, + "query_params": {}, + "body": {"raw": "Hello World"}, + "files": {}, } node_config = { "data": { - "method": "post", + "method": "post", "content_type": "text/plain", - "body": [] # No body params configured + "body": [], # No body params configured } } @@ -435,3 +435,206 @@ class TestWebhookServiceUnit: # Should skip files without filenames assert len(result) == 0 + + def test_validate_json_parameter_type_string(self): + """Test JSON parameter type validation for string type.""" + # Valid string + result = WebhookService._validate_json_parameter_type("name", "hello", "string") + assert result["valid"] is True + + # Invalid string (number) + result = WebhookService._validate_json_parameter_type("name", 123, "string") + assert result["valid"] is False + assert "must be a string, got int" in result["error"] + + def test_validate_json_parameter_type_number(self): + """Test JSON parameter type validation for number type.""" + # Valid integer + result = WebhookService._validate_json_parameter_type("count", 42, "number") + assert result["valid"] is True + + # Valid float + result = WebhookService._validate_json_parameter_type("price", 19.99, "number") + assert result["valid"] is True + + # Invalid number (string) + result = WebhookService._validate_json_parameter_type("count", "42", "number") + assert result["valid"] is False + assert "must be a number, got str" in result["error"] + + def test_validate_json_parameter_type_bool(self): + """Test JSON parameter type validation for boolean type.""" + # Valid boolean + result = WebhookService._validate_json_parameter_type("enabled", True, "boolean") + assert result["valid"] is True + + result = WebhookService._validate_json_parameter_type("enabled", False, "boolean") + assert result["valid"] is True + + # Invalid boolean (string) + result = WebhookService._validate_json_parameter_type("enabled", "true", "boolean") + assert result["valid"] is False + assert "must be a boolean, got str" in result["error"] + + def test_validate_json_parameter_type_object(self): + """Test JSON parameter type validation for object type.""" + # Valid object + result = WebhookService._validate_json_parameter_type("user", {"name": "John", "age": 30}, "object") + assert result["valid"] is True + + # Invalid object (string) + result = WebhookService._validate_json_parameter_type("user", "not_an_object", "object") + assert result["valid"] is False + assert "must be an object, got str" in result["error"] + + def test_validate_json_parameter_type_array_string(self): + """Test JSON parameter type validation for array[string] type.""" + # Valid array of strings + result = WebhookService._validate_json_parameter_type("tags", ["tag1", "tag2", "tag3"], "array[string]") + assert result["valid"] is True + + # Invalid - not an array + result = WebhookService._validate_json_parameter_type("tags", "not_an_array", "array[string]") + assert result["valid"] is False + assert "must be an array, got str" in result["error"] + + # Invalid - array with non-strings + result = WebhookService._validate_json_parameter_type("tags", ["tag1", 123, "tag3"], "array[string]") + assert result["valid"] is False + assert "must be an array of strings" in result["error"] + + def test_validate_json_parameter_type_array_number(self): + """Test JSON parameter type validation for array[number] type.""" + # Valid array of numbers + result = WebhookService._validate_json_parameter_type("scores", [1, 2.5, 3, 4.7], "array[number]") + assert result["valid"] is True + + # Invalid - array with non-numbers + result = WebhookService._validate_json_parameter_type("scores", [1, "2", 3], "array[number]") + assert result["valid"] is False + assert "must be an array of numbers" in result["error"] + + def test_validate_json_parameter_type_array_bool(self): + """Test JSON parameter type validation for array[bool] type.""" + # Valid array of booleans + result = WebhookService._validate_json_parameter_type("flags", [True, False, True], "array[boolean]") + assert result["valid"] is True + + # Invalid - array with non-booleans + result = WebhookService._validate_json_parameter_type("flags", [True, "false", True], "array[boolean]") + assert result["valid"] is False + assert "must be an array of booleans" in result["error"] + + def test_validate_json_parameter_type_array_object(self): + """Test JSON parameter type validation for array[object] type.""" + # Valid array of objects + result = WebhookService._validate_json_parameter_type( + "users", [{"name": "John"}, {"name": "Jane"}], "array[object]" + ) + assert result["valid"] is True + + # Invalid - array with non-objects + result = WebhookService._validate_json_parameter_type( + "users", [{"name": "John"}, "not_object"], "array[object]" + ) + assert result["valid"] is False + assert "must be an array of objects" in result["error"] + + def test_validate_json_parameter_type_unknown_type(self): + """Test JSON parameter type validation for unknown type.""" + # Unknown type should return valid and log warning + result = WebhookService._validate_json_parameter_type("data", "anything", "unknown_type") + assert result["valid"] is True + + def test_validate_webhook_request_json_type_validation(self): + """Test webhook validation with JSON parameter type validation.""" + # Test valid JSON types + webhook_data = { + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "query_params": {}, + "body": { + "name": "John", + "age": 30, + "active": True, + "profile": {"email": "john@example.com"}, + "tags": ["developer", "python"], + "scores": [85, 92.5, 78], + "flags": [True, False], + "items": [{"id": 1}, {"id": 2}], + }, + "files": {}, + } + + node_config = { + "data": { + "method": "post", + "content_type": "application/json", + "body": [ + {"name": "name", "type": "string", "required": True}, + {"name": "age", "type": "number", "required": True}, + {"name": "active", "type": "bool", "required": True}, + {"name": "profile", "type": "object", "required": True}, + {"name": "tags", "type": "array[string]", "required": True}, + {"name": "scores", "type": "array[number]", "required": True}, + {"name": "flags", "type": "array[bool]", "required": True}, + {"name": "items", "type": "array[object]", "required": True}, + ], + } + } + + result = WebhookService.validate_webhook_request(webhook_data, node_config) + assert result["valid"] is True + + def test_validate_webhook_request_json_type_validation_invalid(self): + """Test webhook validation with invalid JSON parameter types.""" + webhook_data = { + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "query_params": {}, + "body": { + "name": 123, # Should be string + "age": "thirty", # Should be number + }, + "files": {}, + } + + node_config = { + "data": { + "method": "post", + "content_type": "application/json", + "body": [ + {"name": "name", "type": "string", "required": True}, + {"name": "age", "type": "number", "required": True}, + ], + } + } + + result = WebhookService.validate_webhook_request(webhook_data, node_config) + assert result["valid"] is False + assert "must be a string, got int" in result["error"] + + def test_validate_webhook_request_non_json_skip_type_validation(self): + """Test that type validation is skipped for non-JSON content types.""" + webhook_data = { + "method": "POST", + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "query_params": {}, + "body": { + "name": 123, # Would be invalid for string if this was JSON + }, + "files": {}, + } + + node_config = { + "data": { + "method": "post", + "content_type": "application/x-www-form-urlencoded", + "body": [ + {"name": "name", "type": "string", "required": True}, + ], + } + } + + result = WebhookService.validate_webhook_request(webhook_data, node_config) + assert result["valid"] is True # Should pass because type validation is only for JSON