From ea63314ba7c8ddea4cad2ca91b5c3d0479069332 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:19:58 +0000 Subject: [PATCH] =?UTF-8?q?Durcit=20la=20s=C3=A9curit=C3=A9=20du=20backend?= =?UTF-8?q?=20et=20du=20player?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy /api/xtream : - Authentification de session rétablie (Authorization ou cookie HttpOnly session — les requêtes navigateur même-origine le portent) ; le proxy était volontairement ouvert, offrant un rebond SSRF non authentifié - Redirections suivies manuellement avec revalidation à chaque saut (hôte privé interdit + allowlist de domaine) : avec followRedirects, seule l'URL initiale était validée, une 302 amont suffisait pour atteindre un hôte interne - Erreurs proxy sans détail d'exception (ClientException porte l'URL amont, credentials Xtream inclus), logs redactés Logs : - redactedLogRequests remplace logRequests() de shelf : l'URI de /api/xtream/ écrivait username/password Xtream en clair à chaque requête, annulant l'effort de LogRedactor partout ailleurs - Les 500 d'epg_api ne renvoient plus e.toString() au client (même risque ClientException) ; détail redacté en log serveur Middleware : - X-Forwarded-For honoré uniquement depuis un proxy de confiance (loopback + RFC1918 par défaut, surchargables via TRUSTED_PROXIES) : un client direct forgeait l'en-tête et contournait le rate limit global comme la limite de tentatives de login - Honeypot comparé sur chemin exact/préfixe : l'ancien contains(trap.replaceAll('/','')) bloquait toute URL contenant console, env ou wpadmin, y compris des URLs proxifiées légitimes Comptes : - Mot de passe admin initial aléatoire (Random.secure, affiché une fois au démarrage) ou ADMIN_INITIAL_PASSWORD ; fini le admin/admin persistant - Longueur minimale de 8 caractères à la création et au changement Divers : - CleanupService ne cible plus Directory.systemTemp en récursif (il supprimait les temporaires de la VM Dart et le parent des sessions HLS) - web/xf-player-core.js : postMessage vers location.origin au lieu de '*', et filtrage d'event.origin à la réception (le côté Flutter le faisait déjà) Validé : dart analyze (0 issue) et dart test (48/48) sur bin/. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015oEu9QayWsw7hCKhenxgVa --- bin/api/epg_api.dart | 13 +++- bin/api/proxy_handler.dart | 89 +++++++++++++++++++---- bin/api/users_handler.dart | 14 ++++ bin/database/database.dart | 38 +++++++++- bin/middleware/auth_middleware.dart | 7 +- bin/middleware/security_middleware.dart | 93 ++++++++++++++++++++++--- bin/server.dart | 10 ++- web/xf-player-core.js | 8 ++- 8 files changed, 236 insertions(+), 36 deletions(-) diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart index fab003f..ea27153 100644 --- a/bin/api/epg_api.dart +++ b/bin/api/epg_api.dart @@ -3,6 +3,7 @@ import 'package:shelf/shelf.dart'; import 'package:http/http.dart' as http; import '../models/playlist_config.dart'; import '../services/xmltv_epg_service.dart'; +import '../utils/log_redactor.dart'; /// API EPG — proxy vers Xtream avec cache 30 minutes /// GET /api/epg/?days=1 @@ -112,8 +113,11 @@ class EpgApi { }, ); } catch (e) { + // Détail redacté en log uniquement : une ClientException Dart contient + // l'URI amont, credentials Xtream inclus. + print('[EpgApi] Erreur EPG: ${LogRedactor.redactUrl('$e')}'); return Response.internalServerError( - body: json.encode({'error': 'Erreur lors de la récupération EPG: $e'}), + body: json.encode({'error': 'Erreur lors de la récupération EPG'}), headers: {'Content-Type': 'application/json'}, ); } @@ -258,7 +262,12 @@ class EpgApi { return {'channel_id': channelId, 'programmes': programmes}; } catch (e) { - return {'channel_id': channelId, 'programmes': [], 'error': e.toString()}; + print('[EpgApi] Erreur panneau pour $channelId: ${LogRedactor.redactUrl('$e')}'); + return { + 'channel_id': channelId, + 'programmes': [], + 'error': 'EPG indisponible', + }; } } diff --git a/bin/api/proxy_handler.dart b/bin/api/proxy_handler.dart index b6f2c6a..9ae9c66 100644 --- a/bin/api/proxy_handler.dart +++ b/bin/api/proxy_handler.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:shelf/shelf.dart'; import 'package:http/http.dart' as http; +import '../database/database.dart'; +import '../middleware/auth_middleware.dart'; import '../models/playlist_config.dart'; import '../utils/log_redactor.dart'; @@ -31,6 +33,7 @@ bool isForbiddenProxyHost(String host) { /// Handler for the Xtream Proxy class ProxyHandler { final Future Function(Request) _getPlaylist; + final AppDatabase _db; final http.Client _client = http.Client(); final Map _playlistCache = {}; @@ -70,7 +73,7 @@ class ProxyHandler { return playlist; } - ProxyHandler(this._getPlaylist); + ProxyHandler(this._getPlaylist, this._db); /// Create Xtream proxy handler with M3U8 URL rewriting support Handler get handler { @@ -84,8 +87,16 @@ class ProxyHandler { return Response.notFound(null); } - // NOTE: Authentication REMOVED from proxy to allow browser-initiated requests (img src, etc.) - // SSRF protection is still active via domain validation below. + // Authentification par session. Les requêtes initiées par le navigateur + // (img src, hls.js) ne portent pas d'en-tête Authorization mais envoient + // le cookie HttpOnly `session` (SameSite=Lax, même origine) posé au + // login : extractAuthToken accepte les deux. Un proxy ouvert offrait un + // rebond SSRF non authentifié vers n'importe quel hôte public via les + // extensions d'image. + final token = extractAuthToken(request); + if (token == null || _db.findSessionByToken(token) == null) { + return Response(401, body: 'Unauthorized'); + } Uri? targetUrl; @@ -139,6 +150,7 @@ class ProxyHandler { targetUrl.path.contains('/picons/') || targetUrl.path.contains('/logos/'); + String? allowedHost; if (!isStaticAsset) { // For API calls, enforce domain allowlist final playlist = await _getCachedPlaylist(request); @@ -149,7 +161,7 @@ class ProxyHandler { } final targetHost = targetUrl.host.toLowerCase(); - final allowedHost = Uri.parse(playlist.dns).host.toLowerCase(); + allowedHost = Uri.parse(playlist.dns).host.toLowerCase(); if (targetHost != allowedHost) { print( @@ -175,7 +187,6 @@ class ProxyHandler { try { print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}'); - final proxyRequest = http.Request(request.method, targetUrl); // Forward safe request headers for (final header in _allowedRequestHeaders) { @@ -184,18 +195,61 @@ class ProxyHandler { } } - proxyRequest.headers.addAll(proxyHeaders); - proxyRequest.followRedirects = true; - + List? postBody; if (request.method == 'POST') { final bodyBytes = await request.read().toList(); - proxyRequest.bodyBytes = bodyBytes.expand((i) => i).toList(); + postBody = bodyBytes.expand((i) => i).toList(); } - // Added 90s timeout to allow frontend (60s) to time out gracefully first - final response = await _client - .send(proxyRequest) - .timeout(const Duration(seconds: 90)); + // Redirections suivies MANUELLEMENT : chaque destination est + // revalidée (hôte privé, allowlist de domaine). Avec + // followRedirects, la validation ne portait que sur l'URL + // initiale — une 302 du serveur amont suffisait pour atteindre + // un hôte interne malgré l'anti-SSRF. + http.StreamedResponse response; + var currentUrl = targetUrl; + var redirects = 0; + while (true) { + final proxyRequest = http.Request(request.method, currentUrl); + proxyRequest.headers.addAll(proxyHeaders); + proxyRequest.followRedirects = false; + if (postBody != null) proxyRequest.bodyBytes = postBody; + + // Added 90s timeout to allow frontend (60s) to time out gracefully first + response = await _client + .send(proxyRequest) + .timeout(const Duration(seconds: 90)); + + final location = response.headers['location']; + final isRedirect = response.statusCode >= 300 && + response.statusCode < 400 && + location != null; + if (!isRedirect) break; + + if (++redirects > 3) { + return Response.forbidden('Too many redirects'); + } + final next = Uri.parse(location); + currentUrl = next.isAbsolute ? next : currentUrl.resolve(location); + if (currentUrl.scheme != 'http' && currentUrl.scheme != 'https') { + return Response.forbidden('Unsupported redirect scheme'); + } + if (isForbiddenProxyHost(currentUrl.host)) { + print( + '[Proxy] Blocked SSRF redirect to private host: ${currentUrl.host}', + ); + return Response.forbidden('Access to this host is forbidden'); + } + if (allowedHost != null && + currentUrl.host.toLowerCase() != allowedHost) { + print( + '[Proxy] Blocked redirect to ${currentUrl.host} (Allowed: $allowedHost)', + ); + return Response.forbidden( + 'Access to this domain is forbidden by policy', + ); + } + } // Build response headers from source response final responseHeaders = { @@ -221,7 +275,12 @@ class ProxyHandler { rethrow; } } catch (e) { - print('[ProxyHandler] error on $path: $e'); + // Redaction : une ClientException porte l'URL amont, credentials + // Xtream inclus. Jamais de détail d'exception vers le client. + print( + '[ProxyHandler] error on ${LogRedactor.redactUrl(path)}: ' + '${LogRedactor.redactUrl('$e')}', + ); // Return transparent 1x1 pixel image fallback for images if (targetUrl?.path.endsWith('.png') == true || @@ -235,7 +294,7 @@ class ProxyHandler { } return Response.internalServerError( - body: jsonEncode({'error': 'Proxy error', 'message': e.toString()}), + body: jsonEncode({'error': 'Proxy error'}), headers: {'content-type': 'application/json'}, ); } diff --git a/bin/api/users_handler.dart b/bin/api/users_handler.dart index de3a7e9..886e71e 100644 --- a/bin/api/users_handler.dart +++ b/bin/api/users_handler.dart @@ -65,6 +65,13 @@ class UsersHandler { }), headers: {'Content-Type': 'application/json'},); } + if (password.length < 8) { + return Response.badRequest(body: jsonEncode({ + 'success': false, + 'error': 'Le mot de passe doit faire au moins 8 caractères', + }), headers: {'Content-Type': 'application/json'},); + } + if (db.findUserByUsername(username) != null) { return Response.badRequest(body: jsonEncode({ 'success': false, @@ -103,6 +110,13 @@ class UsersHandler { }), headers: {'Content-Type': 'application/json'},); } + if (password.length < 8) { + return Response.badRequest(body: jsonEncode({ + 'success': false, + 'error': 'Le mot de passe doit faire au moins 8 caractères', + }), headers: {'Content-Type': 'application/json'},); + } + db.updateUserPassword(id, password); return Response.ok(jsonEncode({'success': true}), headers: {'Content-Type': 'application/json'}); diff --git a/bin/database/database.dart b/bin/database/database.dart index 71dc5a5..4d4fdd5 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'package:sqlite3/sqlite3.dart'; import 'package:uuid/uuid.dart'; import '../models/user.dart'; @@ -127,14 +128,21 @@ class AppDatabase { ); } - /// Seed default admin user if no users exist + /// Seed default admin user if no users exist. + /// + /// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré + /// aléatoirement et affiché UNE FOIS dans les logs de démarrage. L'ancien + /// couple admin/admin restait souvent en place sur les instances exposées. Future seedAdmin() async { final result = _db.select('SELECT COUNT(*) as count FROM users'); final count = result.first['count'] as int; if (count == 0) { final adminId = _uuid.v4(); - final passwordHash = PasswordHasher.hash('admin'); + final envPassword = Platform.environment['ADMIN_INITIAL_PASSWORD']; + final generated = envPassword == null || envPassword.isEmpty; + final password = generated ? _generatePassword() : envPassword; + final passwordHash = PasswordHasher.hash(password); _db.execute( ''' @@ -144,10 +152,34 @@ class AppDatabase { [adminId, 'admin', passwordHash], ); - print('Default admin user created (username: admin, password: admin)'); + if (generated) { + print('╔══════════════════════════════════════════════════════════╗'); + print(' Compte admin créé — mot de passe initial (affiché une'); + print(' seule fois, changez-le après la première connexion) :'); + print(' utilisateur: admin'); + print(' mot de passe: $password'); + print('╚══════════════════════════════════════════════════════════╝'); + } else { + print( + 'Default admin user created (username: admin, ' + 'password: ADMIN_INITIAL_PASSWORD)', + ); + } } } + static String _generatePassword({int length = 16}) { + // Sans caractères ambigus (0/O, 1/l/I) : le mot de passe est recopié + // depuis les logs du conteneur. + const chars = + 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + final random = Random.secure(); + return List.generate( + length, + (_) => chars[random.nextInt(chars.length)], + ).join(); + } + // ==================== Users ==================== /// Find user by username diff --git a/bin/middleware/auth_middleware.dart b/bin/middleware/auth_middleware.dart index 08b9be6..fb135fb 100644 --- a/bin/middleware/auth_middleware.dart +++ b/bin/middleware/auth_middleware.dart @@ -77,7 +77,12 @@ Middleware streamAuthMiddleware(AppDatabase db) { }; } -/// Extract token from Authorization header or cookie +/// Extract token from Authorization header or cookie. +/// Public : le proxy /api/xtream fait sa propre vérification de session +/// (le contrôle doit rester DANS le handler, après le test de chemin, +/// pour que les requêtes non-proxy tombent sur le handler statique). +String? extractAuthToken(Request request) => _extractToken(request); + String? _extractToken(Request request) { // Try Authorization header first final authHeader = request.headers['authorization']; diff --git a/bin/middleware/security_middleware.dart b/bin/middleware/security_middleware.dart index d896d80..9d307c3 100644 --- a/bin/middleware/security_middleware.dart +++ b/bin/middleware/security_middleware.dart @@ -1,26 +1,94 @@ import 'package:shelf/shelf.dart'; import 'dart:async'; import 'dart:io'; +import '../utils/log_redactor.dart'; /// Security Middleware Collection /// /// Includes: +/// - Redacted request logging /// - Honeypot Routes (Trap for bots) /// - Security Headers (HSTS, XSS Protection, CSP Report-Only) /// - Rate Limiting (Basic DoS protection) /// - Login-specific rate limiting (brute-force protection) -/// Resolve the real client IP. -/// Honors the first hop of X-Forwarded-For when behind nginx, otherwise -/// falls back to the socket connection info. -String clientIpOf(Request request) { - final forwarded = request.headers['x-forwarded-for']; - if (forwarded != null && forwarded.isNotEmpty) { - return forwarded.split(',').first.trim(); +/// Proxys de confiance dont l'en-tête X-Forwarded-For est honoré. +/// Par défaut : loopback et plages privées RFC1918 (le reverse proxy du +/// docker-compose parle depuis le réseau Docker). Surcharger avec +/// TRUSTED_PROXIES (liste d'IP séparées par des virgules) pour restreindre. +final List _trustedProxies = + (Platform.environment['TRUSTED_PROXIES'] ?? '') + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + +bool _isTrustedProxy(String address) { + if (_trustedProxies.isNotEmpty) return _trustedProxies.contains(address); + final ip = InternetAddress.tryParse(address); + if (ip == null) return false; + if (ip.isLoopback) return true; + if (ip.type == InternetAddressType.IPv4) { + final parts = ip.address.split('.').map(int.parse).toList(); + if (parts[0] == 10) return true; + if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true; + if (parts[0] == 192 && parts[1] == 168) return true; } + return false; +} + +/// Resolve the real client IP. +/// +/// X-Forwarded-For n'est honoré que si la connexion socket provient d'un +/// proxy de confiance : sinon un client direct peut forger l'en-tête et +/// contourner le rate limit global comme la limite de tentatives de login. +String clientIpOf(Request request) { final connectionInfo = request.context['shelf.io.connection_info'] as HttpConnectionInfo?; - return connectionInfo?.remoteAddress.address ?? 'unknown'; + final socketAddress = connectionInfo?.remoteAddress.address; + + final forwarded = request.headers['x-forwarded-for']; + if (forwarded != null && + forwarded.isNotEmpty && + socketAddress != null && + _isTrustedProxy(socketAddress)) { + return forwarded.split(',').first.trim(); + } + return socketAddress ?? 'unknown'; +} + +/// 0. Redacted request logging. +/// +/// Remplace `logRequests()` de shelf : le chemin `/api/xtream/` embarque +/// `username`/`password` Xtream en clair dans l'URI, que le logger standard +/// écrivait tels quels — annulant l'effort de LogRedactor partout ailleurs. +Middleware redactedLogRequests() { + return (Handler handler) { + return (Request request) async { + final watch = Stopwatch()..start(); + try { + final response = await handler(request); + watch.stop(); + final query = + request.requestedUri.hasQuery ? '?${request.requestedUri.query}' : ''; + print( + '${DateTime.now().toIso8601String()} ${response.statusCode} ' + '${request.method} ' + '${LogRedactor.redactUrl('${request.requestedUri.path}$query')} ' + '(${watch.elapsedMilliseconds}ms)', + ); + return response; + } catch (e) { + watch.stop(); + print( + '${DateTime.now().toIso8601String()} ERR ${request.method} ' + '${LogRedactor.redactUrl(request.requestedUri.path)}: ' + '${LogRedactor.redactUrl('$e')}', + ); + rethrow; + } + }; + }; } /// 1. Security Headers Middleware @@ -71,11 +139,14 @@ Middleware honeypotMiddleware() { return (Handler handler) { return (Request request) { - final path = request.url.path; + // Comparaison sur le chemin exact ou un préfixe de segment. L'ancienne + // comparaison `contains(trap.replaceAll('/', ''))` bloquait toute URL + // contenant « console », « env » ou « wpadmin » n'importe où — y + // compris des URLs proxifiées parfaitement légitimes. + final path = '/${request.url.path}'; - // Check if path contains any honeypot target for (final trap in honeypotPaths) { - if (path.contains(trap.replaceAll('/', ''))) { // Simple check + if (path == trap || path.startsWith('$trap/')) { print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path'); return Response.forbidden('Access Denied'); } diff --git a/bin/server.dart b/bin/server.dart index 0db4644..1869280 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -114,7 +114,7 @@ void main(List args) async { final playlistsHandler = PlaylistsHandler(db); final usersHandler = UsersHandler(db); final settingsHandler = SettingsHandler(db); - final proxyHandler = ProxyHandler(getPlaylist); + final proxyHandler = ProxyHandler(getPlaylist, db); final recordingsApi = RecordingsApi(db, recordingScheduler); // Source XMLTV de repli. Vider EPG_XMLTV_URLS désactive tout appel sortant : // l'EPG se limite alors au panneau de l'abonné. @@ -216,8 +216,10 @@ void main(List args) async { // Do NOT mount here as it would intercept and block the actual proxy // Initialize Cleanup Service + // Ne JAMAIS cibler Directory.systemTemp en récursif : il contient les + // temporaires de la VM Dart et le dossier des sessions HLS — les fichiers + // de plus de 24 h y étaient supprimés aveuglément. final cleanupService = CleanupService(); - cleanupService.addTarget(Directory.systemTemp); cleanupService.addTarget(Directory('/app/data/logs')); cleanupService.addTarget(Directory('/app/data/tmp')); @@ -332,8 +334,10 @@ void main(List args) async { .handler; // Add middleware + // redactedLogRequests remplace logRequests() : l'URI de /api/xtream/ + // contient username/password Xtream en clair. final pipeline = const Pipeline() - .addMiddleware(logRequests()) + .addMiddleware(redactedLogRequests()) .addMiddleware(securityHeadersMiddleware()) .addMiddleware(honeypotMiddleware()) .addMiddleware(rateLimitMiddleware()) diff --git a/web/xf-player-core.js b/web/xf-player-core.js index f873025..b164223 100644 --- a/web/xf-player-core.js +++ b/web/xf-player-core.js @@ -131,7 +131,10 @@ }; XFPlayer.prototype.send = function (msg) { - try { global.parent.postMessage(msg, '*'); } catch (e) {} + // Cible restreinte à notre origine : l'iframe est toujours même-origine + // que le parent Flutter, un wildcard '*' livrerait l'état du player à + // n'importe quelle page qui embarquerait player.html. + try { global.parent.postMessage(msg, global.location.origin); } catch (e) {} }; // ---------------- Démarrage ---------------- @@ -491,6 +494,9 @@ XFPlayer.prototype._wireParentMessages = function () { var self = this; global.addEventListener('message', function (event) { + // N'accepter que les commandes émises par notre propre origine + // (le côté Flutter filtre déjà les messages entrants de la même façon). + if (event.origin !== global.location.origin) return; var d = event.data; if (!d || !d.type) return; var v = self.video;