From 66089f14eedd73e328e110c492e60e9ea852b819 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 18 Jul 2026 20:03:03 +0000 Subject: [PATCH] refactor(permissions): enhance permission handling across various viewsets This update modifies permission classes in multiple viewsets to restrict access based on user roles. The `IsAdmin` permission is now enforced for several actions, including group management and permission listing, ensuring that only administrators can perform sensitive operations. Additionally, a new utility function, `resolve_safe_local_data_path`, is introduced to enhance security when accessing local file paths. The changes improve overall security and maintainability of the codebase. --- CHANGELOG.md | 11 + apps/accounts/api_views.py | 15 +- .../tests/test_auth_group_permissions.py | 44 ++++ apps/channels/api_views.py | 47 +++- apps/channels/tests/test_logo_path_jail.py | 77 +++++++ apps/connect/api_views.py | 10 +- apps/epg/api_views.py | 13 +- apps/epg/tests/test_upload_security.py | 49 +++++ apps/m3u/serializers.py | 8 + apps/m3u/tests/test_password_visibility.py | 46 ++++ apps/output/urls.py | 3 - apps/output/views.py | 2 +- apps/proxy/urls.py | 1 - apps/vod/api_views.py | 14 +- core/api_views.py | 12 +- .../test_system_notifications_permissions.py | 62 ++++++ core/utils.py | 25 +++ core/views.py | 205 ------------------ docker/nginx.conf | 10 - 19 files changed, 400 insertions(+), 254 deletions(-) create mode 100644 apps/accounts/tests/test_auth_group_permissions.py create mode 100644 apps/channels/tests/test_logo_path_jail.py create mode 100644 apps/epg/tests/test_upload_security.py create mode 100644 apps/m3u/tests/test_password_visibility.py create mode 100644 core/tests/test_system_notifications_permissions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a58d1a54..0b2fc04e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **XC live refresh avoids redundant work during catalog filtering.** `collect_xc_streams` skips disabled categories before building entries and uses a shared URL prefix instead of formatting each stream URL separately. Auto-sync releases per-group logo/EPG caches after each group iteration. - **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs. +### Security + +- **Authentication boundaries and local logo serving.** + - Logo and VOD logo `cache` endpoints no longer serve arbitrary filesystem paths: local URLs must resolve under `/data/logos` (blocks `/data/../…` traversal). Empty logo URLs return 404 instead of attempting a remote fetch. + - EPG source file upload API (`POST /api/epg/sources/upload/`) is admin-only and uses `safe_upload_path` so uploaded filenames cannot escape `/data/uploads/epgs`. The web UI does not expose this endpoint (EPG sources are added via URL, Schedules Direct, or dummy); the fix hardens the API-only path. + - M3U account passwords are omitted from API responses for non-admins; admins still receive them (the M3U edit form already blanks the password field on load). + - Channel bulk-regex rename, EPG name/logo/tvg-id apply, channel reorder, channel-group cleanup, recording stop/extend/comskip/metadata actions, and Connect integration APIs require admin. Guide-facing channel read helpers (`summary`, `ids`, etc.) remain available to standard users. + - System notifications: any authenticated user can still list and dismiss notifications visible to them; create/update/delete require admin (previously any authenticated user could create notifications). + - Django `auth.Group` CRUD (`/api/accounts/groups/`) and the permissions list endpoint require admin. Dispatcharr authorization uses `user_level`, not these groups; the React UI does not call these endpoints. + - Removed unused unauthenticated routes: `/proxy/hls/` (HLS proxy package retained for possible future use) and legacy `/output/stream//` (including the nginx location). Live playback continues via `/proxy/ts/stream/`. + ### Fixed - **Live channel Redis `state` no longer stays latched at `buffering` after a buffering-timeout failover.** When FFmpeg speed dipped below the buffering threshold and the timeout triggered a stream switch, the in-memory buffering flag was cleared without rewriting Redis, and the same stats sample could re-write `buffering`. After the new stream recovered, the speed-good path only cleared Redis when that flag was still set, so a healthy session could report `buffering` until restart or retune. A successful buffering-timeout switch now writes `active` and skips the fallthrough `buffering` write. (Fixes #1449) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 8e624370..7308bbe9 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -268,13 +268,18 @@ class UserViewSet(viewsets.ModelViewSet): return Response(serializer.data) -# 🔹 3) Group Management APIs +# 🔹 3) Group Management APIs (Django auth.Group; unused by the React UI) class GroupViewSet(viewsets.ModelViewSet): - """Handles CRUD operations for Groups""" + """CRUD for Django auth groups and their permissions. + + Dispatcharr authorization uses ``user_level``, not these groups. The + endpoint is kept for compatibility but restricted to admins so + non-admins cannot invent auth groups or attach Django permissions. + """ queryset = Group.objects.all() serializer_class = GroupSerializer - permission_classes = [Authenticated] + permission_classes = [IsAdmin] @extend_schema( description="Retrieve a list of groups", @@ -359,9 +364,9 @@ class APIKeyViewSet(viewsets.ViewSet): responses={200: PermissionSerializer(many=True)}, ) @api_view(["GET"]) -@permission_classes([Authenticated]) +@permission_classes([IsAdmin]) def list_permissions(request): - """Returns a list of all available permissions""" + """Returns a list of all available Django permissions (admin only).""" permissions = Permission.objects.all() serializer = PermissionSerializer(permissions, many=True) return Response(serializer.data) diff --git a/apps/accounts/tests/test_auth_group_permissions.py b/apps/accounts/tests/test_auth_group_permissions.py new file mode 100644 index 00000000..867ab049 --- /dev/null +++ b/apps/accounts/tests/test_auth_group_permissions.py @@ -0,0 +1,44 @@ +"""Django auth.Group API is admin-only (unused by the React UI).""" +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.accounts.api_views import GroupViewSet, list_permissions + + +class AuthGroupPermissionTests(TestCase): + def setUp(self): + User = get_user_model() + self.admin = User.objects.create_user( + username="auth_group_admin", + password="x", + user_level=User.UserLevel.ADMIN, + ) + self.standard = User.objects.create_user( + username="auth_group_user", + password="x", + user_level=User.UserLevel.STANDARD, + ) + self.factory = APIRequestFactory() + self.group = Group.objects.create(name="unused-role") + + def test_standard_user_cannot_list_groups(self): + request = self.factory.get("/api/accounts/groups/") + force_authenticate(request, user=self.standard) + view = GroupViewSet.as_view({"get": "list"}) + response = view(request) + self.assertEqual(response.status_code, 403) + + def test_admin_can_list_groups(self): + request = self.factory.get("/api/accounts/groups/") + force_authenticate(request, user=self.admin) + view = GroupViewSet.as_view({"get": "list"}) + response = view(request) + self.assertEqual(response.status_code, 200) + + def test_standard_user_cannot_list_permissions(self): + request = self.factory.get("/api/accounts/permissions/") + force_authenticate(request, user=self.standard) + response = list_permissions(request) + self.assertEqual(response.status_code, 403) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 7dc7ef06..7dc414fe 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -21,12 +21,13 @@ from apps.accounts.permissions import ( Authenticated, IsAdmin, IsOwnerOfObject, + IsStandardUser, permission_classes_by_action, permission_classes_by_method, ) from core.models import UserAgent, CoreSettings -from core.utils import RedisClient, safe_upload_path +from core.utils import RedisClient, safe_upload_path, resolve_safe_local_data_path from apps.m3u.utils import convert_js_numbered_backreferences from .models import ( @@ -611,10 +612,12 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): serializer_class = ChannelGroupSerializer def get_permissions(self): + if self.action == "cleanup_unused_groups": + return [IsAdmin()] try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: - return [Authenticated()] + return [IsAdmin()] def get_queryset(self): # Annotate both counts at the SQL level so the serializer methods @@ -899,13 +902,26 @@ class ChannelViewSet(viewsets.ModelViewSet): "match_epg", "set_epg", "batch_set_epg", + "bulk_regex_rename", + "set_names_from_epg", + "set_logos_from_epg", + "set_tvg_ids_from_epg", + "reorder", ]: return [IsAdmin()] + if self.action in ( + "get_ids", + "summary", + "numbers_in_range", + "by_uuids", + ): + return [IsStandardUser()] + try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: - return [Authenticated()] + return [IsAdmin()] def get_queryset(self): # get_ids and summary only need the filter conditions, not the full @@ -2765,23 +2781,26 @@ class LogoViewSet(viewsets.ModelViewSet): """Streams the logo file, whether it's local or remote.""" logo = self.get_object() logo_url = logo.url + if not logo_url: + raise Http404("Image not found") if logo_url.startswith("/data"): # Local file - if not os.path.exists(logo_url): + safe_path = resolve_safe_local_data_path(logo_url) + if safe_path is None or not os.path.exists(safe_path): raise Http404("Image not found") - stat = os.stat(logo_url) + stat = os.stat(safe_path) # Get proper mime type (first item of the tuple) - content_type, _ = mimetypes.guess_type(logo_url) + content_type, _ = mimetypes.guess_type(safe_path) if not content_type: content_type = "image/jpeg" # Default to a common image type - # Use context manager and set Content-Disposition to inline + # StreamingHttpResponse closes the file when the response finishes. response = StreamingHttpResponse( - open(logo_url, "rb"), content_type=content_type + open(safe_path, "rb"), content_type=content_type ) response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours response["Last-Modified"] = http_date(stat.st_mtime) response["Content-Disposition"] = 'inline; filename="{}"'.format( - os.path.basename(logo_url) + os.path.basename(safe_path) ) return response @@ -3298,10 +3317,18 @@ class RecordingViewSet(viewsets.ModelViewSet): # classes run; _user_can_play_recording enforces authenticated access. if self.action in ('file', 'hls'): return [AllowAny()] + if self.action in ( + 'stop', + 'extend', + 'comskip', + 'refresh_artwork', + 'update_metadata', + ): + return [IsAdmin()] try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: - return [Authenticated()] + return [IsAdmin()] def _user_can_play_recording(self, request, recording): """Authorization gate for recording playback (file/hls actions). diff --git a/apps/channels/tests/test_logo_path_jail.py b/apps/channels/tests/test_logo_path_jail.py new file mode 100644 index 00000000..08825025 --- /dev/null +++ b/apps/channels/tests/test_logo_path_jail.py @@ -0,0 +1,77 @@ +"""Security-focused tests for local logo path jailing and related helpers.""" +import tempfile +import uuid +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.api_views import LogoViewSet +from apps.channels.models import Logo +from core.utils import resolve_safe_local_data_path, safe_upload_path + + +class ResolveSafeLocalDataPathTests(TestCase): + def test_accepts_file_under_logos_root(self): + name = f"_jail_test_{uuid.uuid4().hex}.png" + target = Path("/data/logos") / name + target.write_bytes(b"x") + try: + resolved = resolve_safe_local_data_path( + str(target), allowed_roots=("/data/logos",) + ) + self.assertEqual(resolved, str(target.resolve())) + finally: + target.unlink(missing_ok=True) + + def test_rejects_path_traversal_outside_root(self): + self.assertIsNone( + resolve_safe_local_data_path( + "/data/../etc/passwd", allowed_roots=("/data/logos",) + ) + ) + + def test_rejects_non_data_prefix(self): + self.assertIsNone(resolve_safe_local_data_path("/etc/passwd")) + + def test_safe_upload_path_strips_directory_components(self): + with tempfile.TemporaryDirectory() as tmp: + path = safe_upload_path("../../evil.xml", tmp) + self.assertEqual(Path(path).name, "evil.xml") + self.assertTrue(Path(path).is_relative_to(Path(tmp).resolve())) + + +class LogoCachePathJailTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + User = get_user_model() + self.user, _ = User.objects.get_or_create( + username="logo_cache_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + + def test_traversal_url_returns_404(self): + logo = Logo.objects.create( + name="Traversal", + url="/data/../etc/passwd", + ) + request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/") + force_authenticate(request, user=self.user) + view = LogoViewSet.as_view({"get": "cache"}) + response = view(request, pk=logo.id) + self.assertEqual(response.status_code, 404) + + def test_valid_local_logo_is_served(self): + name = f"_jail_serve_{uuid.uuid4().hex}.png" + file_path = Path("/data/logos") / name + file_path.write_bytes(b"\x89PNG\r\n\x1a\n") + logo = Logo.objects.create(name="Ok", url=str(file_path)) + try: + request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/") + view = LogoViewSet.as_view({"get": "cache"}) + response = view(request, pk=logo.id) + self.assertEqual(response.status_code, 200) + b"".join(response.streaming_content) + finally: + file_path.unlink(missing_ok=True) diff --git a/apps/connect/api_views.py b/apps/connect/api_views.py index fdb5d43a..0c0ab637 100644 --- a/apps/connect/api_views.py +++ b/apps/connect/api_views.py @@ -12,8 +12,6 @@ from .serializers import ( DeliveryLogSerializer, ) from apps.accounts.permissions import ( - Authenticated, - permission_classes_by_action, IsAdmin, ) from .handlers.webhook import WebhookHandler @@ -25,12 +23,8 @@ class IntegrationViewSet(viewsets.ModelViewSet): serializer_class = IntegrationSerializer def get_permissions(self): - try: - perms = permission_classes_by_action[self.action] - except KeyError: - # Respect view/action-specific permission_classes if provided; fallback to Authenticated - perms = getattr(self, "permission_classes", [Authenticated]) - return [perm() for perm in perms] + # Integrations expose webhook URLs / script paths in config; admin only. + return [IsAdmin()] @action(detail=True, methods=["get"], url_path="subscriptions") def list_subscriptions(self, request, pk=None): diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index cdb39a47..acd834b2 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -32,6 +32,7 @@ from apps.accounts.permissions import ( permission_classes_by_action, permission_classes_by_method, ) +from core.utils import safe_upload_path logger = logging.getLogger(__name__) @@ -50,6 +51,8 @@ class EPGSourceViewSet(viewsets.ModelViewSet): serializer_class = EPGSourceSerializer def get_permissions(self): + if self.action == "upload": + return [IsAdmin()] try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: @@ -57,7 +60,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): if self.request.method == 'GET': return [IsStandardUser()] return [IsAdmin()] - return [Authenticated()] + return [IsAdmin()] def get_queryset(self): from django.db.models import Exists, OuterRef @@ -82,8 +85,12 @@ class EPGSourceViewSet(viewsets.ModelViewSet): ) file = request.FILES["file"] - file_name = file.name - file_path = os.path.join("/data/uploads/epgs", file_name) + try: + file_path = safe_upload_path(file.name, "/data/uploads/epgs") + except ValueError: + return Response( + {"error": "Invalid filename"}, status=status.HTTP_400_BAD_REQUEST + ) os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, "wb+") as destination: diff --git a/apps/epg/tests/test_upload_security.py b/apps/epg/tests/test_upload_security.py new file mode 100644 index 00000000..0595467f --- /dev/null +++ b/apps/epg/tests/test_upload_security.py @@ -0,0 +1,49 @@ +"""EPG upload path safety and admin-only permission.""" +from io import BytesIO +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.epg.api_views import EPGSourceViewSet + + +class EPGUploadSecurityTests(TestCase): + def setUp(self): + User = get_user_model() + self.admin = User.objects.create_user( + username="epg_upload_admin", + password="x", + user_level=User.UserLevel.ADMIN, + ) + self.standard = User.objects.create_user( + username="epg_upload_user", + password="x", + user_level=User.UserLevel.STANDARD, + ) + self.factory = APIRequestFactory() + + def _upload(self, user, filename="guide.xml"): + content = SimpleUploadedFile( + filename, b"", content_type="application/xml" + ) + request = self.factory.post( + "/api/epg/sources/upload/", + {"file": content, "name": "Uploaded", "source_type": "xmltv"}, + format="multipart", + ) + force_authenticate(request, user=user) + view = EPGSourceViewSet.as_view({"post": "upload"}) + return view(request) + + def test_standard_user_forbidden(self): + response = self._upload(self.standard) + self.assertEqual(response.status_code, 403) + + @patch("apps.epg.api_views.safe_upload_path", side_effect=ValueError("bad")) + def test_traversal_filename_rejected(self, _mock): + response = self._upload(self.admin, filename="../../evil.xml") + self.assertEqual(response.status_code, 400) + self.assertIn("Invalid filename", response.data.get("error", "")) diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index c1dc6f92..4ac025bc 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -189,6 +189,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): "password": { "required": False, "allow_blank": True, + "write_only": True, }, } @@ -213,6 +214,13 @@ class M3UAccountSerializer(serializers.ModelSerializer): data = super().to_representation(instance) + # write_only strips password for everyone; re-add only for admins so + # operator tooling / profile regex helpers still see credentials. + request = self.context.get("request") + user = getattr(request, "user", None) if request else None + if user is not None and getattr(user, "user_level", 0) >= 10: + data["password"] = instance.password or "" + # Parse custom_properties to get VOD preference and auto_enable_new_groups settings custom_props = instance.custom_properties or {} diff --git a/apps/m3u/tests/test_password_visibility.py b/apps/m3u/tests/test_password_visibility.py new file mode 100644 index 00000000..9970fb4b --- /dev/null +++ b/apps/m3u/tests/test_password_visibility.py @@ -0,0 +1,46 @@ +"""M3U account password visibility for admin vs standard users.""" +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory + +from apps.m3u.models import M3UAccount +from apps.m3u.serializers import M3UAccountSerializer + + +class M3UPasswordVisibilityTests(TestCase): + def setUp(self): + User = get_user_model() + self.admin = User.objects.create_user( + username="m3u_pwd_admin", + password="x", + user_level=User.UserLevel.ADMIN, + ) + self.standard = User.objects.create_user( + username="m3u_pwd_user", + password="x", + user_level=User.UserLevel.STANDARD, + ) + self.account = M3UAccount.objects.create( + name="XC Acc", + server_url="http://example.test", + account_type="XC", + username="xcuser", + password="super-secret", + ) + self.factory = APIRequestFactory() + + def test_admin_sees_password(self): + request = self.factory.get("/api/m3u/accounts/") + request.user = self.admin + data = M3UAccountSerializer( + self.account, context={"request": request} + ).data + self.assertEqual(data.get("password"), "super-secret") + + def test_standard_user_does_not_see_password(self): + request = self.factory.get("/api/m3u/accounts/") + request.user = self.standard + data = M3UAccountSerializer( + self.account, context={"request": request} + ).data + self.assertNotIn("password", data) diff --git a/apps/output/urls.py b/apps/output/urls.py index dc023ed7..e9378a0f 100644 --- a/apps/output/urls.py +++ b/apps/output/urls.py @@ -1,6 +1,5 @@ from django.urls import path, re_path, include from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream -from core.views import stream_view app_name = "output" @@ -9,6 +8,4 @@ urlpatterns = [ re_path(r"^m3u(?:/(?P[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"), # Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/` re_path(r"^epg(?:/(?P[^/]+))?/?$", epg_endpoint, name="epg_endpoint"), - # Allow both `/stream/` and `/stream//` - re_path(r"^stream/(?P[0-9a-fA-F\-]+)/?$", stream_view, name="stream"), ] diff --git a/apps/output/views.py b/apps/output/views.py index 5724a03b..3e7d37a9 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -108,7 +108,7 @@ def epg_endpoint(request, profile_name=None, user=None): def generate_m3u(request, profile_name=None, user=None): """ Dynamically generate an M3U file from channels. - The stream URL now points to the new stream_view that uses StreamProfile. + The stream URL points to the live TS proxy (``/proxy/ts/stream/``). Supports both GET and POST methods for compatibility with IPTVSmarters. """ # Check if this is a POST request and the body is not empty (which we don't want to allow) diff --git a/apps/proxy/urls.py b/apps/proxy/urls.py index 470aa588..4299ea26 100644 --- a/apps/proxy/urls.py +++ b/apps/proxy/urls.py @@ -8,6 +8,5 @@ urlpatterns = [ path('stats/', stats_views.combined_stats, name='combined_stats'), path('ts/', include('apps.proxy.live_proxy.urls')), path('catchup/', include('apps.timeshift.urls')), - path('hls/', include('apps.proxy.hls_proxy.urls')), path('vod/', include('apps.proxy.vod_proxy.urls')), ] diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 07f5b6fe..9b3c3d4c 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -17,6 +17,7 @@ from apps.accounts.permissions import ( Authenticated, permission_classes_by_action, ) +from core.utils import resolve_safe_local_data_path from .models import ( Series, VODCategory, Movie, Episode, VODLogo, M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation @@ -860,17 +861,16 @@ class VODLogoViewSet(viewsets.ModelViewSet): return HttpResponse(status=404) # Check if this is a local file path - if logo.url.startswith('/data/'): - # It's a local file - file_path = logo.url - if not os.path.exists(file_path): - logger.error(f"VOD logo file not found: {file_path}") + if logo.url.startswith('/data'): + safe_path = resolve_safe_local_data_path(logo.url) + if safe_path is None or not os.path.exists(safe_path): + logger.error(f"VOD logo file not found or unsafe path: {logo.url}") return HttpResponse(status=404) try: - return FileResponse(open(file_path, 'rb'), content_type='image/png') + return FileResponse(open(safe_path, 'rb'), content_type='image/png') except Exception as e: - logger.error(f"Error serving VOD logo file {file_path}: {str(e)}") + logger.error(f"Error serving VOD logo file {safe_path}: {str(e)}") return HttpResponse(status=500) else: # It's a remote URL - proxy it diff --git a/core/api_views.py b/core/api_views.py index ee7d1db1..637393ce 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -584,7 +584,17 @@ class SystemNotificationViewSet(viewsets.ModelViewSet): Admins can create and manage notifications. """ serializer_class = SystemNotificationSerializer - permission_classes = [IsAuthenticated] + + def get_permissions(self): + if self.action in ( + "create", + "update", + "partial_update", + "destroy", + ): + return [IsAdmin()] + # list, retrieve, dismiss, dismiss_all, unread_count + return [IsAuthenticated()] def get_queryset(self): """ diff --git a/core/tests/test_system_notifications_permissions.py b/core/tests/test_system_notifications_permissions.py new file mode 100644 index 00000000..e8c4632a --- /dev/null +++ b/core/tests/test_system_notifications_permissions.py @@ -0,0 +1,62 @@ +"""SystemNotification write vs dismiss permission split.""" +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from core.api_views import SystemNotificationViewSet +from core.models import SystemNotification + + +class SystemNotificationPermissionTests(TestCase): + def setUp(self): + User = get_user_model() + self.admin = User.objects.create_user( + username="notif_admin", + password="x", + user_level=User.UserLevel.ADMIN, + ) + self.standard = User.objects.create_user( + username="notif_user", + password="x", + user_level=User.UserLevel.STANDARD, + ) + self.notif = SystemNotification.objects.create( + notification_key="test.notif", + title="Hello", + message="World", + is_active=True, + admin_only=False, + ) + self.factory = APIRequestFactory() + + def test_standard_user_can_list(self): + request = self.factory.get("/api/system/notifications/") + force_authenticate(request, user=self.standard) + view = SystemNotificationViewSet.as_view({"get": "list"}) + response = view(request) + self.assertEqual(response.status_code, 200) + + def test_standard_user_cannot_create(self): + request = self.factory.post( + "/api/system/notifications/", + { + "notification_key": "evil", + "title": "Phish", + "message": "Click", + "is_active": True, + }, + format="json", + ) + force_authenticate(request, user=self.standard) + view = SystemNotificationViewSet.as_view({"post": "create"}) + response = view(request) + self.assertEqual(response.status_code, 403) + + def test_standard_user_can_dismiss(self): + request = self.factory.post( + f"/api/system/notifications/{self.notif.id}/dismiss/" + ) + force_authenticate(request, user=self.standard) + view = SystemNotificationViewSet.as_view({"post": "dismiss"}) + response = view(request, pk=self.notif.id) + self.assertEqual(response.status_code, 200) diff --git a/core/utils.py b/core/utils.py index 5ab643f4..5b283703 100644 --- a/core/utils.py +++ b/core/utils.py @@ -660,6 +660,31 @@ def safe_upload_path(filename: str, base_dir) -> str: return str(file_path) +def resolve_safe_local_data_path(path: str, allowed_roots=("/data/logos",)): + """Return a realpath under one of *allowed_roots*, or None if unsafe. + + Rejects path traversal (``/data/../etc/passwd``), symlinks that escape + the allowlisted roots, and non-``/data`` paths. Used by logo cache + endpoints that must remain AllowAny for player clients. + """ + if not path or not isinstance(path, str): + return None + if not path.startswith("/data"): + return None + try: + real = Path(path).resolve() + except (OSError, RuntimeError, ValueError): + return None + for root in allowed_roots: + try: + root_real = Path(root).resolve() + except (OSError, RuntimeError, ValueError): + continue + if real == root_real or real.is_relative_to(root_real): + return str(real) + return None + + def is_protected_path(file_path): """ Determine if a file path is in a protected directory that shouldn't be deleted. diff --git a/core/views.py b/core/views.py index aafd0f69..4a308ce2 100644 --- a/core/views.py +++ b/core/views.py @@ -1,214 +1,9 @@ # core/views.py -import os -import signal -from shlex import split as shlex_split -import sys -import logging -import regex -import redis - -from django.conf import settings -from django.http import StreamingHttpResponse, HttpResponseServerError from django.shortcuts import render -from apps.channels.models import Channel, Stream -from apps.m3u.models import M3UAccountProfile -from core.models import StreamProfile, CoreSettings - -# Import the persistent lock (the “real” lock) -from dispatcharr.persistent_lock import PersistentLock - -# Configure logging to output to the console. -logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) -logger = logging.getLogger(__name__) - def settings_view(request): """ Renders the settings page. """ return render(request, 'settings.html') - - -def stream_view(request, channel_uuid): - """ - Streams the first available stream for the given channel. - It uses the channel’s assigned StreamProfile. - A persistent Redis lock is used to prevent concurrent streaming on the same channel. - """ - try: - redis_host = getattr(settings, "REDIS_HOST", "localhost") - redis_port = int(getattr(settings, "REDIS_PORT", 6379)) - redis_db = int(getattr(settings, "REDIS_DB", "0")) - redis_password = getattr(settings, "REDIS_PASSWORD", "") - redis_user = getattr(settings, "REDIS_USER", "") - ssl_params = getattr(settings, "REDIS_SSL_PARAMS", {}) - redis_client = redis.Redis( - host=redis_host, - port=redis_port, - db=redis_db, - password=redis_password if redis_password else None, - username=redis_user if redis_user else None, - **ssl_params - ) - - # Retrieve the channel by the provided stream_id. - channel = Channel.objects.get(uuid=channel_uuid) - logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.name) - - # Ensure the channel has at least one stream. - if not channel.streams.exists(): - logger.error("No streams found for channel ID=%s", channel.id) - return HttpResponseServerError("No stream found for this channel.") - - active_stream = None - m3u_account = None - active_profile = None - lock_key = None - persistent_lock = None - - streams = channel.streams.all().order_by('channelstream__order') - logger.debug(f'Found {len(streams)} streams for channel {channel.channel_number}') - for stream in streams: - # Get the first available stream. - logger.debug("Checking stream: ID=%s, Name=%s", stream.id, stream.name) - - # Retrieve the M3U account associated with the stream. - m3u_account = stream.m3u_account - logger.debug("Stream M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name) - - # Use the custom URL if available; otherwise, use the standard URL. - input_url = stream.url - logger.debug("Input URL: %s", input_url) - - # Determine which profile we can use. - m3u_profiles = m3u_account.profiles.all() - default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) - profiles = [obj for obj in m3u_profiles if not obj.is_default] - - # -- Loop through profiles and pick the first active one -- - for profile in [default_profile] + profiles: - logger.debug(f'Checking profile {profile.name}...') - if not profile.is_active: - logger.debug('Profile is not active, skipping.') - continue - - logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available') - stream_index = 0 - max_streams = profile.max_streams - if max_streams == 0: - max_streams = 999999 # maybe a better way than just hardcoding a high number... - while stream_index < max_streams: - stream_index += 1 - - lock_key = f"lock:{profile.id}:{stream_index}" - persistent_lock = PersistentLock(redis_client, lock_key, lock_timeout=120) - logger.debug(f'Attempting to acquire lock: {lock_key}') - - if not persistent_lock.acquire(): - logger.error(f"Could not acquire persistent lock for profile {profile.id} index {stream_index}, currently in use.") - persistent_lock = None - continue - - break - - if persistent_lock is not None: - logger.debug(f'Successfully acquired lock: {lock_key}') - active_profile = M3UAccountProfile.objects.get(id=profile.id) - break - - if active_profile is None or persistent_lock is None: - logger.exception("No available profiles for the stream") - continue - - logger.debug(f"Found available stream profile: stream={stream.name}, profile={profile.name}") - break - - if not active_profile: - logger.exception("No available streams for this channel") - return HttpResponseServerError("No available streams for this channel") - - logger.debug(f"Using M3U profile ID={active_profile.id} (ignoring viewer count limits)") - # Prepare the pattern replacement. - logger.debug("Executing the following pattern replacement:") - logger.debug(f" search: {active_profile.search_pattern}") - # Convert JS-style backreferences in replace: $ -> \g, $1 -> \1 - safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', active_profile.replace_pattern) - safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) - logger.debug(f" replace: {active_profile.replace_pattern}") - logger.debug(f" safe replace: {safe_replace_pattern}") - # regex module accepts JS-style (?...) named groups natively - stream_url = regex.sub(active_profile.search_pattern, safe_replace_pattern, input_url) - logger.debug(f"Generated stream url: {stream_url}") - - # Get the stream profile set on the channel. - stream_profile = channel.stream_profile - if not stream_profile: - logger.error("No stream profile set for channel ID=%s, using default", channel.id) - stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id()) - - logger.debug("Stream profile used: %s", stream_profile.name) - - # Determine the user agent to use. - user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0") - logger.debug("User agent: %s", user_agent) - - # Substitute placeholders in the parameters template. - parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url) - logger.debug("Formatted parameters: %s", parameters) - - # Build the final command. - cmd = [stream_profile.command] + shlex_split(parameters) - logger.debug("Executing command: %s", cmd) - - try: - stdout_r, stdout_w = os.pipe() - devnull_r = os.open(os.devnull, os.O_RDONLY) - devnull_w = os.open(os.devnull, os.O_WRONLY) - file_actions = [ - (os.POSIX_SPAWN_DUP2, devnull_r, 0), - (os.POSIX_SPAWN_DUP2, stdout_w, 1), - (os.POSIX_SPAWN_DUP2, devnull_w, 2), - (os.POSIX_SPAWN_CLOSE, devnull_r), - (os.POSIX_SPAWN_CLOSE, devnull_w), - (os.POSIX_SPAWN_CLOSE, stdout_w), - (os.POSIX_SPAWN_CLOSE, stdout_r), - ] - proc_pid = os.posix_spawn(cmd[0], cmd, dict(os.environ), file_actions=file_actions) - for fd in (devnull_r, devnull_w, stdout_w): - os.close(fd) - proc_stdout = os.fdopen(stdout_r, 'rb') - except Exception as e: - persistent_lock.release() - logger.exception("Error starting stream for channel ID=%s", channel_uuid) - return HttpResponseServerError(f"Error starting stream: {e}") - - except Exception as e: - logger.exception("Error preparing stream for channel ID=%s", channel_uuid) - return HttpResponseServerError(f"Error preparing stream: {e}") - - def stream_generator(stdout_file, pid, persistent_lock): - try: - while True: - chunk = stdout_file.read(8192) - if not chunk: - break - yield chunk - finally: - try: - os.kill(pid, signal.SIGTERM) - logger.debug("Streaming process terminated for channel ID=%s", channel.id) - except OSError: - pass - try: - os.waitpid(pid, os.WNOHANG) - except ChildProcessError: - pass - stdout_file.close() - persistent_lock.release() - logger.debug("Persistent lock released for channel ID=%s", channel.id) - - return StreamingHttpResponse( - stream_generator(proc_stdout, proc_pid, persistent_lock), - content_type="video/MP2T" - ) diff --git a/docker/nginx.conf b/docker/nginx.conf index ba75f3fe..247a4030 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -77,16 +77,6 @@ server { uwsgi_pass unix:/app/uwsgi.sock; } - # Serve FFmpeg streams efficiently - location /output/stream/ { - proxy_pass http://127.0.0.1:5656; - proxy_buffering off; - proxy_set_header Connection keep-alive; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - # WebSockets for real-time communication location /ws/ { proxy_pass http://127.0.0.1:8001;