mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user