From 8894d51877555ff92240fc7c8d1752ce4df2a8bc Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Tue, 28 Apr 2026 16:35:32 -0400 Subject: [PATCH] restrict XC access per user by configurable IP allowlist --- .../migrations/0007_user_xc_allowed_ips.py | 16 +++++++++++++ apps/accounts/models.py | 1 + apps/accounts/serializers.py | 15 ++++++++++++ apps/output/views.py | 5 +++- apps/proxy/ts_proxy/views.py | 5 +++- apps/proxy/vod_proxy/views.py | 8 ++++++- dispatcharr/utils.py | 20 ++++++++++++++++ frontend/src/components/forms/User.jsx | 24 +++++++++++++++++++ 8 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 apps/accounts/migrations/0007_user_xc_allowed_ips.py diff --git a/apps/accounts/migrations/0007_user_xc_allowed_ips.py b/apps/accounts/migrations/0007_user_xc_allowed_ips.py new file mode 100644 index 00000000..962dc639 --- /dev/null +++ b/apps/accounts/migrations/0007_user_xc_allowed_ips.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0006_user_stream_limit'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='xc_allowed_ips', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 2a32f95a..155bfc15 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -31,6 +31,7 @@ class User(AbstractUser): custom_properties = models.JSONField(default=dict, blank=True, null=True) api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True) stream_limit = models.IntegerField(default=0) + xc_allowed_ips = models.TextField(blank=True, default='') def __str__(self): return self.username diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 8a9f609e..067f6b45 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -1,4 +1,5 @@ import json +import ipaddress from rest_framework import serializers from django.contrib.auth.models import Group, Permission @@ -66,6 +67,7 @@ class UserSerializer(serializers.ModelSerializer): "custom_properties", "avatar_config", "stream_limit", + "xc_allowed_ips", "is_staff", "is_superuser", "last_login", @@ -74,6 +76,19 @@ class UserSerializer(serializers.ModelSerializer): "last_name", ] + def validate_xc_allowed_ips(self, value): + if not value or not value.strip(): + return value + for cidr in value.split(','): + cidr = cidr.strip() + if not cidr: + continue + try: + ipaddress.ip_network(cidr, strict=False) + except ValueError: + raise serializers.ValidationError(f"'{cidr}' is not a valid CIDR range") + return value + def validate_custom_properties(self, value): """Validate custom_properties structure and size.""" if value is None: diff --git a/apps/output/views.py b/apps/output/views.py index 5b061f1f..f10eff42 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from apps.epg.models import ProgramData from apps.accounts.models import User -from dispatcharr.utils import network_access_allowed +from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 from datetime import datetime, timedelta @@ -1926,6 +1926,9 @@ def xc_get_user(request): if custom_properties["xc_password"] != password: return None + if not user_xc_ip_allowed(request, user): + return None + return user diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 7170c5dc..900d218e 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -40,7 +40,7 @@ from .url_utils import ( from .utils import get_logger from uuid import UUID import gevent -from dispatcharr.utils import network_access_allowed +from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed from apps.proxy.utils import check_user_stream_limits logger = get_logger() @@ -572,6 +572,9 @@ def stream_xc(request, username, password, channel_id): if custom_properties["xc_password"] != password: return Response({"error": "Invalid credentials"}, status=401) + if not user_xc_ip_allowed(request, user): + return Response({"error": "Invalid credentials"}, status=401) + if user.user_level < 10: user_profile_count = user.channel_profiles.count() diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 924f6906..fa77fa0f 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -19,7 +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 +from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed logger = logging.getLogger(__name__) @@ -1031,6 +1031,9 @@ def stream_xc_movie(request, username, password, stream_id, extension): if custom_properties["xc_password"] != password: return Response({"error": "Invalid credentials"}, status=401) + if not user_xc_ip_allowed(request, user): + return Response({"error": "Invalid credentials"}, status=401) + # All authenticated users get access to VOD from all active M3U accounts filters = {"movie_id": stream_id, "m3u_account__is_active": True} @@ -1065,6 +1068,9 @@ def stream_xc_episode(request, username, password, stream_id, extension): if custom_properties["xc_password"] != password: return Response({"error": "Invalid credentials"}, status=401) + if not user_xc_ip_allowed(request, user): + return Response({"error": "Invalid credentials"}, status=401) + # All authenticated users get access to series/episodes from all active M3U accounts filters = {"episode_id": stream_id, "m3u_account__is_active": True} diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index c8fe58ad..f96cd539 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -38,6 +38,26 @@ def get_client_ip(request): return ip +def user_xc_ip_allowed(request, user): + """Check request IP against per-user XC allowed ranges. Empty = allow all (0.0.0.0/0).""" + allowed_ips = getattr(user, 'xc_allowed_ips', '') or '' + if not allowed_ips.strip(): + return True + + cidrs = [c.strip() for c in allowed_ips.split(',') if c.strip()] + if not cidrs: + return True + + client_ip = ipaddress.ip_address(get_client_ip(request)) + for cidr in cidrs: + try: + if client_ip in ipaddress.ip_network(cidr, strict=False): + return True + except ValueError: + continue + return False + + def network_access_allowed(request, settings_key): try: network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index 92ad3c0f..ffce64c6 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -13,6 +13,7 @@ import { Switch, NumberInput, Tabs, + TagsInput, Text, useMantineTheme, } from '@mantine/core'; @@ -23,6 +24,7 @@ import useChannelsStore from '../../store/channels'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; import useAuthStore from '../../store/auth'; import { copyToClipboard } from '../../utils'; +import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils'; const User = ({ user = null, isOpen, onClose }) => { const profiles = useChannelsStore((s) => s.profiles); @@ -48,6 +50,7 @@ const User = ({ user = null, isOpen, onClose }) => { stream_limit: 0, password: '', xc_password: '', + xc_allowed_ips: [], channel_profiles: [], hide_adult_content: false, epg_days: 0, @@ -69,6 +72,11 @@ const User = ({ user = null, isOpen, onClose }) => { values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i) ? 'XC password must be alphanumeric' : null, + xc_allowed_ips: values.xc_allowed_ips.some( + (cidr) => !cidr.match(IPV4_CIDR_REGEX) && !cidr.match(IPV6_CIDR_REGEX) + ) + ? 'Each entry must be a valid CIDR range (e.g. 192.168.1.0/24)' + : null, }), }); @@ -94,6 +102,9 @@ const User = ({ user = null, isOpen, onClose }) => { customProps.xc_password = values.xc_password || ''; delete values.xc_password; + // Serialize xc_allowed_ips array to comma-separated string for the backend + values.xc_allowed_ips = (values.xc_allowed_ips || []).join(','); + // Save hide_adult_content in custom_properties customProps.hide_adult_content = values.hide_adult_content || false; delete values.hide_adult_content; @@ -155,6 +166,9 @@ const User = ({ user = null, isOpen, onClose }) => { ? user.channel_profiles.map((id) => `${id}`) : ['0'], xc_password: customProps.xc_password || '', + xc_allowed_ips: user.xc_allowed_ips + ? user.xc_allowed_ips.split(',').filter(Boolean) + : [], hide_adult_content: customProps.hide_adult_content || false, epg_days: customProps.epg_days || 0, epg_prev_days: customProps.epg_prev_days || 0, @@ -384,6 +398,16 @@ const User = ({ user = null, isOpen, onClose }) => { } /> + {isAdmin && ( + + )} {canGenerateKey && ( {userAPIKey && (