chore: validate param type of application/json when call a webhook (#25074)

This commit is contained in:
非法操作 2025-09-03 15:49:07 +08:00 committed by GitHub
parent 7120c6414c
commit 2013ceb9d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 363 additions and 40 deletions

View File

@ -1,7 +1,7 @@
import logging import logging
from flask import jsonify from flask import jsonify
from werkzeug.exceptions import BadRequest, NotFound from werkzeug.exceptions import NotFound
from controllers.trigger import bp from controllers.trigger import bp
from services.webhook_service import WebhookService from services.webhook_service import WebhookService
@ -28,7 +28,7 @@ def handle_webhook(webhook_id: str):
# Validate request against node configuration # Validate request against node configuration
validation_result = WebhookService.validate_webhook_request(webhook_data, node_config) validation_result = WebhookService.validate_webhook_request(webhook_data, node_config)
if not validation_result["valid"]: 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) # Process webhook call (send to Celery)
WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow)

View File

@ -35,7 +35,17 @@ class WebhookBodyParameter(BaseModel):
"""Body parameter with type information.""" """Body parameter with type information."""
name: str 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 required: bool = False

View File

@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from core.file.models import FileTransferMethod from core.file.models import FileTransferMethod
from core.tools.tool_file_manager import ToolFileManager from core.tools.tool_file_manager import ToolFileManager
from core.variables.types import SegmentType
from extensions.ext_database import db from extensions.ext_database import db
from factories import file_factory from factories import file_factory
from models.account import Account, TenantAccountJoin, TenantAccountRole from models.account import Account, TenantAccountJoin, TenantAccountRole
@ -186,7 +187,7 @@ class WebhookService:
param_name = param.get("name", "") param_name = param.get("name", "")
if param_name not in webhook_data["query_params"]: if param_name not in webhook_data["query_params"]:
return {"valid": False, "error": f"Required query parameter missing: {param_name}"} return {"valid": False, "error": f"Required query parameter missing: {param_name}"}
if configured_content_type == "text/plain": if configured_content_type == "text/plain":
# For text/plain, just validate that we have a body if any body params are configured as required # For text/plain, just validate that we have a body if any body params are configured as required
body_params = node_data.get("body", []) body_params = node_data.get("body", [])
@ -195,21 +196,52 @@ class WebhookService:
raw_content = body_data.get("raw", "") raw_content = body_data.get("raw", "")
if not raw_content or not isinstance(raw_content, str): if not raw_content or not isinstance(raw_content, str):
return {"valid": False, "error": "Required body content missing for text/plain request"} 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: 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", []) body_params = node_data.get("body", [])
for body_param in body_params: for body_param in body_params:
if body_param.get("required", False): param_name = body_param.get("name", "")
param_name = body_param.get("name", "") param_type = body_param.get("type", SegmentType.STRING)
param_type = body_param.get("type", "string") is_required = body_param.get("required", False)
# Check if parameter exists if not is_required:
if param_type == "file": continue
file_obj = webhook_data.get("files", {}).get(param_name)
if not file_obj: # Check if parameter exists
return {"valid": False, "error": f"Required file parameter missing: {param_name}"} if param_type == SegmentType.FILE:
else: file_obj = webhook_data.get("files", {}).get(param_name)
if param_name not in webhook_data.get("body", {}): if not file_obj:
return {"valid": False, "error": f"Required body parameter missing: {param_name}"} 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} return {"valid": True}
@ -217,6 +249,84 @@ class WebhookService:
logger.exception("Validation error") logger.exception("Validation error")
return {"valid": False, "error": "Validation failed"} 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 @classmethod
def trigger_workflow_execution( def trigger_workflow_execution(
cls, webhook_trigger: WorkflowWebhookTrigger, webhook_data: dict[str, Any], workflow: Workflow cls, webhook_trigger: WorkflowWebhookTrigger, webhook_data: dict[str, Any], workflow: Workflow

View File

@ -202,24 +202,24 @@ class TestWebhookServiceUnit:
result = WebhookService.validate_webhook_request(webhook_data, node_config) result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False 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): def test_validate_webhook_request_text_plain_with_required_body(self):
"""Test webhook validation for text/plain content type with required body content.""" """Test webhook validation for text/plain content type with required body content."""
# Test case 1: text/plain with raw content - should pass # Test case 1: text/plain with raw content - should pass
webhook_data = { webhook_data = {
"method": "POST", "method": "POST",
"headers": {"content-type": "text/plain"}, "headers": {"content-type": "text/plain"},
"query_params": {}, "query_params": {},
"body": {"raw": "Hello World"}, "body": {"raw": "Hello World"},
"files": {} "files": {},
} }
node_config = { node_config = {
"data": { "data": {
"method": "post", "method": "post",
"content_type": "text/plain", "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 # Test case 2: text/plain without raw content but required - should fail
webhook_data_no_body = { webhook_data_no_body = {
"method": "POST", "method": "POST",
"headers": {"content-type": "text/plain"}, "headers": {"content-type": "text/plain"},
"query_params": {}, "query_params": {},
"body": {}, "body": {},
"files": {} "files": {},
} }
result = WebhookService.validate_webhook_request(webhook_data_no_body, node_config) 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 # Test case 3: text/plain with empty raw content but required - should fail
webhook_data_empty_body = { webhook_data_empty_body = {
"method": "POST", "method": "POST",
"headers": {"content-type": "text/plain"}, "headers": {"content-type": "text/plain"},
"query_params": {}, "query_params": {},
"body": {"raw": ""}, "body": {"raw": ""},
"files": {} "files": {},
} }
result = WebhookService.validate_webhook_request(webhook_data_empty_body, node_config) 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): def test_validate_webhook_request_text_plain_no_body_params(self):
"""Test webhook validation for text/plain content type with no body params configured.""" """Test webhook validation for text/plain content type with no body params configured."""
webhook_data = { webhook_data = {
"method": "POST", "method": "POST",
"headers": {"content-type": "text/plain"}, "headers": {"content-type": "text/plain"},
"query_params": {}, "query_params": {},
"body": {"raw": "Hello World"}, "body": {"raw": "Hello World"},
"files": {} "files": {},
} }
node_config = { node_config = {
"data": { "data": {
"method": "post", "method": "post",
"content_type": "text/plain", "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 # Should skip files without filenames
assert len(result) == 0 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