From 08545b8c9293ff33d68df931644f6754a1d1fc91 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 9 Apr 2026 21:31:38 -0500 Subject: [PATCH 1/9] security: Prevented users from setting `xc_password` (and other admin-managed keys) on their own account via the `PATCH /api/accounts/users/me/` endpoint. --- apps/accounts/api_views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index c698f07a..9fba92c0 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -291,6 +291,14 @@ class UserViewSet(viewsets.ModelViewSet): for key in disallowed: request.data.pop(key, None) + # Strip admin-managed keys from custom_properties so users cannot + # set their own XC credentials via this endpoint. + ADMIN_ONLY_PROPS = {"xc_password"} + cp = request.data.get("custom_properties") + if isinstance(cp, dict): + for key in ADMIN_ONLY_PROPS: + cp.pop(key, None) + serializer = UserSerializer(user, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() From 7083c512be6088ca58d028a6488b09841adc2064 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 9 Apr 2026 21:32:36 -0500 Subject: [PATCH 2/9] security: Fixed path traversal vulnerability in file uploads. The M3U account upload (`apps/m3u/api_views.py`), logo upload (`apps/channels/api_views.py`), and backup upload (`apps/backups/api_views.py`) all used the uploaded filename directly without sanitization. `os.path.join()` discards all preceding components when it encounters an absolute path segment, and `pathlib`'s `/` operator behaves identically; a relative `../` sequence also escapes via OS path resolution at `open()` time. All three upload paths now strip directory components via `Path(name).name` and validate the resolved path remains within the intended upload directory. Exploiting any of these required admin credentials. --- apps/backups/api_views.py | 13 +++++++++++-- apps/channels/api_views.py | 13 ++++++++----- apps/m3u/api_views.py | 17 +++++++++++------ core/utils.py | 15 +++++++++++++++ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/apps/backups/api_views.py b/apps/backups/api_views.py index a5af495e..36334d06 100644 --- a/apps/backups/api_views.py +++ b/apps/backups/api_views.py @@ -13,6 +13,7 @@ from rest_framework.permissions import AllowAny from apps.accounts.permissions import IsAdmin from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.response import Response +from core.utils import safe_upload_path from . import services from .tasks import create_backup_task, restore_backup_task @@ -267,10 +268,18 @@ def upload_backup(request): try: backup_dir = services.get_backup_dir() - filename = uploaded.name or "uploaded-backup.zip" + # Sanitize filename: strip directory components to prevent path traversal + filename = Path(uploaded.name or "uploaded-backup.zip").name + if not filename: + filename = "uploaded-backup.zip" + + try: + safe_upload_path(filename, str(backup_dir)) + except ValueError: + return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST) # Ensure unique filename - backup_file = backup_dir / filename + backup_file = (backup_dir / filename).resolve() counter = 1 while backup_file.exists(): name_parts = filename.rsplit(".", 1) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 268cc828..cde49dad 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -24,7 +24,7 @@ from apps.accounts.permissions import ( ) from core.models import UserAgent, CoreSettings -from core.utils import RedisClient +from core.utils import RedisClient, safe_upload_path from .models import ( Stream, @@ -1901,10 +1901,13 @@ class LogoViewSet(viewsets.ModelViewSet): {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST ) - file_name = file.name - file_path = os.path.join("/data/logos", file_name) + # Sanitize filename: strip directory components to prevent path traversal + try: + file_path = safe_upload_path(file.name, "/data/logos") + except ValueError: + return Response({"error": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST) - os.makedirs(os.path.dirname(file_path), exist_ok=True) + os.makedirs("/data/logos", exist_ok=True) with open(file_path, "wb+") as destination: for chunk in file.chunks(): destination.write(chunk) @@ -1924,7 +1927,7 @@ class LogoViewSet(viewsets.ModelViewSet): # Get custom name from request data, fallback to filename custom_name = request.data.get('name', '').strip() - logo_name = custom_name if custom_name else file_name + logo_name = custom_name if custom_name else os.path.basename(file_path) logo, _ = Logo.objects.get_or_create( url=file_path, diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 3b856dc8..b8cb099a 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -20,6 +20,7 @@ import json from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile from core.models import UserAgent +from core.utils import safe_upload_path from apps.channels.models import ChannelGroupM3UAccount from core.serializers import UserAgentSerializer from apps.vod.models import M3UVODCategoryRelation @@ -54,10 +55,12 @@ class M3UAccountViewSet(viewsets.ModelViewSet): file_path = None if "file" in request.FILES: file = request.FILES["file"] - file_name = file.name - file_path = os.path.join("/data/uploads/m3us", file_name) + try: + file_path = safe_upload_path(file.name, "/data/uploads/m3us") + except ValueError: + return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST) - os.makedirs(os.path.dirname(file_path), exist_ok=True) + os.makedirs("/data/uploads/m3us", exist_ok=True) with open(file_path, "wb+") as destination: for chunk in file.chunks(): destination.write(chunk) @@ -117,10 +120,12 @@ class M3UAccountViewSet(viewsets.ModelViewSet): file_path = None if "file" in request.FILES: file = request.FILES["file"] - file_name = file.name - file_path = os.path.join("/data/uploads/m3us", file_name) + try: + file_path = safe_upload_path(file.name, "/data/uploads/m3us") + except ValueError: + return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST) - os.makedirs(os.path.dirname(file_path), exist_ok=True) + os.makedirs("/data/uploads/m3us", exist_ok=True) with open(file_path, "wb+") as destination: for chunk in file.chunks(): destination.write(chunk) diff --git a/core/utils.py b/core/utils.py index 583075c3..ba2458ec 100644 --- a/core/utils.py +++ b/core/utils.py @@ -3,6 +3,7 @@ import logging import time import os import threading +from pathlib import Path import re from django.conf import settings from redis.exceptions import ConnectionError, TimeoutError @@ -431,6 +432,20 @@ def cleanup_memory(log_usage=False, force_collection=True): pass logger.trace("Memory cleanup complete for django") +def safe_upload_path(filename: str, base_dir) -> str: + """Return a safe absolute path for an uploaded file within base_dir. + + Strips all directory components from *filename* and verifies the resolved + path stays inside *base_dir*. Raises ValueError on path traversal attempts. + """ + safe_name = Path(filename).name + base = Path(base_dir).resolve() + file_path = (base / safe_name).resolve() + if not file_path.is_relative_to(base): + raise ValueError("Invalid filename.") + return str(file_path) + + def is_protected_path(file_path): """ Determine if a file path is in a protected directory that shouldn't be deleted. From 47427d4b0f4063c0c8cca87e6767694c4d453715 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 9 Apr 2026 21:36:51 -0500 Subject: [PATCH 3/9] security: - Set `DEFAULT_PERMISSION_CLASSES` to `Authenticated` in the DRF configuration. - Explicitly marked the HDHomeRun discovery endpoints (`DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, `HDHRDeviceXMLAPIView`) and the version endpoint with `permission_classes = [AllowAny]` to document their intentionally public access now that the global default is `Authenticated`. --- apps/hdhr/api_views.py | 5 +++++ core/api_views.py | 4 ++-- dispatcharr/settings.py | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 2227170e..539adffe 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -1,6 +1,7 @@ from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.permissions import AllowAny from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse import logging @@ -46,6 +47,7 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): # 🔹 2) Discover API class DiscoverAPIView(APIView): """Returns device discovery information""" + permission_classes = [AllowAny] @extend_schema( description="Retrieve HDHomeRun device discovery information", @@ -98,6 +100,7 @@ class DiscoverAPIView(APIView): # 🔹 3) Lineup API class LineupAPIView(APIView): """Returns available channel lineup""" + permission_classes = [AllowAny] @extend_schema( description="Retrieve the available channel lineup", @@ -138,6 +141,7 @@ class LineupAPIView(APIView): # 🔹 4) Lineup Status API class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" + permission_classes = [AllowAny] @extend_schema( description="Retrieve the HDHomeRun lineup status", @@ -155,6 +159,7 @@ class LineupStatusAPIView(APIView): # 🔹 5) Device XML API class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" + permission_classes = [AllowAny] @extend_schema( description="Retrieve the HDHomeRun device XML configuration", diff --git a/core/api_views.py b/core/api_views.py index a81ddf54..d01d8619 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -9,7 +9,7 @@ from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.views import APIView from django.shortcuts import get_object_or_404 -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.decorators import api_view, permission_classes, action from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.types import OpenApiTypes @@ -330,8 +330,8 @@ def environment(request): @extend_schema( description="Get application version information", ) - @api_view(["GET"]) +@permission_classes([AllowAny]) def version(request): # Import version information from version import __version__, __timestamp__ diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index ec61eb4d..6d131058 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -265,6 +265,9 @@ REST_FRAMEWORK = { "rest_framework_simplejwt.authentication.JWTAuthentication", "apps.accounts.authentication.ApiKeyAuthentication", ], + "DEFAULT_PERMISSION_CLASSES": [ + "apps.accounts.permissions.Authenticated", + ], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } From b535f28ac5461279e2652a14150947ac8c48ce0c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 9 Apr 2026 21:46:50 -0500 Subject: [PATCH 4/9] security: Added rate limiting to the login endpoint (`POST /api/accounts/token/`) using DRF's built-in throttling. A `LoginRateThrottle` (3 requests/minute per IP, sliding window) is applied to the `TokenObtainPairView`. Repeated failed attempts from the same IP receive `429 Too Many Requests`. --- apps/accounts/api_views.py | 8 +++++++- dispatcharr/settings.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 9fba92c0..6a70fd53 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -6,6 +6,7 @@ from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view, permission_classes, action from rest_framework.response import Response from rest_framework import viewsets, status, serializers +from rest_framework.throttling import AnonRateThrottle from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer from drf_spectacular.types import OpenApiTypes import json @@ -20,9 +21,14 @@ from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView logger = logging.getLogger(__name__) +class LoginRateThrottle(AnonRateThrottle): + scope = "login" + + class TokenObtainPairView(TokenObtainPairView): + throttle_classes = [LoginRateThrottle] + def post(self, request, *args, **kwargs): - # Custom logic here if not network_access_allowed(request, "UI"): # Log blocked login attempt due to network restrictions from core.utils import log_system_event diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 6d131058..db9284ff 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -269,6 +269,10 @@ REST_FRAMEWORK = { "apps.accounts.permissions.Authenticated", ], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], + "DEFAULT_THROTTLE_CLASSES": [], + "DEFAULT_THROTTLE_RATES": { + "login": "3/minute", + }, } SPECTACULAR_SETTINGS = { From 04a684b359b675ba4e1107efa54f067c376426b2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 10 Apr 2026 08:34:13 -0500 Subject: [PATCH 5/9] =?UTF-8?q?security:=20Removed=20`CORS=5FALLOW=5FCREDE?= =?UTF-8?q?NTIALS=20=3D=20True`=20from=20CORS=20configuration.=20Dispatcha?= =?UTF-8?q?rr=20authenticates=20via=20JWT=20`Authorization`=20headers=20an?= =?UTF-8?q?d=20API=20keys=20=E2=80=94=20not=20cookies=20=E2=80=94=20so=20c?= =?UTF-8?q?redentials=20are=20never=20sent=20cross-origin=20by=20browsers.?= =?UTF-8?q?=20The=20setting=20was=20also=20redundant:=20browsers=20reject?= =?UTF-8?q?=20`Access-Control-Allow-Credentials:=20true`=20when=20`Access-?= =?UTF-8?q?Control-Allow-Origin`=20is=20a=20wildcard=20(`*`),=20so=20it=20?= =?UTF-8?q?had=20no=20effect=20in=20practice.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dispatcharr/settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index db9284ff..e0eae919 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -420,7 +420,6 @@ BACKUP_DATA_DIRS = [ SERVER_IP = "127.0.0.1" CORS_ALLOW_ALL_ORIGINS = True -CORS_ALLOW_CREDENTIALS = True CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"] APPEND_SLASH = True From 289a8f48d018cb4282106ec01913825a322e3eaf Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 10 Apr 2026 08:50:13 -0500 Subject: [PATCH 6/9] security: Update frontend packages to mitigate CVE's. --- frontend/package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6880b99a..e5350fab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2518,9 +2518,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3986,9 +3986,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.clamp": { @@ -5490,9 +5490,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { From 66ee67da30214993700d3906ad042fd46cd77d2f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 10 Apr 2026 10:46:16 -0500 Subject: [PATCH 7/9] security: Change from default of "Authenticated" to "IsAdmin" --- dispatcharr/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index e0eae919..4bf4e8dc 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -266,7 +266,7 @@ REST_FRAMEWORK = { "apps.accounts.authentication.ApiKeyAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ - "apps.accounts.permissions.Authenticated", + "apps.accounts.permissions.IsAdmin", ], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], "DEFAULT_THROTTLE_CLASSES": [], From fa9a7868ff836d9d91cc8ffdb36200a82b987c94 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 10 Apr 2026 10:47:50 -0500 Subject: [PATCH 8/9] security: proxy streaming endpoints (`stream_ts`, `stream_xc`, `stream_vod`, `head_vod`, `stream_xc_movie`, `stream_xc_episode`) use `@permission_classes([AllowAny])` (access is controlled by the per-stream-type network allow-list inside the view body); the `UserAgentViewSet`, `StreamProfileViewSet`, `CoreSettingsViewSet`, and `ProxySettingsViewSet` gained `get_permissions()` methods mapping read actions to `IsStandardUser` and write actions to `IsAdmin`; and `AuthViewSet.logout` was updated to return `[Authenticated()]`. --- apps/accounts/api_views.py | 3 +-- apps/proxy/ts_proxy/views.py | 3 +++ apps/proxy/vod_proxy/views.py | 5 +++++ core/api_views.py | 26 ++++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 6a70fd53..10e9272c 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -159,8 +159,7 @@ class AuthViewSet(viewsets.ViewSet): Login doesn't require auth, but logout does """ if self.action == 'logout': - from rest_framework.permissions import IsAuthenticated - return [IsAuthenticated()] + return [Authenticated()] return [] @extend_schema( diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 5750616b..b3ea295a 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -19,6 +19,7 @@ from apps.m3u.models import M3UAccount, M3UAccountProfile from apps.accounts.models import User from core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny from rest_framework.response import Response from apps.accounts.permissions import ( IsAdmin, @@ -46,6 +47,7 @@ logger = get_logger() @api_view(["GET"]) +@permission_classes([AllowAny]) def stream_ts(request, channel_id, user=None): if not network_access_allowed(request, "STREAMS"): return JsonResponse({"error": "Forbidden"}, status=403) @@ -554,6 +556,7 @@ def stream_ts(request, channel_id, user=None): @api_view(["GET"]) +@permission_classes([AllowAny]) def stream_xc(request, username, password, channel_id): user = get_object_or_404(User, username=username) diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index fdebdcea..e5d16e90 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -15,6 +15,7 @@ from apps.m3u.models import M3UAccountProfile from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key from .utils import get_client_info from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny from apps.accounts.models import User from apps.accounts.permissions import IsAdmin from apps.proxy.utils import check_user_stream_limits @@ -291,6 +292,7 @@ def _transform_url(original_url, m3u_profile): return original_url @api_view(["GET"]) +@permission_classes([AllowAny]) def stream_vod(request, content_type, content_id, session_id=None, profile_id=None, user=None): """ Stream VOD content (movies or series episodes) with session-based connection reuse @@ -500,6 +502,7 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No return HttpResponse(f"Streaming error: {str(e)}", status=500) @api_view(["HEAD"]) +@permission_classes([AllowAny]) def head_vod(request, content_type, content_id, session_id=None, profile_id=None): """ Handle HEAD requests for FUSE filesystem integration @@ -987,6 +990,7 @@ def stop_vod_client(request): return JsonResponse({'error': str(e)}, status=500) @api_view(["GET"]) +@permission_classes([AllowAny]) def stream_xc_movie(request, username, password, stream_id, extension): from apps.vod.models import M3UMovieRelation @@ -1017,6 +1021,7 @@ def stream_xc_movie(request, username, password, stream_id, extension): return stream_vod(request._request, 'movie', movie_relation.movie.uuid, session_id, profile_id, user) @api_view(["GET"]) +@permission_classes([AllowAny]) def stream_xc_episode(request, username, password, stream_id, extension): from apps.vod.models import M3UEpisodeRelation diff --git a/core/api_views.py b/core/api_views.py index d01d8619..596da877 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -35,6 +35,9 @@ import os from core.tasks import rehash_streams from apps.accounts.permissions import ( Authenticated, + IsAdmin, + IsStandardUser, + permission_classes_by_action, ) from dispatcharr.utils import get_client_ip @@ -50,6 +53,12 @@ class UserAgentViewSet(viewsets.ModelViewSet): queryset = UserAgent.objects.all() serializer_class = UserAgentSerializer + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + class StreamProfileViewSet(viewsets.ModelViewSet): """ @@ -59,6 +68,12 @@ class StreamProfileViewSet(viewsets.ModelViewSet): queryset = StreamProfile.objects.all() serializer_class = StreamProfileSerializer + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + class CoreSettingsViewSet(viewsets.ModelViewSet): """ @@ -69,6 +84,12 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): queryset = CoreSettings.objects.all() serializer_class = CoreSettingsSerializer + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + def update(self, request, *args, **kwargs): instance = self.get_object() old_value = instance.value @@ -171,6 +192,11 @@ class ProxySettingsViewSet(viewsets.ViewSet): """ serializer_class = ProxySettingsSerializer + def get_permissions(self): + if self.action in ('list', 'retrieve'): + return [IsStandardUser()] + return [IsAdmin()] + def _get_or_create_settings(self): """Get or create the proxy settings CoreSettings entry""" try: From c3fb9c0073ed0952f4fb6237743ca03d77967aa7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 10 Apr 2026 11:09:27 -0500 Subject: [PATCH 9/9] security: Fixed missing `network_access_allowed` checks in the VOD proxy. `stream_vod`, `head_vod`, `stream_xc_movie`, and `stream_xc_episode` were not checking the `STREAMS` network policy, unlike the equivalent TS proxy endpoints. --- CHANGELOG.md | 11 +++++++++++ apps/proxy/vod_proxy/views.py | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52246f84..e97b52c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Set `DEFAULT_PERMISSION_CLASSES` to `IsAdmin` in the DRF configuration. All viewsets and function-based views that require non-admin or unauthenticated access were explicitly annotated: proxy streaming endpoints (`stream_ts`, `stream_xc`, `stream_vod`, `head_vod`, `stream_xc_movie`, `stream_xc_episode`) use `@permission_classes([AllowAny])` (access is controlled by the per-stream-type network allow-list inside the view body); the `UserAgentViewSet`, `StreamProfileViewSet`, `CoreSettingsViewSet`, and `ProxySettingsViewSet` gained `get_permissions()` methods mapping read actions to `IsStandardUser` and write actions to `IsAdmin`; and `AuthViewSet.logout` was updated to return `[Authenticated()]`. +- Fixed missing `network_access_allowed` checks in the VOD proxy. `stream_vod`, `head_vod`, `stream_xc_movie`, and `stream_xc_episode` were not checking the `STREAMS` network policy, unlike the equivalent TS proxy endpoints. +- Explicitly marked the HDHomeRun discovery endpoints (`DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, `HDHRDeviceXMLAPIView`) and the version endpoint with `permission_classes = [AllowAny]` to document their intentionally public access now that the global default is `IsAdmin`. +- Fixed path traversal vulnerability in file uploads. The M3U account upload (`apps/m3u/api_views.py`), logo upload (`apps/channels/api_views.py`), and backup upload (`apps/backups/api_views.py`) all used the uploaded filename directly without sanitization. `os.path.join()` discards all preceding components when it encounters an absolute path segment, and `pathlib`'s `/` operator behaves identically; a relative `../` sequence also escapes via OS path resolution at `open()` time. All three upload paths now strip directory components via `Path(name).name` and validate the resolved path remains within the intended upload directory. Exploiting any of these required admin credentials. +- Prevented users from setting `xc_password` (and other admin-managed keys) on their own account via the `PATCH /api/accounts/users/me/` endpoint. - Hardened the HLS proxy `change_stream` endpoint by converting it from a plain Django view to a DRF `@api_view` with `@permission_classes([IsAdmin])`, ensuring the endpoint actually enforces admin-only access. The previous decorator arrangement (`@csrf_exempt` + `@permission_classes`) had no effect on a plain Django view. +- Added rate limiting to the login endpoint (`POST /api/accounts/token/`) using DRF's built-in throttling. A `LoginRateThrottle` (3 requests/minute per IP, sliding window) is applied to the `TokenObtainPairView`. Repeated failed attempts from the same IP receive `429 Too Many Requests`. +- Removed `CORS_ALLOW_CREDENTIALS = True` from CORS configuration. Dispatcharr authenticates via JWT `Authorization` headers and API keys — not cookies — so credentials are never sent cross-origin by browsers. The setting was also redundant: browsers reject `Access-Control-Allow-Credentials: true` when `Access-Control-Allow-Origin` is a wildcard (`*`), so it had no effect in practice. +- Updated frontend npm dependencies to resolve 6 audit vulnerabilities (6 high): + - Updated `@xmldom/xmldom` 0.8.11 → 0.8.12, resolving **high** XML injection via unsafe CDATA serialization allowing attacker-controlled markup insertion ([GHSA-wh4c-j3r5-mjhp](https://github.com/advisories/GHSA-wh4c-j3r5-mjhp)) + - Updated `lodash` 4.17.23 → 4.18.1, resolving **high** Code Injection via `_.template` imports key names ([GHSA-r5fr-rjxr-66jc](https://github.com/advisories/GHSA-r5fr-rjxr-66jc)) and **high** Prototype Pollution via array path bypass in `_.unset` and `_.omit` ([GHSA-f23m-r3pf-42rh](https://github.com/advisories/GHSA-f23m-r3pf-42rh)) + - Updated `vite` 7.3.1 → 7.3.2, resolving **high** Path Traversal in optimized deps `.map` handling ([GHSA-4w7w-66w2-5vf9](https://github.com/advisories/GHSA-4w7w-66w2-5vf9)), **high** `server.fs.deny` bypass with queries ([GHSA-v2wj-q39q-566r](https://github.com/advisories/GHSA-v2wj-q39q-566r)), and **high** Arbitrary File Read via dev server WebSocket ([GHSA-p9ff-h696-f583](https://github.com/advisories/GHSA-p9ff-h696-f583)) ### Removed diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index e5d16e90..a1dcf9a3 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -19,6 +19,7 @@ from rest_framework.permissions import AllowAny from apps.accounts.models import User from apps.accounts.permissions import IsAdmin from apps.proxy.utils import check_user_stream_limits +from dispatcharr.utils import network_access_allowed logger = logging.getLogger(__name__) @@ -303,6 +304,9 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No session_id: Optional session ID from URL path (for persistent connections) profile_id: Optional M3U profile ID for authentication """ + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}") logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}") logger.info(f"[VOD-REQUEST] Request method: {request.method}") @@ -509,6 +513,9 @@ def head_vod(request, content_type, content_id, session_id=None, profile_id=None Returns content length and session URL header for subsequent GET requests """ + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + logger.info(f"[VOD-HEAD] HEAD request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}") try: @@ -992,6 +999,9 @@ def stop_vod_client(request): @api_view(["GET"]) @permission_classes([AllowAny]) def stream_xc_movie(request, username, password, stream_id, extension): + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + from apps.vod.models import M3UMovieRelation session_id = request.GET.get('session_id') @@ -1023,6 +1033,9 @@ def stream_xc_movie(request, username, password, stream_id, extension): @api_view(["GET"]) @permission_classes([AllowAny]) def stream_xc_episode(request, username, password, stream_id, extension): + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + from apps.vod.models import M3UEpisodeRelation session_id = request.GET.get('session_id')