fix: require explicit anonymous webhook access (#14890)

### What problem does this PR solve?

Fixes #14882

Agent webhook execution currently fails open when the saved webhook
`security` block is missing/empty, or when `auth_type` is set to `none`.
This allows unauthenticated webhook invocation without an explicit
operator opt-in.

This PR makes anonymous webhook access explicit:
- Rejects missing or empty webhook security config.
- Requires `allow_anonymous: true` when `auth_type` is `none`.
- Preserves explicit anonymous webhooks by having the frontend serialize
`allow_anonymous: true` when the user selects `None` auth.
- Updates webhook unit tests to cover both denied implicit-anonymous
configs and allowed explicit-anonymous configs.

### Type of change

- [x] Bug Fix
- [x] Security hardening
- [x] Test

### Tests

- [x] `ZHIPU_AI_API_KEY=dummy uv run python -m pytest
--confcutdir=test/testcases/test_web_api/test_agent_app
test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py`
- [x] `uv run ruff check api/apps/restful_apis/agent_api.py
test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py`
- [x] `npm exec eslint src/pages/agent/utils.ts
src/pages/agent/form/begin-form/schema.ts`

---------

Co-authored-by: Zhichang Yu <yuzhichang@gmail.com>
This commit is contained in:
Renzo
2026-06-27 19:20:29 -10:00
committed by GitHub
parent bf18b59264
commit 6c872256a9
6 changed files with 191 additions and 32 deletions

View File

@@ -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/<agent_id>/webhook", methods=["POST", "GET", "PUT", "PATCH", "DELETE", "HEAD"]) # noqa: F821
@manager.route("/agents/<agent_id>/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/<agent_id>/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:

View File

@@ -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": "<think></think>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

View File

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

View File

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

View File

@@ -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]: () => (
<RAGFlowFormItem
name="security.allow_anonymous"
label={t('flow.webhook.allowAnonymous')}
tooltip={t('flow.webhook.allowAnonymousTip')}
horizontal
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
)}
</RAGFlowFormItem>
),
};
return (

View File

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