Protect advanced request diagnostics for non-admins
Magent CI/CD / verify (push) Successful in 11m8s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s

This commit is contained in:
2026-08-31 16:09:17 +12:00
parent ecf9b230c1
commit 3402e53c31
3 changed files with 129 additions and 21 deletions
+21 -7
View File
@@ -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():
+80 -1
View File
@@ -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)
+28 -13
View File
@@ -275,6 +275,7 @@ export default function RequestTimelinePage() {
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(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() {
)}
</section>
<section className="request-advanced">
{isAdmin && <section className="request-advanced">
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
<span><strong>Advanced details</strong><small>Service diagnostics, status history and recorded actions</small></span>
<span>{showDetails ? 'Hide' : 'Show'}</span>
@@ -846,7 +861,7 @@ export default function RequestTimelinePage() {
</div>
</div>
)}
</section>
</section>}
</main>
)
}