From 2400e70cea5c560897de221b5916699945762281 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 22 May 2026 20:37:47 +0300 Subject: [PATCH 1/4] fix(backend): filter in-memory invocations by owner in list_wikis The DB query in WikiManagementService.list_wikis correctly filters by owner_id / visibility, but the loop that appends active in-memory invocations iterated all invocations with no ownership check, leaking other users' in-progress and failed builds into every user's wiki list (visible in the dashboard, 404 when opened). Mirror the same rule: skip invocations owned by someone other than the caller. Legacy unowned invocations (owner_id=="") stay visible to all. Shared visibility is not tracked on Invocation (in-progress wikis have no visibility yet); completed shared wikis reach the list via the DB path which already handles them correctly. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/routes.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 74a80a3..6e58ebd 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -434,8 +434,13 @@ async def list_wikis( wiki.progress = best_inv.progress wiki.error = None - # Add active/failed invocations not yet in completed list + # Add active/failed invocations not yet in completed list. + # Mirror the DB visibility rule: own wikis + legacy unowned (owner_id==""). + # Owned invocations from other users are never shown — the caller would + # get a 404 trying to open them, so leaking them in the list is wrong. for inv in service.invocations.values(): + if inv.owner_id and user_id and inv.owner_id != user_id: + continue if inv.wiki_id not in completed_wiki_ids: # Auto-register completed invocations into DB so get_wiki works if inv.status == "complete" and inv.repo_url: From 46355d44e7117974f473a0e25d71d279503db8f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 22 May 2026 20:54:55 +0300 Subject: [PATCH 2/4] fix(backend): tighten ownership guard + add regression tests for list_wikis leak - Normalize user_id with `or None` so an empty-string user.id (JWT missing sub) doesn't bypass the filter - Change guard from `and user_id` to `not user_id or inv.owner_id != user_id` so callers with no valid user_id also can't see other users' invocations (mirrors DB rule: anonymous sees only shared + legacy unowned) - Add two regression tests: one verifying the ownership split across three invocations (own / other-user / legacy), one covering the empty-string user_id edge case Flaky test note: test_generate_chapter::test_oversized_content_is_kept_not_split fails intermittently in CI under -n auto because generate_chapter runs sub-pages concurrently via asyncio.gather + run_in_executor, making SequentialFakeLLM response order non-deterministic. Pre-existing; unrelated to this change. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/routes.py | 4 +- backend/tests/unit/test_get_wiki_is_owner.py | 72 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 6e58ebd..367c494 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -395,7 +395,7 @@ async def list_wikis( management: WikiManagementService = Depends(get_wiki_management), service: WikiService = Depends(get_wiki_service), ) -> WikiListResponse: - user_id = user.id if user else None + user_id = (user.id or None) if user else None result = await management.list_wikis(user_id=user_id) completed_wiki_ids = {w.wiki_id for w in result.wikis} @@ -439,7 +439,7 @@ async def list_wikis( # Owned invocations from other users are never shown — the caller would # get a 404 trying to open them, so leaking them in the list is wrong. for inv in service.invocations.values(): - if inv.owner_id and user_id and inv.owner_id != user_id: + if inv.owner_id and (not user_id or inv.owner_id != user_id): continue if inv.wiki_id not in completed_wiki_ids: # Auto-register completed invocations into DB so get_wiki works diff --git a/backend/tests/unit/test_get_wiki_is_owner.py b/backend/tests/unit/test_get_wiki_is_owner.py index 004619c..9173acc 100644 --- a/backend/tests/unit/test_get_wiki_is_owner.py +++ b/backend/tests/unit/test_get_wiki_is_owner.py @@ -487,3 +487,75 @@ async def test_list_wikis_override_fires_for_running_incremental_refresh( assert resp.json()["wikis"][0]["status"] == "running" assert resp.json()["wikis"][0]["progress"] == 0.3 + + +# --------------------------------------------------------------------------- +# GET /api/v1/wikis — in-memory invocation ownership filter +# --------------------------------------------------------------------------- + + +def _make_foreign_invocation(wiki_id: str, owner_id: str, status: str = "generating") -> MagicMock: + inv = MagicMock() + inv.id = f"inv-{wiki_id}" + inv.wiki_id = wiki_id + inv.repo_url = f"https://github.com/example/{wiki_id}" + inv.branch = "main" + inv.status = status + inv.progress = 0.5 + inv.error = None + inv.pages_completed = 0 + inv.created_at = datetime(2024, 1, 3) + inv.owner_id = owner_id + return inv + + +@pytest.mark.asyncio +async def test_list_wikis_hides_other_users_in_progress_invocations(app_with_mocks): + """Invocations owned by other users must not appear in the caller's list.""" + app, mock_management, mock_service = app_with_mocks + + wiki_list = MagicMock() + wiki_list.wikis = [] + mock_management.list_wikis = AsyncMock(return_value=wiki_list) + mock_management.storage.list_artifacts = AsyncMock(return_value=[]) + + mock_service.invocations = { + "own": _make_foreign_invocation("wiki-own", owner_id="user-1"), + "other": _make_foreign_invocation("wiki-other", owner_id="user-2"), + "legacy": _make_foreign_invocation("wiki-legacy", owner_id=""), + } + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/v1/wikis") + + returned_ids = {w["wiki_id"] for w in resp.json()["wikis"]} + assert "wiki-own" in returned_ids, "caller's own invocation must be included" + assert "wiki-legacy" in returned_ids, "legacy unowned invocation must be included" + assert "wiki-other" not in returned_ids, "other user's invocation must be excluded" + + +@pytest.mark.asyncio +async def test_list_wikis_empty_string_user_id_does_not_leak_owned_invocations(app_with_mocks): + """user_id normalised to None when user.id is '' so the filter still runs.""" + app, mock_management, mock_service = app_with_mocks + + # Override the user dependency to return an empty-string id + from app.auth import get_current_user + app.dependency_overrides[get_current_user] = lambda: MagicMock(id="") + + wiki_list = MagicMock() + wiki_list.wikis = [] + mock_management.list_wikis = AsyncMock(return_value=wiki_list) + mock_management.storage.list_artifacts = AsyncMock(return_value=[]) + + mock_service.invocations = { + "other": _make_foreign_invocation("wiki-other", owner_id="user-2"), + "legacy": _make_foreign_invocation("wiki-legacy", owner_id=""), + } + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/v1/wikis") + + returned_ids = {w["wiki_id"] for w in resp.json()["wikis"]} + assert "wiki-other" not in returned_ids, "owned invocations must not leak when caller has empty user id" + assert "wiki-legacy" in returned_ids, "legacy unowned invocations still visible" From 981fe15a3ac196c13d146b2a287049b46eb54379 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 22 May 2026 21:01:45 +0300 Subject: [PATCH 3/4] style(tests): ruff format test_get_wiki_is_owner.py Remove manual alignment spaces flagged by Ruff E221/E241/E272. Co-Authored-By: Claude Sonnet 4.6 --- backend/tests/unit/test_get_wiki_is_owner.py | 40 +++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/backend/tests/unit/test_get_wiki_is_owner.py b/backend/tests/unit/test_get_wiki_is_owner.py index 9173acc..ee709d2 100644 --- a/backend/tests/unit/test_get_wiki_is_owner.py +++ b/backend/tests/unit/test_get_wiki_is_owner.py @@ -15,10 +15,11 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from app.core.deep_research.research_engine import DeepResearchEngine from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from app.core.deep_research.research_engine import DeepResearchEngine + # --------------------------------------------------------------------------- # DeepResearchEngine._get_repo_context tests # --------------------------------------------------------------------------- @@ -309,7 +310,8 @@ async def test_get_wiki_override_clears_stale_error_when_refresh_is_live( staticmethod(lambda record, user_id: wiki_summary), ) async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test", + transport=ASGITransport(app=app), + base_url="http://test", ) as client: resp = await client.get("/api/v1/wikis/wiki-1") @@ -344,12 +346,15 @@ async def test_get_wiki_override_fires_for_running_incremental_refresh( mock_service.invocations = { # Stale terminal invocation comes first in iteration order. "inv-old-failed": _make_inflight_invocation( - status="failed", invocation_id="inv-old-failed", + status="failed", + invocation_id="inv-old-failed", ), # The live in-progress run is second — the loop must still # prefer it. "inv-r": _make_inflight_invocation( - status="running", progress=0.3, invocation_id="inv-r", + status="running", + progress=0.3, + invocation_id="inv-r", ), } @@ -362,7 +367,8 @@ async def test_get_wiki_override_fires_for_running_incremental_refresh( staticmethod(lambda record, user_id: wiki_summary), ) async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test", + transport=ASGITransport(app=app), + base_url="http://test", ) as client: resp = await client.get("/api/v1/wikis/wiki-1") @@ -399,7 +405,8 @@ async def test_get_wiki_terminal_invocation_does_not_override(app_with_mocks): staticmethod(lambda record, user_id: wiki_summary), ) async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test", + transport=ASGITransport(app=app), + base_url="http://test", ) as client: resp = await client.get("/api/v1/wikis/wiki-1") @@ -434,7 +441,8 @@ async def test_list_wikis_override_clears_stale_error_when_refresh_is_live( app, mock_management, mock_service = app_with_mocks wiki_summary = _make_serializable_wiki_summary( - status="failed", is_owner=True, + status="failed", + is_owner=True, error="Git clone failed (from prior attempt)", ) @@ -448,7 +456,8 @@ async def test_list_wikis_override_clears_stale_error_when_refresh_is_live( } async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test", + transport=ASGITransport(app=app), + base_url="http://test", ) as client: resp = await client.get("/api/v1/wikis") @@ -468,7 +477,8 @@ async def test_list_wikis_override_fires_for_running_incremental_refresh( app, mock_management, mock_service = app_with_mocks wiki_summary = _make_serializable_wiki_summary( - status="complete", is_owner=True, + status="complete", + is_owner=True, ) wiki_list = MagicMock() @@ -481,7 +491,8 @@ async def test_list_wikis_override_fires_for_running_incremental_refresh( } async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test", + transport=ASGITransport(app=app), + base_url="http://test", ) as client: resp = await client.get("/api/v1/wikis") @@ -520,8 +531,8 @@ async def test_list_wikis_hides_other_users_in_progress_invocations(app_with_moc mock_management.storage.list_artifacts = AsyncMock(return_value=[]) mock_service.invocations = { - "own": _make_foreign_invocation("wiki-own", owner_id="user-1"), - "other": _make_foreign_invocation("wiki-other", owner_id="user-2"), + "own": _make_foreign_invocation("wiki-own", owner_id="user-1"), + "other": _make_foreign_invocation("wiki-other", owner_id="user-2"), "legacy": _make_foreign_invocation("wiki-legacy", owner_id=""), } @@ -529,7 +540,7 @@ async def test_list_wikis_hides_other_users_in_progress_invocations(app_with_moc resp = await client.get("/api/v1/wikis") returned_ids = {w["wiki_id"] for w in resp.json()["wikis"]} - assert "wiki-own" in returned_ids, "caller's own invocation must be included" + assert "wiki-own" in returned_ids, "caller's own invocation must be included" assert "wiki-legacy" in returned_ids, "legacy unowned invocation must be included" assert "wiki-other" not in returned_ids, "other user's invocation must be excluded" @@ -541,6 +552,7 @@ async def test_list_wikis_empty_string_user_id_does_not_leak_owned_invocations(a # Override the user dependency to return an empty-string id from app.auth import get_current_user + app.dependency_overrides[get_current_user] = lambda: MagicMock(id="") wiki_list = MagicMock() @@ -549,7 +561,7 @@ async def test_list_wikis_empty_string_user_id_does_not_leak_owned_invocations(a mock_management.storage.list_artifacts = AsyncMock(return_value=[]) mock_service.invocations = { - "other": _make_foreign_invocation("wiki-other", owner_id="user-2"), + "other": _make_foreign_invocation("wiki-other", owner_id="user-2"), "legacy": _make_foreign_invocation("wiki-legacy", owner_id=""), } From 23aefb58ace871d651b341131f2155e07105827d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 22 May 2026 21:28:05 +0300 Subject: [PATCH 4/4] fix(backend): normalize user_id and fix best_inv metadata leak in list_wikis - Normalize user_id with `or None` in all routes (not just list_wikis) so an empty-string user.id can never bypass ownership checks - Fix get_wiki B2 redaction: change `and user_id and` to `and (not user_id or ...)` so the same guard pattern is used everywhere - Fix best_inv enrichment loop in list_wikis: skip invocations owned by other users when selecting status/progress to attach to a shared/legacy wiki, matching the B2 behaviour already in get_wiki Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/routes.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 367c494..635d316 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -324,7 +324,7 @@ async def ask( qa_service: QAService = Depends(get_qa_service), accept: str = "application/json", ) -> AskResponse | StreamingResponse: - user_id = user.id if user else None + user_id = (user.id or None) if user else None try: if "text/event-stream" in accept: # SSE: generator handles its own recording via try/finally @@ -355,7 +355,7 @@ async def research( service: ResearchService = Depends(get_research_service), accept: str = "application/json", ) -> ResearchResponse | StreamingResponse: - user_id = user.id if user else None + user_id = (user.id or None) if user else None try: if request.research_type == "codemap": if "text/event-stream" in accept: @@ -415,6 +415,9 @@ async def list_wikis( best_inv = None for inv in service.invocations.values(): if inv.wiki_id == wiki.wiki_id: + # Mirror B2: don't attach another user's invocation metadata + if inv.owner_id and (not user_id or inv.owner_id != user_id): + continue if inv.status in ("generating", "running"): best_inv = inv break @@ -539,7 +542,7 @@ async def get_wiki( service: WikiService = Depends(get_wiki_service), ) -> dict: """Get wiki detail with pages and their content.""" - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki_record = await management.get_wiki(wiki_id, user_id=user_id) wiki_meta = WikiManagementService._record_to_summary(wiki_record, user_id) if wiki_record else None @@ -560,7 +563,7 @@ async def get_wiki( active_invocation = inv # keep first match as fallback # B2: Don't leak in-flight invocation metadata to non-owners - if active_invocation and user_id and active_invocation.owner_id and active_invocation.owner_id != user_id: + if active_invocation and active_invocation.owner_id and (not user_id or active_invocation.owner_id != user_id): active_invocation = None # If DB has a record in a non-complete state (new: registered at generation start), @@ -754,7 +757,7 @@ async def search_wiki( """Full-text search over wiki pages with graph-expansion re-ranking.""" from app.core.wiki_search_engine import WikiSearchEngine - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki = await management.get_wiki(wiki_id, user_id=user_id) if wiki is None: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -802,7 +805,7 @@ async def list_wiki_pages( index_cache=Depends(get_wiki_index_cache), ) -> WikiPageListResponse: """List all pages in a wiki with their titles and descriptions.""" - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki = await management.get_wiki(wiki_id, user_id=user_id) if wiki is None: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -836,7 +839,7 @@ async def get_page_neighbors( index_cache=Depends(get_wiki_index_cache), ) -> PageNeighborsResponse: """Return the wikilink graph neighborhood for a single wiki page.""" - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki = await management.get_wiki(wiki_id, user_id=user_id) if wiki is None: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -866,7 +869,7 @@ async def get_wiki_page_by_title( index_cache=Depends(get_wiki_index_cache), ) -> WikiPageResponse: """Return the full content of a single wiki page identified by its title.""" - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki = await management.get_wiki(wiki_id, user_id=user_id) if wiki is None: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -907,7 +910,7 @@ async def get_wiki_page( ) -> dict: """Get a single wiki page content. page_id can be section/page format.""" # Access control — verify caller can view this wiki - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki_record = await management.get_wiki(wiki_id, user_id=user_id) if wiki_record is None: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -970,7 +973,7 @@ async def list_qa( status: QAStatus | None = Query(default=None), ) -> QAListResponse: """Paginated Q&A history for a wiki.""" - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki = await management.get_wiki(wiki_id, user_id=user_id) if not wiki: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -987,7 +990,7 @@ async def qa_stats( management: WikiManagementService = Depends(get_wiki_management), ) -> QAStatsResponse: """QA statistics for a wiki.""" - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki = await management.get_wiki(wiki_id, user_id=user_id) if not wiki: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -1069,7 +1072,7 @@ async def diff_wiki( # implementation detail of the repo, not shareable user data, so we # don't honor the wiki's shared-visibility flag for this endpoint # (unlike /search). Mirrors the /refresh guard. - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki = await management.get_wiki(wiki_id, user_id=user_id) if wiki is None: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -1176,7 +1179,7 @@ async def incremental_refresh_wiki( Owner-only — same access model as ``/refresh`` since this surfaces internal content_hash + node_id state through the per-page events. """ - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki_record = await management.get_wiki(wiki_id, user_id=user_id) if wiki_record is None: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -1236,7 +1239,7 @@ async def delete_wiki( management: WikiManagementService = Depends(get_wiki_management), service: WikiService = Depends(get_wiki_service), ) -> DeleteWikiResponse: - user_id = user.id if user else None + user_id = (user.id or None) if user else None settings = request.app.state.settings result = await management.delete_wiki(wiki_id, user_id=user_id, cache_dir=settings.cache_dir) @@ -1347,7 +1350,7 @@ async def export_wiki( 400, f"Invalid format '{format}'. Must be one of: {', '.join(sorted(_VALID_EXPORT_FORMATS))}" ) - user_id = user.id if user else None + user_id = (user.id or None) if user else None wiki_record = await management.get_wiki(wiki_id, user_id=user_id) if wiki_record is None: raise HTTPException(404, f"Wiki not found: {wiki_id}") @@ -1407,7 +1410,7 @@ async def import_wiki( import_service: ImportService = Depends(get_import_service), ) -> WikiSummary: """Import a wiki from a ``.wikiexport`` bundle produced by the wikis export.""" - user_id = user.id if user else None + user_id = (user.id or None) if user else None # Size guard — reject overly large uploads before reading if bundle.size is not None and bundle.size > _MAX_IMPORT_BYTES: @@ -1811,7 +1814,7 @@ async def project_codemap( Proxies to the codemap pipeline with ``project_id`` set and ``research_type=codemap``. """ - user_id = user.id if user else None + user_id = (user.id or None) if user else None # Verify the project exists and is accessible project = await svc.get_project(project_id, user_id=user_id or "")