diff --git a/CHANGELOG.md b/CHANGELOG.md index 40545381..e98d8e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **HDHomeRun discovery endpoints now respect the `M3U_EPG` network access policy**. `DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, and `HDHRDeviceXMLAPIView` were marked `AllowAny` so HDHR clients (Plex, Emby, Jellyfin, Channels DVR, etc.) can discover the tuner without authenticating, but they were not gated by any network allowlist. The lineup enumerates every channel name and per-channel UUID stream URL, so any client that could reach the server could full-enumerate the lineup. All four views now call `network_access_allowed(request, "M3U_EPG")` and return `403 Forbidden` for clients outside the allowlist, matching the gating already applied to the M3U and EPG endpoints (and matching what the Network Access settings UI already advertised: "Limit access to M3U, EPG, and HDHR URLs"). Operators with a restrictive `M3U_EPG` policy will see HDHR discovery start being blocked for off-LAN clients on upgrade; loosen the policy if remote HDHR access is required. +- **Removed `dangerouslySetInnerHTML` from the M3U Profile regex preview**. The "Matched Text" preview in the M3U Profile editor built an HTML string by interpolating the user's sample input into a `` wrapper and rendering it via `dangerouslySetInnerHTML`. A crafted sample input (admin-only, so self-XSS only) could inject arbitrary HTML into the preview pane. The preview now returns an array of plain strings and `` React elements, so user input is always treated as text by React. - **Authorization on DVR recording playback endpoints**. `RecordingViewSet.file` and `RecordingViewSet.hls` now require an authenticated session and enforce a per-user channel-access check before serving any bytes. Admins (`user_level >= 10`) are always allowed; standard users are allowed only when the recording's source channel is visible under their channel-profile assignments and within their `user_level`, mirroring the same logic used by `stream_xc` for live channels. Unauthenticated requests now receive `403 Forbidden` instead of being served. The pre-existing `network_access_allowed(request, "STREAMS")` perimeter check is retained as a separate, prior gate so external IPs can be blocked from streaming entirely even with a valid token. ### Fixed diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 539adffe..b1d0e852 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -18,11 +18,22 @@ from django.views import View from django.utils.decorators import method_decorator from django.contrib.auth.decorators import login_required from django.views.decorators.csrf import csrf_exempt +from dispatcharr.utils import network_access_allowed # Configure logger logger = logging.getLogger(__name__) +def _hdhr_network_check(request): + """Return a 403 JsonResponse if the client IP is not allowed by the + M3U_EPG network access policy. HDHR discovery endpoints expose channel + inventory and stream URLs, so they share the same allowlist as M3U/EPG. + """ + if not network_access_allowed(request, "M3U_EPG"): + return JsonResponse({"error": "Forbidden"}, status=403) + return None + + @login_required def hdhr_dashboard_view(request): """Render the HDHR management page.""" @@ -53,6 +64,10 @@ class DiscoverAPIView(APIView): description="Retrieve HDHomeRun device discovery information", ) def get(self, request, profile=None): + blocked = _hdhr_network_check(request) + if blocked is not None: + return blocked + uri_parts = ["hdhr"] if profile is not None: uri_parts.append(profile) @@ -106,6 +121,10 @@ class LineupAPIView(APIView): description="Retrieve the available channel lineup", ) def get(self, request, profile=None): + blocked = _hdhr_network_check(request) + if blocked is not None: + return blocked + if profile is not None: channel_profile = ChannelProfile.objects.get(name=profile) channels = Channel.objects.filter( @@ -147,6 +166,10 @@ class LineupStatusAPIView(APIView): description="Retrieve the HDHomeRun lineup status", ) def get(self, request, profile=None): + blocked = _hdhr_network_check(request) + if blocked is not None: + return blocked + data = { "ScanInProgress": 0, "ScanPossible": 0, @@ -165,6 +188,10 @@ class HDHRDeviceXMLAPIView(APIView): description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): + blocked = _hdhr_network_check(request) + if blocked is not None: + return blocked + base_url = request.build_absolute_uri("/hdhr/").rstrip("/") xml_response = f""" diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index f92039d0..1243a5ca 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -294,15 +294,35 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setXcMode(mode); }; - // Local regex for the live demo preview + // Local regex for the live demo preview. Returns an array of strings and + // React nodes so user-supplied text is never interpolated into raw + // HTML (avoids self-XSS via dangerouslySetInnerHTML). const getHighlightedSearchText = () => { if (!searchPattern || !sampleInput) return sampleInput; try { const regex = new RegExp(searchPattern, 'g'); - return sampleInput.replace( - regex, - (match) => `${match}` - ); + const parts = []; + let lastIndex = 0; + let m; + while ((m = regex.exec(sampleInput)) !== null) { + if (m.index > lastIndex) { + parts.push(sampleInput.slice(lastIndex, m.index)); + } + parts.push( + + {m[0]} + + ); + lastIndex = m.index + m[0].length; + if (m[0].length === 0) regex.lastIndex++; + } + if (lastIndex < sampleInput.length) { + parts.push(sampleInput.slice(lastIndex)); + } + return parts; } catch { return sampleInput; } @@ -529,11 +549,10 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { + > + {getHighlightedSearchText()} +