fix: pass webhook request metadata explicitly

This commit is contained in:
Yanli 盐粒 2026-03-20 04:51:37 +08:00
parent b8cedefd7d
commit 5574802631
4 changed files with 182 additions and 90 deletions

View File

@ -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)

View File

@ -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(

View File

@ -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"))

View File

@ -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