diff --git a/api/apps/restful_apis/agent_api.py b/api/apps/restful_apis/agent_api.py index 286358be8e..0837b23ae1 100644 --- a/api/apps/restful_apis/agent_api.py +++ b/api/apps/restful_apis/agent_api.py @@ -100,6 +100,22 @@ def _require_canvas_owner_sync(func): return wrapper +def _is_truthy(value): + if isinstance(value, bool): + return value + if isinstance(value, int): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return False + + +def _allow_anonymous_webhook(security_cfg: dict) -> bool: + if not isinstance(security_cfg, dict): + return False + return _is_truthy(security_cfg.get("allow_anonymous")) + + def _get_user_nickname(user_id: str) -> str: exists, user = UserService.get_by_id(user_id) if not exists: @@ -1567,9 +1583,26 @@ async def agent_chat_completion(tenant_id, agent_id=None): @manager.route("/agents//webhook", methods=["POST", "GET", "PUT", "PATCH", "DELETE", "HEAD"]) # noqa: F821 -@manager.route("/agents//webhook/test",methods=["POST", "GET", "PUT", "PATCH", "DELETE", "HEAD"],) # noqa: F821 async def webhook(agent_id: str): - is_test = request.path.startswith(f"/api/v1/agents/{agent_id}/webhook/test") + return await _webhook_impl(agent_id, is_test=False) + + +@manager.route("/agents//webhook/test", methods=["POST", "GET", "PUT", "PATCH", "DELETE", "HEAD"]) # noqa: F821 +@login_required +@add_tenant_id_to_kwargs +async def webhook_test(agent_id: str, tenant_id: str): + if not UserCanvasService.query(user_id=tenant_id, id=agent_id): + logging.warning( + "Webhook test denied: owner check failed agent_id=%s tenant_id=%s method=%s", + agent_id, + tenant_id, + request.method, + ) + return get_json_result(data=False, message="Only the owner of the agent is authorized for this operation.", code=RetCode.OPERATING_ERROR) + return await _webhook_impl(agent_id, is_test=True) + + +async def _webhook_impl(agent_id: str, is_test: bool): start_ts = time.time() # 1. Fetch canvas by agent_id @@ -1605,12 +1638,16 @@ async def webhook(agent_id: str): code=RetCode.BAD_REQUEST,message=f"HTTP method '{request_method}' not allowed for this webhook." ),RetCode.BAD_REQUEST - # 6. Validate webhook security async def validate_webhook_security(security_cfg: dict): """Validate webhook security rules based on security configuration.""" - if not security_cfg: - return # No security config → allowed by default + if not isinstance(security_cfg, dict) or not security_cfg: + logging.warning( + "Webhook denied: missing security config agent_id=%s method=%s", + agent_id, + request.method, + ) + raise Exception("Webhook security is required. Set allow_anonymous to true to permit unauthenticated webhooks.") # 1. Validate max body size await _validate_max_body_size(security_cfg) @@ -1625,6 +1662,13 @@ async def webhook(agent_id: str): auth_type = security_cfg.get("auth_type", "none") if auth_type == "none": + if not _allow_anonymous_webhook(security_cfg): + logging.warning( + "Webhook denied: anonymous access missing explicit opt-in agent_id=%s method=%s", + agent_id, + request.method, + ) + raise Exception("Anonymous webhook access requires allow_anonymous to be true") return if auth_type == "token": @@ -1643,7 +1687,7 @@ async def webhook(agent_id: str): """Check request size does not exceed max_body_size.""" max_size = security_cfg.get("max_body_size") if not max_size: - return + max_size = "10MB" # Convert "10MB" → bytes units = {"kb": 1024, "mb": 1024**2} @@ -1688,7 +1732,7 @@ async def webhook(agent_id: str): """Simple in-memory rate limiting.""" rl = security_cfg.get("rate_limit") if not rl: - return + rl = {"limit": 60, "per": "minute"} limit = int(rl.get("limit", 60)) if limit <= 0: diff --git a/test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py b/test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py index e93c48249a..b3eaba997e 100644 --- a/test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py +++ b/test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py @@ -167,6 +167,12 @@ def _default_webhook_params( } +def _anonymous_security(**overrides): + security = {"auth_type": "none", "allow_anonymous": True} + security.update(overrides) + return security + + def _make_webhook_cvs(module, *, params=None, dsl=None, canvas_category=None): if dsl is None: if params is None: @@ -654,7 +660,15 @@ def test_webhook_security_dispatch(monkeypatch): _DummyRequest(headers={"Content-Type": "application/json"}, json_body={}, args={"a": "b"}), ) - for security in ({}, {"auth_type": "none"}): + for security, message in ( + ({}, "Webhook security is required"), + ({"auth_type": "none"}, "Anonymous webhook access requires allow_anonymous"), + ): + cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=security)) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id, _cvs=cvs: (True, _cvs)) + _assert_bad_request(_run(module.webhook("agent-1")), message) + + for security in (_anonymous_security(), _anonymous_security(allow_anonymous="true")): cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=security)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id, _cvs=cvs: (True, _cvs)) res = _run(module.webhook("agent-1")) @@ -666,6 +680,30 @@ def test_webhook_security_dispatch(monkeypatch): _assert_bad_request(_run(module.webhook("agent-1")), "Unsupported auth_type") +@pytest.mark.p2 +def test_webhook_test_requires_owner(monkeypatch): + module = _load_agents_app(monkeypatch) + _patch_background_task(monkeypatch, module) + + monkeypatch.setattr( + module, + "request", + _DummyRequest(path="/api/v1/agents/agent-1/webhook/test", headers={"Content-Type": "application/json"}, json_body={}), + ) + + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: []) + denied = _run(module.webhook_test(agent_id="agent-1")) + assert denied["code"] == module.RetCode.OPERATING_ERROR + assert "Only the owner" in denied["message"] + + cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=_anonymous_security())) + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [cvs]) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) + allowed = _run(module.webhook_test(agent_id="agent-1")) + assert hasattr(allowed, "status_code") + assert allowed.status_code == 200 + + @pytest.mark.p2 def test_webhook_max_body_size(monkeypatch): module = _load_agents_app(monkeypatch) @@ -674,18 +712,18 @@ def test_webhook_max_body_size(monkeypatch): base_request = _DummyRequest(headers={"Content-Type": "application/json"}, json_body={}) monkeypatch.setattr(module, "request", base_request) - cvs = _make_webhook_cvs(module, params=_default_webhook_params(security={"auth_type": "none"})) + cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=_anonymous_security())) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) res = _run(module.webhook("agent-1")) assert hasattr(res, "status_code") assert res.status_code == 200 - security = {"auth_type": "none", "max_body_size": "123"} + security = _anonymous_security(max_body_size="123") cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=security)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) _assert_bad_request(_run(module.webhook("agent-1")), "Invalid max_body_size format") - security = {"auth_type": "none", "max_body_size": "11mb"} + security = _anonymous_security(max_body_size="11mb") cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=security)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) _assert_bad_request(_run(module.webhook("agent-1")), "exceeds maximum allowed size") @@ -695,11 +733,35 @@ def test_webhook_max_body_size(monkeypatch): "request", _DummyRequest(headers={"Content-Type": "application/json"}, json_body={}, content_length=2048), ) - security = {"auth_type": "none", "max_body_size": "1kb"} + security = _anonymous_security(max_body_size="1kb") cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=security)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) _assert_bad_request(_run(module.webhook("agent-1")), "Request body too large") + monkeypatch.setattr( + module, + "request", + _DummyRequest(headers={"Content-Type": "application/json"}, json_body={}, content_length=10 * 1024 * 1024 + 1), + ) + security = _anonymous_security() + cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=security)) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) + _assert_bad_request(_run(module.webhook("agent-1")), "Request body too large") + + token_security = {"auth_type": "token", "token": {"token_header": "X-TOKEN", "token_value": "ok"}} + cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=token_security)) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) + monkeypatch.setattr( + module, + "request", + _DummyRequest( + headers={"Content-Type": "application/json", "X-TOKEN": "ok"}, + json_body={}, + content_length=10 * 1024 * 1024 + 1, + ), + ) + _assert_bad_request(_run(module.webhook("agent-1")), "Request body too large") + @pytest.mark.p2 def test_webhook_ip_whitelist(monkeypatch): @@ -713,14 +775,14 @@ def test_webhook_ip_whitelist(monkeypatch): ) for whitelist in ([], ["127.0.0.0/24"], ["127.0.0.1"]): - security = {"auth_type": "none", "ip_whitelist": whitelist} + security = _anonymous_security(ip_whitelist=whitelist) cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=security)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id, _cvs=cvs: (True, _cvs)) res = _run(module.webhook("agent-1")) assert hasattr(res, "status_code"), res assert res.status_code == 200 - security = {"auth_type": "none", "ip_whitelist": ["10.0.0.1"]} + security = _anonymous_security(ip_whitelist=["10.0.0.1"]) cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=security)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) _assert_bad_request(_run(module.webhook("agent-1")), "is not allowed") @@ -733,25 +795,47 @@ def test_webhook_rate_limit(monkeypatch): monkeypatch.setattr(module, "request", _DummyRequest(headers={"Content-Type": "application/json"}, json_body={})) - cvs = _make_webhook_cvs(module, params=_default_webhook_params(security={"auth_type": "none"})) + cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=_anonymous_security())) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) res = _run(module.webhook("agent-1")) assert hasattr(res, "status_code") assert res.status_code == 200 - bad_limit = {"auth_type": "none", "rate_limit": {"limit": 0, "per": "minute"}} + module.REDIS_CONN.bucket_result = [0] + cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=_anonymous_security())) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) + _assert_bad_request(_run(module.webhook("agent-1")), "Too many requests") + + module.REDIS_CONN.bucket_result = [1] + token_security = {"auth_type": "token", "token": {"token_header": "X-TOKEN", "token_value": "ok"}} + cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=token_security)) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) + monkeypatch.setattr( + module, + "request", + _DummyRequest(headers={"Content-Type": "application/json", "X-TOKEN": "ok"}, json_body={}), + ) + res = _run(module.webhook("agent-1")) + assert hasattr(res, "status_code") + assert res.status_code == 200 + + module.REDIS_CONN.bucket_result = [0] + _assert_bad_request(_run(module.webhook("agent-1")), "Too many requests") + + module.REDIS_CONN.bucket_result = [1] + bad_limit = _anonymous_security(rate_limit={"limit": 0, "per": "minute"}) cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=bad_limit)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) _assert_bad_request(_run(module.webhook("agent-1")), "rate_limit.limit must be > 0") - bad_per = {"auth_type": "none", "rate_limit": {"limit": 1, "per": "week"}} + bad_per = _anonymous_security(rate_limit={"limit": 1, "per": "week"}) cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=bad_per)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) _assert_bad_request(_run(module.webhook("agent-1")), "Invalid rate_limit.per") module.REDIS_CONN.bucket_result = [0] module.REDIS_CONN.bucket_exc = None - denied = {"auth_type": "none", "rate_limit": {"limit": 1, "per": "minute"}} + denied = _anonymous_security(rate_limit={"limit": 1, "per": "minute"}) cvs = _make_webhook_cvs(module, params=_default_webhook_params(security=denied)) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) _assert_bad_request(_run(module.webhook("agent-1")), "Too many requests") @@ -869,7 +953,7 @@ def test_webhook_parse_request_branches(monkeypatch): module = _load_agents_app(monkeypatch) _patch_background_task(monkeypatch, module) - security = {"auth_type": "none"} + security = _anonymous_security() params = _default_webhook_params(security=security, content_types="application/json") cvs = _make_webhook_cvs(module, params=params) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) @@ -935,7 +1019,7 @@ def test_webhook_parse_request_branches(monkeypatch): def test_webhook_canvas_constructor_exception(monkeypatch): module = _load_agents_app(monkeypatch) - params = _default_webhook_params(security={"auth_type": "none"}) + params = _default_webhook_params(security=_anonymous_security()) cvs = _make_webhook_cvs(module, params=params) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) monkeypatch.setattr( @@ -1048,7 +1132,7 @@ def test_webhook_parse_request_form_and_raw_body_paths(monkeypatch): module = _load_agents_app(monkeypatch) _patch_background_task(monkeypatch, module) - security = {"auth_type": "none"} + security = _anonymous_security() def _run_with(params, req): cvs = _make_webhook_cvs(module, params=params) @@ -1137,7 +1221,7 @@ def test_webhook_schema_extract_cast_defaults_and_validation_errors(monkeypatch) } params = _default_webhook_params( - security={"auth_type": "none"}, + security=_anonymous_security(), content_types="application/json", schema=base_schema, ) @@ -1216,7 +1300,7 @@ def test_webhook_schema_extract_cast_defaults_and_validation_errors(monkeypatch) for schema, body_payload, expected_substring in failure_cases: params = _default_webhook_params( - security={"auth_type": "none"}, + security=_anonymous_security(), content_types="application/json", schema=schema, ) @@ -1238,7 +1322,7 @@ def test_webhook_immediate_response_status_and_template_validation(monkeypatch): def _run_case(response_cfg): params = _default_webhook_params( - security={"auth_type": "none"}, + security=_anonymous_security(), content_types="application/json", response=response_cfg, ) @@ -1301,8 +1385,9 @@ def test_webhook_background_run_success_and_error_trace_paths(monkeypatch): monkeypatch.setattr(module, "Canvas", _CanvasSuccess) - params = _default_webhook_params(security={"auth_type": "none"}, content_types="application/json") + params = _default_webhook_params(security=_anonymous_security(), content_types="application/json") cvs = _make_webhook_cvs(module, params=params) + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [cvs]) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) monkeypatch.setattr( module, @@ -1310,7 +1395,7 @@ def test_webhook_background_run_success_and_error_trace_paths(monkeypatch): _DummyRequest(path="/api/v1/agents/agent-1/webhook/test", headers={"Content-Type": "application/json"}, json_body={}), ) - res = _run(module.webhook("agent-1")) + res = _run(module.webhook_test(agent_id="agent-1")) assert res.status_code == 200 assert len(tasks) == 1 _run(tasks.pop(0)) @@ -1332,8 +1417,9 @@ def test_webhook_background_run_success_and_error_trace_paths(monkeypatch): tasks.clear() redis_store.clear() cvs = _make_webhook_cvs(module, params=params) + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [cvs]) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id, _cvs=cvs: (True, _cvs)) - res = _run(module.webhook("agent-1")) + res = _run(module.webhook_test(agent_id="agent-1")) assert res.status_code == 200 _run(tasks.pop(0)) trace_obj = json.loads(redis_store[key]) @@ -1348,8 +1434,9 @@ def test_webhook_background_run_success_and_error_trace_paths(monkeypatch): monkeypatch.setattr(module.REDIS_CONN, "set_obj", lambda *_args, **_kwargs: None) tasks.clear() cvs = _make_webhook_cvs(module, params=params) + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [cvs]) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id, _cvs=cvs: (True, _cvs)) - _run(module.webhook("agent-1")) + _run(module.webhook_test(agent_id="agent-1")) _run(tasks.pop(0)) assert any("Failed to append webhook trace" in msg for msg in log_messages) @@ -1363,11 +1450,12 @@ def test_webhook_sse_success_and_exception_paths(monkeypatch): monkeypatch.setattr(module.REDIS_CONN, "set_obj", lambda key, obj, _ttl: redis_store.__setitem__(key, json.dumps(obj))) params = _default_webhook_params( - security={"auth_type": "none"}, + security=_anonymous_security(), content_types="application/json", execution_mode="Deferred", ) cvs = _make_webhook_cvs(module, params=params) + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [cvs]) monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _id: (True, cvs)) class _CanvasSSESuccess(_StubCanvas): @@ -1383,7 +1471,7 @@ def test_webhook_sse_success_and_exception_paths(monkeypatch): "request", _DummyRequest(path="/api/v1/agents/agent-1/webhook/test", headers={"Content-Type": "application/json"}, json_body={}), ) - res = _run(module.webhook("agent-1")) + res = _run(module.webhook_test(agent_id="agent-1")) assert res.status_code == 201 payload = json.loads(_run(res.get_data(as_text=True))) assert payload == {"message": "Hello", "success": True, "code": 201} @@ -1399,7 +1487,7 @@ def test_webhook_sse_success_and_exception_paths(monkeypatch): "request", _DummyRequest(path="/api/v1/agents/agent-1/webhook/test", headers={"Content-Type": "application/json"}, json_body={}), ) - res = _run(module.webhook("agent-1")) + res = _run(module.webhook_test(agent_id="agent-1")) assert res.status_code == 400 payload = json.loads(_run(res.get_data(as_text=True))) assert payload["code"] == 400 diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 19b5a6ad14..25419c4f2f 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2881,6 +2881,9 @@ Important structured information may include: names, dates, locations, events, k 'Accepted Response: The system returns an acknowledgment immediately after the request is validated, while the workflow continues to execute asynchronously in the background. /Final Response: The system returns a response only after the workflow execution is completed.', authMethods: 'Authentication methods', authType: 'Authentication type', + allowAnonymous: 'Allow anonymous access', + allowAnonymousTip: + 'Anyone with this webhook URL can trigger the agent when this is enabled.', limit: 'Request frequency limit', per: 'Time period', maxBodySize: 'Maximum body size', diff --git a/web/src/pages/agent/form/begin-form/schema.ts b/web/src/pages/agent/form/begin-form/schema.ts index bcb58f00cd..396d71595a 100644 --- a/web/src/pages/agent/form/begin-form/schema.ts +++ b/web/src/pages/agent/form/begin-form/schema.ts @@ -29,6 +29,7 @@ export const BeginFormSchema = z.object({ per: z.string().optional(), }), max_body_size: z.string(), + allow_anonymous: z.boolean().optional(), jwt: z .object({ algorithm: z.string().default(WebhookJWTAlgorithmList[0]).optional(), diff --git a/web/src/pages/agent/form/begin-form/webhook/auth.tsx b/web/src/pages/agent/form/begin-form/webhook/auth.tsx index c1ba782086..a674e10db9 100644 --- a/web/src/pages/agent/form/begin-form/webhook/auth.tsx +++ b/web/src/pages/agent/form/begin-form/webhook/auth.tsx @@ -1,6 +1,7 @@ import { SelectWithSearch } from '@/components/originui/select-with-search'; import { RAGFlowFormItem } from '@/components/ragflow-form'; import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import { WebhookJWTAlgorithmList } from '@/constants/agent'; import { WebhookSecurityAuthType } from '@/pages/agent/constant'; import { buildOptions } from '@/utils/form'; @@ -100,7 +101,21 @@ export function Auth() { [WebhookSecurityAuthType.Token]: renderTokenAuth, [WebhookSecurityAuthType.Basic]: renderBasicAuth, [WebhookSecurityAuthType.Jwt]: renderJwtAuth, - [WebhookSecurityAuthType.None]: () => null, + [WebhookSecurityAuthType.None]: () => ( + + {(field) => ( + + )} + + ), }; return ( diff --git a/web/src/pages/agent/utils.ts b/web/src/pages/agent/utils.ts index b56c9b739c..ebab563c2f 100644 --- a/web/src/pages/agent/utils.ts +++ b/web/src/pages/agent/utils.ts @@ -456,6 +456,14 @@ function transformBeginParams(params: BeginFormSchemaType) { required_claims: security?.jwt?.required_claims.map((x) => x.value), }; } + if ( + params.security?.auth_type === WebhookSecurityAuthType.None && + params.security?.allow_anonymous + ) { + nextSecurity.allow_anonymous = true; + } else { + delete nextSecurity.allow_anonymous; + } return { ...params, schema: transformRequestSchemaToJsonschema(params.schema),