From 3402e53c31f0855bef5188e5aebb9d50e7d0aaf4 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Mon, 31 Aug 2026 16:09:17 +1200 Subject: [PATCH] Protect advanced request diagnostics for non-admins --- backend/app/routers/requests.py | 28 ++++++--- backend/tests/test_backend_quality.py | 81 ++++++++++++++++++++++++++- frontend/app/requests/[id]/page.tsx | 41 +++++++++----- 3 files changed, 129 insertions(+), 21 deletions(-) diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index f9eb3e4..721efa2 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -255,13 +255,25 @@ def _user_can_use_search_auto(user: Dict[str, Any]) -> bool: return bool(user.get("auto_search_enabled", True)) -def _filter_snapshot_actions_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot: - if _user_can_use_search_auto(user): - return snapshot - snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"] +def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot: + if not _user_can_use_search_auto(user): + snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"] + if user.get("role") != "admin": + # The standard request view is intentionally collaborative, but service payloads can + # contain requester identities, internal URLs, download hashes and diagnostic errors. + snapshot.timeline = [] + snapshot.raw = {} return snapshot +def _require_advanced_request_access(user: Dict[str, Any]) -> None: + if user.get("role") != "admin": + raise HTTPException( + status_code=403, + detail="Advanced request details are available to administrators only", + ) + + def _quality_profile_id(value: Any) -> Optional[int]: if isinstance(value, int): return value @@ -1766,7 +1778,7 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre if client.configured(): await _ensure_request_access(client, int(request_id), user) snapshot = await build_snapshot(request_id) - return _filter_snapshot_actions_for_user(snapshot, user) + return _filter_snapshot_for_user(snapshot, user) @router.post("/{request_id}/actions/recheck") @@ -1825,7 +1837,7 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur _cache_set(f"request:{request_id}", fresh_request) _refresh_recent_cache_from_db() - snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user) + snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user) status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated") message = f"Recheck complete. {status_label}." await asyncio.to_thread( @@ -2403,7 +2415,7 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_ client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if client.configured(): await _ensure_request_access(client, int(request_id), user) - snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user) + snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user) return triage_snapshot(snapshot) @@ -2759,6 +2771,7 @@ async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_curre async def request_history( request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user) ) -> dict: + _require_advanced_request_access(user) runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if client.configured(): @@ -2771,6 +2784,7 @@ async def request_history( async def request_actions( request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user) ) -> dict: + _require_advanced_request_access(user) runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if client.configured(): diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 5d9524b..c61ab74 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -12,7 +12,7 @@ from backend.app import db from backend.app.auth import require_admin from backend.app.config import settings from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url -from backend.app.models import NormalizedState, RequestType, Snapshot, TimelineHop +from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop from backend.app.routers import auth as auth_router from backend.app.routers import portal as portal_router from backend.app.routers import requests as requests_router @@ -223,6 +223,85 @@ class RequestCacheTests(unittest.TestCase): self.assertEqual(requests_router._cache_get(key), {"id": 123}) +class RequestVisibilityTests(unittest.TestCase): + def test_non_admin_snapshot_excludes_advanced_identifying_data(self) -> None: + snapshot = Snapshot( + request_id="3925", + title="Example", + timeline=[ + TimelineHop( + service="Seerr", + status="approved", + details={"requestedBy": "viewer@example.com"}, + ) + ], + raw={ + "jellyseerr": {"requestedBy": {"email": "viewer@example.com"}}, + "qbittorrent": {"downloadIds": ["secret-hash"]}, + }, + actions=[ + ActionOption( + id="search_releases", + label="Search and choose a download", + risk="safe", + ) + ], + presentation={"status": {"label": "Needs attention"}}, + ) + + filtered = requests_router._filter_snapshot_for_user( + snapshot, {"username": "helper", "role": "user"} + ) + + self.assertEqual(filtered.timeline, []) + self.assertEqual(filtered.raw, {}) + self.assertEqual([action.id for action in filtered.actions], ["search_releases"]) + self.assertEqual(filtered.presentation["status"]["label"], "Needs attention") + + def test_admin_snapshot_retains_advanced_diagnostics(self) -> None: + snapshot = Snapshot( + request_id="3925", + title="Example", + timeline=[TimelineHop(service="Seerr", status="approved")], + raw={"jellyseerr": {"id": 3925}}, + ) + + filtered = requests_router._filter_snapshot_for_user( + snapshot, {"username": "admin", "role": "admin"} + ) + + self.assertEqual(len(filtered.timeline), 1) + self.assertEqual(filtered.raw["jellyseerr"]["id"], 3925) + + def test_non_admin_cannot_request_advanced_history(self) -> None: + with self.assertRaises(HTTPException) as context: + requests_router._require_advanced_request_access( + {"username": "helper", "role": "user"} + ) + + self.assertEqual(context.exception.status_code, 403) + + def test_my_requests_cache_only_returns_signed_in_users_rows(self) -> None: + previous = dict(requests_router._recent_cache) + requests_router._recent_cache["items"] = [ + {"request_id": 100, "requested_by_id": 7, "requested_by_norm": "zak"}, + {"request_id": 101, "requested_by_id": 8, "requested_by_norm": "someone-else"}, + ] + try: + rows = requests_router._get_recent_from_cache( + requested_by_norm="zak", + requested_by_id=7, + limit=10, + offset=0, + since_iso=None, + ) + finally: + requests_router._recent_cache.clear() + requests_router._recent_cache.update(previous) + + self.assertEqual([row["request_id"] for row in rows], [100]) + + class RequestPresentationTests(unittest.TestCase): def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None: self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4) diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index a171ac9..ad1cd90 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -275,6 +275,7 @@ export default function RequestTimelinePage() { const [historySnapshots, setHistorySnapshots] = useState([]) const [historyActions, setHistoryActions] = useState([]) const [operationProgress, setOperationProgress] = useState(null) + const [isAdmin, setIsAdmin] = useState(false) useEffect(() => { if (!requestId) return @@ -287,29 +288,43 @@ export default function RequestTimelinePage() { return } const baseUrl = getApiBase() - const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([ + const [meResponse, snapshotResponse] = await Promise.all([ + authFetch(`${baseUrl}/auth/me`), authFetch(`${baseUrl}/requests/${requestId}/snapshot`), - authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`), - authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`), ]) - if ([snapshotResponse, historyResponse, actionsResponse].some((response) => response.status === 401)) { + if ([meResponse, snapshotResponse].some((response) => response.status === 401)) { clearToken() router.push('/login') return } + if (!meResponse.ok) { + throw new Error('Unable to verify your request access.') + } + const me = await meResponse.json() + const viewerIsAdmin = me?.role === 'admin' + setIsAdmin(viewerIsAdmin) if (!snapshotResponse.ok) { throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.')) } const snapshotData = await snapshotResponse.json() if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.') setSnapshot(snapshotData) - if (historyResponse.ok) { - const historyData = await historyResponse.json() - if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots) - } - if (actionsResponse.ok) { - const actionsData = await actionsResponse.json() - if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions) + if (viewerIsAdmin) { + const [historyResponse, actionsResponse] = await Promise.all([ + authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`), + authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`), + ]) + if (historyResponse.ok) { + const historyData = await historyResponse.json() + if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots) + } + if (actionsResponse.ok) { + const actionsData = await actionsResponse.json() + if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions) + } + } else { + setHistorySnapshots([]) + setHistoryActions([]) } } catch (error) { console.error(error) @@ -811,7 +826,7 @@ export default function RequestTimelinePage() { )} -
+ {isAdmin &&
+
} ) }