From 557480263101e5653bd7979541ff23effd1c2258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yanli=20=E7=9B=90=E7=B2=92?= Date: Fri, 20 Mar 2026 04:51:37 +0800 Subject: [PATCH] fix: pass webhook request metadata explicitly --- api/controllers/trigger/webhook.py | 13 +- api/services/trigger/webhook_service.py | 44 ++-- .../controllers/trigger/test_webhook.py | 13 +- .../services/test_webhook_service.py | 202 ++++++++++++------ 4 files changed, 182 insertions(+), 90 deletions(-) diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py index eb579da5d4..47489e8c5c 100644 --- a/api/controllers/trigger/webhook.py +++ b/api/controllers/trigger/webhook.py @@ -52,8 +52,19 @@ def handle_webhook(webhook_id: str): if error: return jsonify({"error": "Bad Request", "message": error}), 400 + trigger_call_depth = WebhookService.extract_workflow_call_depth( + dict(request.headers), + request_method=request.method, + request_path=request.path, + ) + # Process webhook call (send to Celery) - WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) + WebhookService.trigger_workflow_execution( + webhook_trigger, + webhook_data, + workflow, + call_depth=trigger_call_depth, + ) # Return configured response response_data, status_code = WebhookService.generate_webhook_response(node_config) diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index b75606ef40..ffdb17bf52 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -63,24 +63,30 @@ class WebhookService: return key.replace("-", "_") @classmethod - def extract_workflow_call_depth(cls, headers: Mapping[str, Any]) -> int: + def extract_workflow_call_depth( + cls, + headers: Mapping[str, Any], + *, + request_method: str, + request_path: str, + ) -> int: """Extract the reserved workflow recursion depth header. The depth header is only trusted when accompanied by a valid HMAC - signature for the current request method/path/depth tuple. Header lookup - accepts either canonical or lower-case names because upstream proxies may - normalize casing. Invalid, missing, unsigned, or negative values are - treated as external requests and therefore fall back to depth 0. + signature for the current request method/path/depth tuple supplied by the + caller while a request context is available. Header lookup is normalized + case-insensitively so mixed-case spellings still round-trip after headers + are materialized into a plain mapping. Invalid, missing, unsigned, or + negative values are treated as external requests and therefore fall back + to depth 0. """ - raw_value = headers.get(WORKFLOW_CALL_DEPTH_HEADER) - if raw_value is None: - raw_value = headers.get(WORKFLOW_CALL_DEPTH_HEADER.lower()) + normalized_headers = {str(key).lower(): value for key, value in headers.items()} + + raw_value = normalized_headers.get(WORKFLOW_CALL_DEPTH_HEADER.lower()) if raw_value is None: return 0 - raw_signature = headers.get(WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER) - if raw_signature is None: - raw_signature = headers.get(WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER.lower()) + raw_signature = normalized_headers.get(WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER.lower()) if raw_signature is None: return 0 @@ -89,8 +95,8 @@ class WebhookService: # instead of trusting the sender's path or method directly. expected_signature = build_workflow_call_depth_signature( secret_key=dify_config.SECRET_KEY, - method=request.method, - path=request.path, + method=request_method, + path=request_path, depth=normalized_value, ) if not hmac.compare_digest(str(raw_signature).strip(), expected_signature): @@ -786,7 +792,12 @@ class WebhookService: @classmethod 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, + *, + call_depth: int = 0, ) -> None: """Trigger workflow execution via AsyncWorkflowService. @@ -794,6 +805,8 @@ class WebhookService: webhook_trigger: The webhook trigger object webhook_data: Processed webhook data for workflow inputs workflow: The workflow to execute + call_depth: Validated recursion depth derived earlier from the + incoming request metadata Raises: ValueError: If tenant owner is not found @@ -806,14 +819,13 @@ class WebhookService: workflow_inputs = cls.build_workflow_inputs(webhook_data) # Create trigger data - trigger_call_depth = cls.extract_workflow_call_depth(dict(request.headers)) trigger_data = WebhookTriggerData( app_id=webhook_trigger.app_id, workflow_id=workflow.id, root_node_id=webhook_trigger.node_id, # Start from the webhook node inputs=workflow_inputs, tenant_id=webhook_trigger.tenant_id, - call_depth=trigger_call_depth, + call_depth=call_depth, ) end_user = EndUserService.get_or_create_end_user_by_type( diff --git a/api/tests/unit_tests/controllers/trigger/test_webhook.py b/api/tests/unit_tests/controllers/trigger/test_webhook.py index 91c793d292..ee29f3f7b2 100644 --- a/api/tests/unit_tests/controllers/trigger/test_webhook.py +++ b/api/tests/unit_tests/controllers/trigger/test_webhook.py @@ -11,6 +11,7 @@ import controllers.trigger.webhook as module def mock_request(): module.request = types.SimpleNamespace( method="POST", + path="/triggers/webhook/wh-1", headers={"x-test": "1"}, args={"a": "b"}, ) @@ -56,14 +57,17 @@ class TestHandleWebhook: @patch.object(module.WebhookService, "extract_and_validate_webhook_data") @patch.object(module.WebhookService, "trigger_workflow_execution") @patch.object(module.WebhookService, "generate_webhook_response") + @patch.object(module.WebhookService, "extract_workflow_call_depth", return_value=4) def test_success( self, + mock_extract_depth, mock_generate, mock_trigger, mock_extract, mock_get, ): - mock_get.return_value = (DummyWebhookTrigger(), "workflow", "node_config") + webhook_trigger = DummyWebhookTrigger() + mock_get.return_value = (webhook_trigger, "workflow", "node_config") mock_extract.return_value = {"input": "x"} mock_generate.return_value = ({"ok": True}, 200) @@ -71,7 +75,12 @@ class TestHandleWebhook: assert status == 200 assert response["ok"] is True - mock_trigger.assert_called_once() + mock_extract_depth.assert_called_once_with( + {"x-test": "1"}, + request_method="POST", + request_path=module.request.path, + ) + mock_trigger.assert_called_once_with(webhook_trigger, {"input": "x"}, "workflow", call_depth=4) @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") @patch.object(module.WebhookService, "extract_and_validate_webhook_data", side_effect=ValueError("bad")) diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index ed0fa1b48f..fbcb231171 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -566,20 +566,36 @@ class TestWebhookServiceUnit: assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None) def test_extract_workflow_call_depth_defaults_to_zero_for_invalid_values(self): - assert WebhookService.extract_workflow_call_depth({}) == 0 - assert WebhookService.extract_workflow_call_depth({WORKFLOW_CALL_DEPTH_HEADER: "abc"}) == 0 - assert WebhookService.extract_workflow_call_depth({WORKFLOW_CALL_DEPTH_HEADER.lower(): "-1"}) == 0 + assert WebhookService.extract_workflow_call_depth({}, request_method="POST", request_path="/webhook") == 0 + assert ( + WebhookService.extract_workflow_call_depth( + {WORKFLOW_CALL_DEPTH_HEADER: "abc"}, + request_method="POST", + request_path="/webhook", + ) + == 0 + ) + assert ( + WebhookService.extract_workflow_call_depth( + {WORKFLOW_CALL_DEPTH_HEADER.lower(): "-1"}, + request_method="POST", + request_path="/webhook", + ) + == 0 + ) def test_extract_workflow_call_depth_ignores_unsigned_external_header(self): - assert WebhookService.extract_workflow_call_depth({WORKFLOW_CALL_DEPTH_HEADER: "5"}) == 0 + assert ( + WebhookService.extract_workflow_call_depth( + {WORKFLOW_CALL_DEPTH_HEADER: "5"}, + request_method="POST", + request_path="/webhook", + ) + == 0 + ) def test_extract_workflow_call_depth_honors_signed_internal_header(self): - app = Flask(__name__) - - with ( - patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY), - app.test_request_context("/triggers/webhook/test-webhook", method="POST"), - ): + with patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY): signature = build_workflow_call_depth_signature( secret_key=TEST_SECRET_KEY, method="POST", @@ -592,18 +608,36 @@ class TestWebhookServiceUnit: { WORKFLOW_CALL_DEPTH_HEADER: "4", WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: signature, - } + }, + request_method="POST", + request_path="/triggers/webhook/test-webhook", + ) + == 4 + ) + + def test_extract_workflow_call_depth_accepts_mixed_case_reserved_headers(self): + with patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY): + signature = build_workflow_call_depth_signature( + secret_key=TEST_SECRET_KEY, + method="POST", + path="/triggers/webhook/test-webhook", + depth="4", + ) + + assert ( + WebhookService.extract_workflow_call_depth( + { + "X-Dify-Workflow-Call-Depth": "4", + "X-Dify-Workflow-Call-Depth-Signature": signature, + }, + request_method="POST", + request_path="/triggers/webhook/test-webhook", ) == 4 ) def test_extract_workflow_call_depth_rejects_signature_for_other_path(self): - app = Flask(__name__) - - with ( - patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY), - app.test_request_context("/triggers/webhook/right-webhook", method="POST"), - ): + with patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY): wrong_signature = build_workflow_call_depth_signature( secret_key=TEST_SECRET_KEY, method="POST", @@ -616,33 +650,35 @@ class TestWebhookServiceUnit: { WORKFLOW_CALL_DEPTH_HEADER: "4", WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: wrong_signature, - } + }, + request_method="POST", + request_path="/triggers/webhook/right-webhook", ) == 0 ) @patch("services.trigger.webhook_service.dify_config") def test_extract_workflow_call_depth_honors_signature_with_empty_secret(self, mock_config): - app = Flask(__name__) mock_config.SECRET_KEY = "" - with app.test_request_context("/triggers/webhook/test-webhook", method="POST"): - signature = build_workflow_call_depth_signature( - secret_key="", - method="POST", - path="/triggers/webhook/test-webhook", - depth="4", - ) + signature = build_workflow_call_depth_signature( + secret_key="", + method="POST", + path="/triggers/webhook/test-webhook", + depth="4", + ) - assert ( - WebhookService.extract_workflow_call_depth( - { - WORKFLOW_CALL_DEPTH_HEADER: "4", - WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: signature, - } - ) - == 4 + assert ( + WebhookService.extract_workflow_call_depth( + { + WORKFLOW_CALL_DEPTH_HEADER: "4", + WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: signature, + }, + request_method="POST", + request_path="/triggers/webhook/test-webhook", ) + == 4 + ) @patch("services.trigger.webhook_service.QuotaType") @patch("services.trigger.webhook_service.EndUserService") @@ -657,7 +693,6 @@ class TestWebhookServiceUnit: mock_end_user_service, mock_quota_type, ): - app = Flask(__name__) webhook_trigger = MagicMock(app_id="app", tenant_id="tenant", node_id="root", webhook_id="webhook") workflow = MagicMock(id="workflow") mock_end_user = MagicMock() @@ -671,21 +706,19 @@ class TestWebhookServiceUnit: depth="4", ) - with ( - patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY), - app.test_request_context( - "/triggers/webhook/test-webhook", - method="POST", - headers={ - WORKFLOW_CALL_DEPTH_HEADER: "4", - WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: signature, - }, - ), - ): + with patch("services.trigger.webhook_service.dify_config.SECRET_KEY", TEST_SECRET_KEY): WebhookService.trigger_workflow_execution( webhook_trigger, {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}, workflow, + call_depth=WebhookService.extract_workflow_call_depth( + { + WORKFLOW_CALL_DEPTH_HEADER: "4", + WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: signature, + }, + request_method="POST", + request_path="/triggers/webhook/test-webhook", + ), ) trigger_data = mock_async_workflow_service.trigger_workflow_async.call_args.args[2] @@ -704,23 +737,22 @@ class TestWebhookServiceUnit: mock_end_user_service, mock_quota_type, ): - app = Flask(__name__) webhook_trigger = MagicMock(app_id="app", tenant_id="tenant", node_id="root", webhook_id="webhook") workflow = MagicMock(id="workflow") mock_end_user_service.get_or_create_end_user_by_type.return_value = MagicMock() mock_db.engine = MagicMock() mock_session.return_value.__enter__.return_value = MagicMock() - with app.test_request_context( - "/triggers/webhook/test-webhook", - method="POST", - headers={WORKFLOW_CALL_DEPTH_HEADER: "5"}, - ): - WebhookService.trigger_workflow_execution( - webhook_trigger, - {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}, - workflow, - ) + WebhookService.trigger_workflow_execution( + webhook_trigger, + {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}, + workflow, + call_depth=WebhookService.extract_workflow_call_depth( + {WORKFLOW_CALL_DEPTH_HEADER: "5"}, + request_method="POST", + request_path="/triggers/webhook/test-webhook", + ), + ) trigger_data = mock_async_workflow_service.trigger_workflow_async.call_args.args[2] assert trigger_data.call_depth == 0 @@ -738,7 +770,6 @@ class TestWebhookServiceUnit: mock_end_user_service, mock_quota_type, ): - app = Flask(__name__) webhook_trigger = MagicMock(app_id="app", tenant_id="tenant", node_id="root", webhook_id="webhook") workflow = MagicMock(id="workflow") mock_end_user_service.get_or_create_end_user_by_type.return_value = MagicMock() @@ -751,19 +782,48 @@ class TestWebhookServiceUnit: depth="5", ) - with app.test_request_context( - "/triggers/webhook/test-webhook", - method="POST", - headers={ - WORKFLOW_CALL_DEPTH_HEADER: "5", - WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: captured_signature, - }, - ): - WebhookService.trigger_workflow_execution( - webhook_trigger, - {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}, - workflow, - ) + WebhookService.trigger_workflow_execution( + webhook_trigger, + {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}, + workflow, + call_depth=WebhookService.extract_workflow_call_depth( + { + WORKFLOW_CALL_DEPTH_HEADER: "5", + WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER: captured_signature, + }, + request_method="POST", + request_path="/triggers/webhook/test-webhook", + ), + ) trigger_data = mock_async_workflow_service.trigger_workflow_async.call_args.args[2] assert trigger_data.call_depth == 0 + + @patch("services.trigger.webhook_service.QuotaType") + @patch("services.trigger.webhook_service.EndUserService") + @patch("services.trigger.webhook_service.AsyncWorkflowService") + @patch("services.trigger.webhook_service.Session") + @patch("services.trigger.webhook_service.db") + def test_trigger_workflow_execution_does_not_require_request_context_when_call_depth_is_passed( + self, + mock_db, + mock_session, + mock_async_workflow_service, + mock_end_user_service, + mock_quota_type, + ): + webhook_trigger = MagicMock(app_id="app", tenant_id="tenant", node_id="root", webhook_id="webhook") + workflow = MagicMock(id="workflow") + mock_end_user_service.get_or_create_end_user_by_type.return_value = MagicMock() + mock_db.engine = MagicMock() + mock_session.return_value.__enter__.return_value = MagicMock() + + WebhookService.trigger_workflow_execution( + webhook_trigger, + {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}, + workflow, + call_depth=4, + ) + + trigger_data = mock_async_workflow_service.trigger_workflow_async.call_args.args[2] + assert trigger_data.call_depth == 4