mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
fix: pass webhook request metadata explicitly
This commit is contained in:
parent
b8cedefd7d
commit
5574802631
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user