Fix: enforce tenant authorization on document download endpoint (#14618) (#14625)

### What problem does this PR solve?

Closes #14618.

The `GET /v1/document/get/<doc_id>` endpoint in
`api/apps/document_app.py` was protected only by `@login_required` and
called `DocumentService.get_by_id(doc_id)` without verifying that the
document's knowledge base belonged to the requesting user's tenant. Any
authenticated user who knew (or guessed) a document ID could download
files belonging to any other tenant — a cross-tenant IDOR.

This PR adds a `DocumentService.accessible(doc_id, current_user.id)`
check before serving the file. The helper already exists and joins
`Document` → `Knowledgebase` → `UserTenant` to verify the requesting
user belongs to the tenant that owns the document's KB. The same pattern
is already used by `api/apps/restful_apis/document_api.py` and mirrors
the tenant scoping in the SDK route at `api/apps/sdk/doc.py`.

The check returns the existing `"Document not found!"` error for both
non-existent and inaccessible documents, so attackers cannot use the
response to enumerate valid doc IDs across tenants.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] Other (please describe): Security fix (cross-tenant IDOR /
authorization bypass)
This commit is contained in:
web-dev0521
2026-05-08 02:24:03 -04:00
committed by GitHub
parent a82ae4a991
commit d51fb88573
2 changed files with 60 additions and 1 deletions

View File

@@ -23,7 +23,7 @@ from quart import request, make_response
from peewee import OperationalError
from pydantic import ValidationError
from api.apps import login_required
from api.apps import current_user, login_required
from api.constants import FILE_NAME_LEN_LIMIT, IMG_BASE64_PREFIX
from api.apps.services.document_api_service import validate_document_update_fields, map_doc_keys, \
map_doc_keys_with_run_status, update_document_name_only, update_chunk_method, update_document_status_only, \
@@ -1859,7 +1859,16 @@ async def batch_update_document_status(tenant_id, dataset_id):
@manager.route("/documents/<doc_id>/preview", methods=["GET"]) # noqa: F821
@login_required
async def get(doc_id):
"""Return the raw file bytes for a document the requesting user is authorized to read.
The user must belong to the tenant that owns the document's knowledge base; otherwise
the response is indistinguishable from a missing document to avoid cross-tenant ID
enumeration.
"""
try:
if not DocumentService.accessible(doc_id, current_user.id):
return get_data_error_result(message="Document not found!")
e, doc = DocumentService.get_by_id(doc_id)
if not e:
return get_data_error_result(message="Document not found!")
@@ -1884,10 +1893,19 @@ async def get(doc_id):
@login_required
@add_tenant_id_to_kwargs
async def download_attachment(tenant_id=None, doc_id=None, attachment_id=None):
"""Stream a document's underlying file to the requesting user.
Mirrors the authorization model of the preview endpoint: the user must belong
to the tenant that owns the document's knowledge base. A denial returns the
same "Document not found!" response so the endpoint cannot be used to
enumerate doc ids across tenants.
"""
try:
# Keep backward compatibility with older callers and unit tests that still
# pass `attachment_id` instead of the route parameter name.
doc_id = doc_id or attachment_id
if not DocumentService.accessible(doc_id, current_user.id):
return get_data_error_result(message="Document not found!")
ext = request.args.get("ext", "markdown")
data = await thread_pool_exec(settings.STORAGE_IMPL.get, tenant_id, doc_id)
response = await make_response(data)