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 && (