mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-05 15:20:30 +08:00
fix: enforce tenant-scoped authorization for chatbot SDK endpoints (#14592)
Closes #14590 ## Self Checks - [x] I have searched for existing issues [search for existing issues](https://github.com/infiniflow/ragflow/issues), including closed ones. - [x] I confirm that I am using English to submit this report ([Language Policy](https://github.com/infiniflow/ragflow/issues/5910)). - [x] Non-english title submitions will be closed directly ( 非英文标题的提交将会被直接关闭 ) ([Language Policy](https://github.com/infiniflow/ragflow/issues/5910)). - [x] Please do not modify this template :) and fill in all the required fields. ## RAGFlow workspace code commit ID `a1b2c3d4e5f67890123456789abcdef12345678` ## RAGFlow image version `0.13.1` ## Other environment information - Hardware parameters: N/A - OS type: Linux 6.17.0-22-generic - Others: API key authentication via `Authorization: Bearer <token>` ## Actual behavior The chatbot API endpoints: - `POST /chatbots/<dialog_id>/completions` - `GET /chatbots/<dialog_id>/info` validate only that the bearer token exists in `APIToken`, but do not verify that `dialog_id` belongs to the same tenant as that token. Current flow (simplified): 1. Route extracts bearer token and checks `APIToken.query(beta=token)`. 2. If token exists, request is accepted. 3. Downstream service resolves dialog globally by ID (`DialogService.get_by_id(dialog_id)` in `conversation_service.py`). 4. No tenant ownership check is enforced for `dialog_id`. Impact: Any user with a valid API key can attempt arbitrary `dialog_id` values and access/invoke chatbots outside their own tenant boundary if IDs are known/guessed/leaked. Security classification: - Vulnerability class: Broken Access Control (IDOR, OWASP Top 10 A01) - Severity recommendation: Critical - Exploit prerequisite: any valid API key + discoverable target `dialog_id` ## Expected behavior Requests to `/chatbots/<dialog_id>/completions` and `/chatbots/<dialog_id>/info` must be authorized only when: 1. bearer token is valid, and 2. `dialog_id` belongs to the same `tenant_id` as the token. Otherwise, reject with authorization failure (e.g., 403 or 404-equivalent policy). ## Steps to reproduce 1. Prepare two tenants: - Tenant A with API key `TOKEN_A` - Tenant B with chatbot `dialog_id = DIALOG_B` 2. Send request from Tenant A to Tenant B chatbot completion endpoint: ```bash curl -X POST "https://<host>/chatbots/DIALOG_B/completions" \ -H "Authorization: Bearer TOKEN_A" \ -H "Content-Type: application/json" \ -d '{"question":"hello","stream":false}' ``` 3. Observe request is processed (or reaches dialog resolution) without tenant ownership rejection. 4. Repeat against info endpoint: ```bash curl -X GET "https://<host>/chatbots/DIALOG_B/info" \ -H "Authorization: Bearer TOKEN_A" ``` 5. Observe the same missing ownership enforcement. ## Additional information Affected code paths: - `api/apps/sdk/session.py` - `chatbot_completions(dialog_id)` - `chatbots_inputs(dialog_id)` - `api/db/services/conversation_service.py` - `async_iframe_completion(...)` uses global dialog lookup Suggested fix: 1. In both chatbot endpoints: - Resolve `tenant_id = objs[0].tenant_id` from validated token. - Fetch dialog with tenant-scoped query (`DialogService.query(id=dialog_id, tenant_id=tenant_id)`). - Reject if dialog is not found/owned by tenant. 2. Defense in depth: - Require and enforce `tenant_id` in service-layer dialog resolution for external flows. - Avoid global `get_by_id(dialog_id)` where user-controlled dialog IDs are reachable. 3. Add regression tests: - Positive: same-tenant token + dialog succeeds. - Negative: cross-tenant token + dialog fails for both endpoints.
This commit is contained in:
@@ -1292,6 +1292,13 @@ def test_chatbot_routes_auth_stream_nonstream_unit(monkeypatch):
|
||||
res = _run(inspect.unwrap(module.chatbot_completions)("dialog-1"))
|
||||
assert "API key is invalid" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer ok"}))
|
||||
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")])
|
||||
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _dialog_id: (False, None))
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"stream": False}))
|
||||
res = _run(inspect.unwrap(module.chatbot_completions)("dialog-unauthorized"))
|
||||
assert res["message"] == "Authentication error: no access to this chatbot!"
|
||||
|
||||
stream_calls = []
|
||||
|
||||
async def _iframe_stream(dialog_id, **req):
|
||||
@@ -1301,6 +1308,11 @@ def test_chatbot_routes_auth_stream_nonstream_unit(monkeypatch):
|
||||
monkeypatch.setattr(module, "iframe_completion", _iframe_stream)
|
||||
monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer ok"}))
|
||||
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")])
|
||||
monkeypatch.setattr(
|
||||
module.DialogService,
|
||||
"get_by_id",
|
||||
lambda _dialog_id: (True, SimpleNamespace(id="dialog-1", tenant_id="tenant-1", status="1")),
|
||||
)
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"stream": True}))
|
||||
resp = _run(inspect.unwrap(module.chatbot_completions)("dialog-1"))
|
||||
assert isinstance(resp, _StubResponse)
|
||||
@@ -1308,11 +1320,17 @@ def test_chatbot_routes_auth_stream_nonstream_unit(monkeypatch):
|
||||
_run(_collect_stream(resp.body))
|
||||
assert stream_calls[-1][0] == "dialog-1"
|
||||
assert stream_calls[-1][1]["quote"] is False
|
||||
assert stream_calls[-1][1]["tenant_id"] == "tenant-1"
|
||||
|
||||
async def _iframe_nonstream(_dialog_id, **_req):
|
||||
yield {"answer": "non-stream"}
|
||||
|
||||
monkeypatch.setattr(module, "iframe_completion", _iframe_nonstream)
|
||||
monkeypatch.setattr(
|
||||
module.DialogService,
|
||||
"get_by_id",
|
||||
lambda _dialog_id: (True, SimpleNamespace(id="dialog-1", tenant_id="tenant-1", status="1")),
|
||||
)
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"stream": False, "quote": True}))
|
||||
res = _run(inspect.unwrap(module.chatbot_completions)("dialog-1"))
|
||||
assert res["data"]["answer"] == "non-stream"
|
||||
@@ -1329,8 +1347,27 @@ def test_chatbot_routes_auth_stream_nonstream_unit(monkeypatch):
|
||||
monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer ok"}))
|
||||
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")])
|
||||
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _dialog_id: (False, None))
|
||||
|
||||
res = _run(inspect.unwrap(module.chatbots_inputs)("dialog-404"))
|
||||
assert res["message"] == "Can't find dialog by ID: dialog-404"
|
||||
assert res["message"] == "Authentication error: no access to this chatbot!"
|
||||
|
||||
# Happy path: valid token + owned dialog -> correct payload
|
||||
stub_dialog = SimpleNamespace(
|
||||
name="My Bot",
|
||||
icon="avatar.png",
|
||||
tenant_id="tenant-1",
|
||||
status="1",
|
||||
prompt_config={"prologue": "Hello!", "tavily_api_key": "key123"},
|
||||
)
|
||||
monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer ok"}))
|
||||
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")])
|
||||
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _dialog_id: (True, stub_dialog))
|
||||
res = _run(inspect.unwrap(module.chatbots_inputs)("dialog-404"))
|
||||
assert res["code"] == 0
|
||||
assert res["data"]["title"] == "My Bot"
|
||||
assert res["data"]["avatar"] == "avatar.png"
|
||||
assert res["data"]["prologue"] == "Hello!"
|
||||
assert res["data"]["has_tavily_key"] is True
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
|
||||
Reference in New Issue
Block a user