fix(api): gate sandbox artifact download on agent session ownership (#16169)

Fixes #16168

## Summary
- Add session-scoped authorization for `GET
/api/v1/documents/artifact/<filename>`
- Allow download only when the artifact filename appears in the caller's
`api_4_conversation` message and
`UserCanvasService.accessible(dialog_id, user_id)` passes
- Deny with generic `"Artifact not found."` before storage access (no
cross-user enumeration)
- Return 4xx when the blob is missing (existing behavior preserved)

## Approach
Sandbox artifacts are runtime CodeExec outputs, not KB documents — this
uses the same session gate pattern as `agent_chat_completion`, not
`DocumentService.accessible`.

## Test plan
- [x] Unit: denied when filename not referenced in user sessions
- [x] Unit: denied when agent canvas is not accessible
- [x] Unit: authorized user receives bytes; missing blob returns
`"Artifact not found."`
- [ ] `pytest
test/testcases/test_web_api/test_document_app/test_document_metadata.py
-k get_artifact`

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Zhichang Yu <yuzhichang@gmail.com>
This commit is contained in:
kpdev
2026-06-27 22:24:15 -07:00
committed by GitHub
parent e16d1a0150
commit 4712fcb1b3
2 changed files with 103 additions and 1 deletions

View File

@@ -641,6 +641,79 @@ class TestDocumentMetadataUnit:
assert res["code"] == RetCode.DATA_ERROR
assert "Image not found" in res["message"]
@pytest.mark.p2
def test_get_artifact_denied_without_session_reference_unit(self, document_app_module, monkeypatch):
module = document_app_module
filename = "a1b2c3d4e5f6789012345678901234abcd.png"
monkeypatch.setattr(module, "_sandbox_artifact_dialog_ids_for_user", lambda *_args, **_kwargs: [])
res = _run(module.get_artifact(filename))
assert res["code"] == RetCode.DATA_ERROR
assert res["message"] == "Artifact not found."
@pytest.mark.p2
def test_get_artifact_denied_when_agent_not_accessible_unit(self, document_app_module, monkeypatch):
module = document_app_module
filename = "a1b2c3d4e5f6789012345678901234abcd.png"
monkeypatch.setattr(module, "_sandbox_artifact_dialog_ids_for_user", lambda *_args, **_kwargs: ["agent-1"])
monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False)
res = _run(module.get_artifact(filename))
assert res["code"] == RetCode.DATA_ERROR
assert res["message"] == "Artifact not found."
@pytest.mark.p2
def test_get_artifact_success_and_missing_blob_unit(self, document_app_module, monkeypatch):
module = document_app_module
filename = "a1b2c3d4e5f6789012345678901234abcd.png"
class _Headers(dict):
def set(self, key, value):
self[key] = value
class _ArtifactResponse:
def __init__(self, data):
self.data = data
self.headers = _Headers()
monkeypatch.setattr(module, "_sandbox_artifact_dialog_ids_for_user", lambda *_args, **_kwargs: ["agent-1"])
monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True)
async def fake_thread_pool_exec(fn, *args, **kwargs):
return fn(*args, **kwargs)
async def fake_make_response(data):
return _ArtifactResponse(data)
monkeypatch.setattr(module, "thread_pool_exec", fake_thread_pool_exec)
monkeypatch.setattr(module, "make_response", fake_make_response)
monkeypatch.setattr(
module,
"apply_safe_file_response_headers",
lambda response, content_type, extension: response.headers.update(
{"content_type": content_type, "extension": extension}
),
)
monkeypatch.setattr(
module.settings,
"STORAGE_IMPL",
SimpleNamespace(get=lambda *_args, **_kwargs: b"artifact-bytes"),
)
res = _run(module.get_artifact(filename))
assert isinstance(res, _ArtifactResponse)
assert res.data == b"artifact-bytes"
assert res.headers["content_type"] == "image/png"
monkeypatch.setattr(
module.settings,
"STORAGE_IMPL",
SimpleNamespace(get=lambda *_args, **_kwargs: None),
)
res = _run(module.get_artifact(filename))
assert res["code"] == RetCode.DATA_ERROR
assert res["message"] == "Artifact not found."
class TestDocumentBatchChangeStatus:
@pytest.mark.p2