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:
dale053
2026-05-08 03:00:18 -07:00
committed by GitHub
parent ada6d47880
commit 26d70189b6
3 changed files with 132 additions and 13 deletions

View File

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