restrict XC access per user by configurable IP allowlist

This commit is contained in:
Seth Van Niekerk 2026-04-28 16:35:32 -04:00
parent 2650bee7e9
commit 8894d51877
No known key found for this signature in database
GPG key ID: E86ACA677312A675
8 changed files with 91 additions and 3 deletions

View file

@ -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=''),
),
]

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -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}

View file

@ -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

View file

@ -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 }) => {
</ActionIcon>
}
/>
{isAdmin && (
<TagsInput
label="XC Allowed IP Ranges"
description="Restrict XC access to these CIDR ranges. Leave empty to allow all (0.0.0.0/0)."
placeholder="e.g. 192.168.1.0/24"
splitChars={[',', ' ']}
{...form.getInputProps('xc_allowed_ips')}
key={form.key('xc_allowed_ips')}
/>
)}
{canGenerateKey && (
<Stack gap="xs">
{userAPIKey && (