ranger/core/debrid.py
Valentin 956a79d726
Some checks are pending
Build & Push Docker image / build (push) Waiting to run
Security: correctifs suite à un audit externe (P0 critiques + P1 rapides)
Suite à un audit externe du dépôt, correction des vulnérabilités confirmées
après vérification manuelle du code :

P0 (critiques) :
- RealDebrid : suppression d'un serveur StremThru tiers codé en dur
  (stremthru.13377001.xyz) auquel la clé API RD était envoyée à l'insu de
  l'utilisateur dès lors qu'aucune StremThru propre n'était configurée.
  Remplacé par un vrai client API Real-Debrid officiel (api.real-debrid.com
  exclusivement).
- XSS réfléchie sur /configure : la config décodée était injectée brute
  (json.dumps) dans un <script> inline, une valeur contenant "</script>"
  cassait hors du tag. Échappement des caractères dangereux en séquences
  unicode avant injection.
- XSS stockée dans le dashboard admin : un ID Stremio non validé entrait
  dans les clés de cache puis était rendu via innerHTML sans échappement
  (dont un point d'injection via onclick jamais échappé du tout). Validation
  stricte de l'IMDB ID côté serveur (^tt\d{5,9}$) + reconstruction du
  dashboard sans innerHTML/onclick interpolés (DOM API + addEventListener).
- Fuite de cache inter-comptes : la clé de cache recherche et la clé de
  cache des liens résolus ne dépendaient pas des identifiants utilisés,
  seulement de l'IMDB/hash. Deux profils différents (ex: plusieurs membres
  d'une famille) partageant la même instance pouvaient recevoir les
  résultats ou liens streaming de l'autre. Namespacing par empreinte des
  credentials (core.cache.fingerprint) pour les trackers/débrideurs à clé ;
  cache resté partagé pour les trackers publics (pas de credential, pas de
  fuite).
- SSRF via Torznab/UNIT3D : les URLs d'indexeurs viennent de la config
  utilisateur, or Ranger est exposé publiquement (config auto-suffisante,
  pas besoin de connaître de clé). Ajout de core/netsafety.py qui résout et
  bloque les cibles loopback/link-local/réservées (dont le 169.254.169.254
  des métadonnées cloud) avant chaque requête ; RFC1918 reste autorisé
  (usage prévu : Jackett/Prowlarr auto-hébergé sur le LAN de l'opérateur).

Corrections additionnelles :
- Token admin : comparaison à temps constant (hmac.compare_digest) et
  suppression du fallback ?token= en query string (loggable par les CDN/proxy).
- Conteneur Docker : utilisateur non-root (UID/GID 1000 fixes).
- RANGER_PUBLIC_URL (optionnel) : URL publique fixe prioritaire sur les
  en-têtes X-Forwarded-Proto/Host, potentiellement usurpables si le port du
  conteneur est aussi joignable directement.
- Détection anime : le genre Animation (16) est désormais requis dans tous
  les cas — un "or orig_lang == 'ja'" classait n'importe quel drama
  live-action japonais comme anime.
- Numérotation absolue transmise jusqu'à /resolve (elle ne servait qu'au
  filtrage) : la sélection du bon fichier dans un pack anime fansub peut
  désormais matcher l'épisode absolu en plus de SxxExx.
- Budget de 20s sur la vérification de disponibilité multi-débrideurs : un
  backend lent/hors service ne bloque plus toute la réponse /stream.
- UNIT3D : suppression de download_torrent (code mort, jamais appelé) et
  réduction du logging de réponse brute (risque de fuite de tokens signés).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:51:33 +02:00

129 lines
5.1 KiB
Python

"""
Couche d'abstraction débrideur.
Unifie les services natifs (AllDebrid, Real-Debrid, TorBox, DebridLink) et leur
proxy StremThru derrière une interface commune :
- check_availability(hashes) -> {hash: bool}
- resolve(hash, season, episode, media_type) -> url directe
Le cache SQLite est consulté avant tout appel réseau ; StremThru remplace le
service natif quand il est configuré (contournement blocage IP).
"""
import asyncio
import logging
from core import cache
from services.alldebrid import AllDebridService
from services.realdebrid import RealDebridService
from services.torbox import TorBoxService
from services.debridlink import DebridLinkService
from services.stremthru import StremThruService, STREMTHRU_STORES
NATIVE_FACTORIES = {
"alldebrid": AllDebridService,
"realdebrid": RealDebridService,
"torbox": TorBoxService,
"debridlink": DebridLinkService,
}
class DebridBackend:
"""Un débrideur configuré (natif ou via StremThru)."""
def __init__(self, service_name, api_key, stremthru=None, client_ip=None):
self.name = service_name
self.api_key = api_key
self._stremthru_cfg = stremthru or {}
self._client_ip = client_ip
self._impl = self._build_impl()
def _build_impl(self):
url = (self._stremthru_cfg.get("url") or "").strip().rstrip("/")
if url and self.name in STREMTHRU_STORES:
logging.info(f"StremThru actif pour {self.name} ({url})")
return StremThruService(
url, self.name, self.api_key,
auth=(self._stremthru_cfg.get("auth") or "").strip() or None,
client_ip=self._client_ip,
)
return NATIVE_FACTORIES[self.name](self.api_key)
@property
def uses_stremthru(self):
return isinstance(self._impl, StremThruService)
def clean_hash(self, info_hash):
if hasattr(self._impl, "_clean_hash"):
return self._impl._clean_hash(info_hash)
return info_hash.lower().strip()
async def check_availability(self, hashes):
"""Vérifie la disponibilité en cache, cache SQLite d'abord."""
if not hashes:
return {}
cleaned = [self.clean_hash(h) for h in hashes]
cleaned = list(dict.fromkeys([h for h in cleaned if h]))
known, unknown = cache.get_availability(self.name, cleaned)
if unknown:
fresh = await self._check_remote(unknown)
cache.set_availability(self.name, fresh)
known.update(fresh)
return known
async def _check_remote(self, hashes):
try:
if self.name == "torbox" and not self.uses_stremthru:
# TorBox natif : un appel par hash
results = await asyncio.gather(
*[self._impl.check_availability(h) for h in hashes],
return_exceptions=True,
)
return {
h: bool(r)
for h, r in zip(hashes, results)
if not isinstance(r, Exception)
}
avail = await self._impl.check_availability(hashes)
# Normalise les clés en hash nettoyés
return {h: bool(avail.get(h, avail.get(self.clean_hash(h), False))) for h in hashes}
except Exception as e:
logging.error(f"{self.name}: check_availability a échoué: {e}")
return {}
async def resolve(self, info_hash, season=None, episode=None, media_type=None, absolute_episode=None):
"""Génère l'URL de lecture directe (ajoute au débrideur si non caché)."""
try:
if self.name == "torbox" and not self.uses_stremthru:
magnet = f"magnet:?xt=urn:btih:{info_hash}"
stream_type = "series" if (season and episode) else "movie"
url = await self._impl.get_stream_link(
magnet, stream_type, season=season, episode=episode, absolute_episode=absolute_episode)
else:
url = await self._impl.unlock_magnet(
info_hash, season=season, episode=episode, media_type=media_type, absolute_episode=absolute_episode)
if url:
cache.mark_cached(self.name, self.clean_hash(info_hash))
return url
except Exception as e:
logging.error(f"{self.name}: resolve a échoué: {e}")
return None
def build_backends(config, client_ip=None):
"""Construit les DebridBackend dans l'ordre de priorité de la config.
StremThru est appliqué par débrideur : un service dont la case StremThru
est décochée utilise son API native même si une URL StremThru est configurée.
"""
from core.config import get_debrids
global_stremthru = config.get("stremthru") or {}
backends = []
for service, key, use_stremthru in get_debrids(config):
try:
stremthru = global_stremthru if use_stremthru else None
backends.append(DebridBackend(service, key, stremthru=stremthru, client_ip=client_ip))
except Exception as e:
logging.error(f"Impossible d'initialiser {service}: {e}")
return backends