From b8cedefd7d5d3a97059a9f51a33b77ebd864e529 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:27:22 +0800 Subject: [PATCH] fix: normalize signed call depth for root webhook URLs --- api/dify_graph/nodes/http_request/executor.py | 9 ++-- .../test_workflow_app_runner_single_node.py | 2 + .../test_http_request_executor.py | 45 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/api/dify_graph/nodes/http_request/executor.py b/api/dify_graph/nodes/http_request/executor.py index f0736324c9..7150e327b1 100644 --- a/api/dify_graph/nodes/http_request/executor.py +++ b/api/dify_graph/nodes/http_request/executor.py @@ -290,8 +290,10 @@ class Executor: Reserved workflow call-depth headers are removed case-insensitively before the canonical pair is re-added from ``workflow_call_depth``. - This keeps propagation deterministic even if a workflow author manually - configured colliding headers on the node. + The signature path mirrors Flask request matching, so URLs without an + explicit path are normalized to ``/`` before signing. This keeps + propagation deterministic even if a workflow author manually configured + colliding headers on the node. """ authorization = deepcopy(self.auth) headers = deepcopy(self.headers) or {} @@ -301,12 +303,13 @@ class Executor: } headers = {k: v for k, v in headers.items() if k.lower() not in reserved_header_names} parsed_url = urlparse(self.url) + signed_path = parsed_url.path or "/" next_call_depth = str(self.workflow_call_depth + 1) headers[WORKFLOW_CALL_DEPTH_HEADER] = next_call_depth headers[WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER] = build_workflow_call_depth_signature( secret_key=self._http_request_config.secret_key, method=self.method, - path=parsed_url.path, + path=signed_path, depth=next_call_depth, ) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py index 178e26118e..bb3df71419 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py @@ -100,6 +100,7 @@ def test_run_uses_single_node_execution_branch( workflow=workflow, single_iteration_run=single_iteration_run, single_loop_run=single_loop_run, + call_depth=0, ) init_graph.assert_not_called() @@ -156,6 +157,7 @@ def test_single_node_run_validates_target_node_config(monkeypatch) -> None: node_id="loop-node", user_inputs={}, graph_runtime_state=graph_runtime_state, + call_depth=0, node_type_filter_key="loop_id", node_type_label="loop", ) diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py index dcfce1d566..ee16d16c5b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py @@ -937,3 +937,48 @@ def test_executor_propagates_workflow_call_depth_to_arbitrary_target_with_secret path="/data", depth="3", ) + + +def test_executor_normalizes_empty_url_path_when_signing_workflow_call_depth(): + variable_pool = VariablePool( + system_variables=SystemVariable.default(), + user_inputs={}, + ) + node_data = HttpRequestNodeData( + title="External target without explicit path", + method="get", + url="https://api.example.com", + authorization=HttpRequestNodeAuthorization(type="no-auth"), + headers="X-Test: value", + params="", + ) + + executor = Executor( + node_data=node_data, + timeout=HttpRequestNodeTimeout(connect=10, read=30, write=30), + http_request_config=HttpRequestNodeConfig( + max_connect_timeout=HTTP_REQUEST_CONFIG.max_connect_timeout, + max_read_timeout=HTTP_REQUEST_CONFIG.max_read_timeout, + max_write_timeout=HTTP_REQUEST_CONFIG.max_write_timeout, + max_binary_size=HTTP_REQUEST_CONFIG.max_binary_size, + max_text_size=HTTP_REQUEST_CONFIG.max_text_size, + ssl_verify=HTTP_REQUEST_CONFIG.ssl_verify, + ssrf_default_max_retries=HTTP_REQUEST_CONFIG.ssrf_default_max_retries, + secret_key=TEST_SECRET_KEY, + ), + variable_pool=variable_pool, + workflow_call_depth=2, + http_client=ssrf_proxy, + file_manager=file_manager, + ) + + headers = executor._assembling_headers() + + assert headers["X-Test"] == "value" + assert headers[WORKFLOW_CALL_DEPTH_HEADER] == "3" + assert headers[WORKFLOW_CALL_DEPTH_SIGNATURE_HEADER] == build_workflow_call_depth_signature( + secret_key=TEST_SECRET_KEY, + method="get", + path="/", + depth="3", + )