feat(serve): add service allowlist for global and per-user access control

Allow server operators to restrict which services are exposed via the API using serve.services (global) and serve.users.<key>.services (per-user).
Effective access is the intersection of both when both are set. Unlisted services return the same error as non-existent ones to prevent enumeration.
This commit is contained in:
imSp4rky
2026-03-31 23:06:07 -06:00
parent 8a714d6455
commit 3aaca77c48
5 changed files with 82 additions and 13 deletions
+10
View File
@@ -143,6 +143,16 @@ def serve(
if remote_only:
api_only = True
global_services = config.serve.get("services")
if global_services:
log.info(f"Global service allowlist: {', '.join(global_services)}")
users = config.serve.get("users", {})
for user_key, user_cfg in (users.items() if isinstance(users, dict) else []):
user_services = user_cfg.get("services") if isinstance(user_cfg, dict) else None
if user_services:
username = user_cfg.get("username", user_key[:8] + "...")
log.info(f"User '{username}' restricted to services: {', '.join(user_services)}")
if api_only:
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
if no_key:
+50 -6
View File
@@ -7,6 +7,7 @@ from aiohttp import web
from unshackle.core.api.errors import APIError, APIErrorCode, handle_api_exception
from unshackle.core.api.input_bridge import AuthStatus, InputBridge
from unshackle.core.config import config
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy
from unshackle.core.services import Services
@@ -64,13 +65,48 @@ DEFAULT_DOWNLOAD_PARAMS = {
}
def validate_service(service_tag: str) -> Optional[str]:
"""Validate and normalize service tag."""
def get_allowed_services(request: Optional[web.Request] = None) -> Optional[List[str]]:
"""Get effective service allowlist considering global + per-key config.
Returns None if all services are allowed.
"""
global_allowed = config.serve.get("services")
global_set: Optional[set[str]] = None
if global_allowed:
global_set = {Services.get_tag(s) for s in global_allowed}
key_set: Optional[set[str]] = None
if request:
secret_key = request.headers.get("X-Secret-Key")
if secret_key:
users = config.serve.get("users", {})
user_config = users.get(secret_key, {})
user_services = user_config.get("services")
if user_services:
key_set = {Services.get_tag(s) for s in user_services}
if global_set and key_set:
result = global_set & key_set
elif global_set:
result = global_set
elif key_set:
result = key_set
else:
return None
return list(result)
def validate_service(service_tag: str, request: Optional[web.Request] = None) -> Optional[str]:
"""Validate, normalize, and check allowlist for service tag."""
try:
normalized = Services.get_tag(service_tag)
service_path = Services.get_path(normalized)
if not service_path.exists():
return None
allowed = get_allowed_services(request)
if allowed is not None and normalized not in allowed:
return None
return normalized
except Exception:
return None
@@ -341,6 +377,14 @@ async def search_handler(data: Dict[str, Any], request: Optional[web.Request] =
details={"service": service_tag},
)
allowed = get_allowed_services(request)
if allowed is not None and normalized_service not in allowed:
raise APIError(
APIErrorCode.INVALID_SERVICE,
f"Service '{service_tag}' not found",
details={"service": service_tag},
)
profile = data.get("profile")
proxy_param = data.get("proxy")
no_proxy = data.get("no_proxy", False)
@@ -465,7 +509,7 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques
details={"missing_parameter": "title_id"},
)
normalized_service = validate_service(service_tag)
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
@@ -617,7 +661,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
details={"missing_parameter": "title_id"},
)
normalized_service = validate_service(service_tag)
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
@@ -993,7 +1037,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
details={"missing_parameter": "title_id"},
)
normalized_service = validate_service(service_tag)
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
@@ -1377,7 +1421,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
if not title_id:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: title_id")
normalized_service = validate_service(service_tag)
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
+9 -6
View File
@@ -7,12 +7,12 @@ from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
from unshackle.core import __version__
from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler,
list_download_jobs_handler, list_titles_handler, list_tracks_handler,
search_handler, session_create_handler, session_delete_handler,
session_info_handler, session_license_handler, session_prompt_get_handler,
session_prompt_post_handler, session_segments_handler,
session_titles_handler, session_tracks_handler)
from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_allowed_services,
get_download_job_handler, list_download_jobs_handler, list_titles_handler,
list_tracks_handler, search_handler, session_create_handler,
session_delete_handler, session_info_handler, session_license_handler,
session_prompt_get_handler, session_prompt_post_handler,
session_segments_handler, session_titles_handler, session_tracks_handler)
from unshackle.core.services import Services
from unshackle.core.update_checker import UpdateChecker
@@ -155,6 +155,9 @@ async def services(request: web.Request) -> web.Response:
"""
try:
service_tags = Services.get_tags()
allowed = get_allowed_services(request)
if allowed is not None:
service_tags = [t for t in service_tags if t in allowed]
services_info = []
for tag in service_tags:
+1 -1
View File
@@ -259,7 +259,7 @@ class HLS:
@staticmethod
def _probe_ts_info(
variant_url: str, session: Optional[Union[Session, CurlSession]] = None
variant_url: str, session: Optional[Union[Session, RnetSession]] = None
) -> Optional[tuple[int, int, Video.Codec]]:
"""Probe the first TS segment of a variant playlist to extract resolution and codec."""
if not session:
+12
View File
@@ -469,6 +469,12 @@ serve:
# Maximum concurrent sessions before oldest is evicted (default: 100)
max_sessions: 100
# Global service allowlist (optional)
# Only these services will be exposed on the API. If omitted, all services are available.
# services:
# - SERVICE_TAG_1
# - SERVICE_TAG_2
users:
secret_key_for_user:
devices: # Widevine devices (WVDs) this user can access
@@ -476,6 +482,12 @@ serve:
playready_devices: # PlayReady devices (PRDs) this user can access
- playready_device_sl3000
username: user
# Per-user service allowlist (optional)
# Restricts this user to only the listed services. If omitted, user can access
# all globally-allowed services. Effective access is the intersection of global
# and per-user allowlists.
# services:
# - SERVICE_TAG_1
# devices: # Widevine device paths (auto-populated from directories.wvds)
# - '/path/to/device.wvd'
# playready_devices: # PlayReady device paths (auto-populated from directories.prds)