From 0408e7d8d3bebd95fe08b9a934387471ee94de45 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 17 Sep 2026 07:40:53 +0000 Subject: [PATCH] fix(security): escape provider catalogue values in the admin streams search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleProviderStreams built the Provider Streams table (the "search provider" modal on the stream and movie pages) by pasting values from the remote provider's API — stream icons, names, container extensions, expiry and connection counts — into HTML attributes and an onClick handler. Rows are read with only < and > entity-encoded, so quotes survived: a provider returning a stream_icon of `x' onerror='…` ran script in the admin's session when the modal opened, and a backslash undid the \' escaping in addStream(). Each value is now decoded and encoded once for where it lands: an attribute, a JSON string literal inside the handler, or cell text. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA --- .../Controllers/Admin/TableController.php | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Public/Controllers/Admin/TableController.php b/src/Public/Controllers/Admin/TableController.php index b0394f09..9d85c911 100644 --- a/src/Public/Controllers/Admin/TableController.php +++ b/src/Public/Controllers/Admin/TableController.php @@ -4892,26 +4892,30 @@ class TableController extends BaseAdminController { if ($rIsAPI) { $rReturn["data"][] = self::filterRow($rRow, RequestManager::get("show_columns") ?? '', RequestManager::get("hide_columns") ?? ''); } else { + // Every value below except the admin's own provider row comes + // from the remote provider's API (ProvidersCronJob), and the + // table renders these cells as HTML: escape each one for where + // it lands — an attribute, a JS string inside an attribute, text. if ($rRow["type"] == "live") { $rStreamURL = ($rRow["ssl"] ? "https" : "http") . "://" . $rRow["ip"] . ":" . $rRow["port"] . "/live/" . $rRow["username"] . "/" . $rRow["password"] . "/" . $rRow["stream_id"] . ($rRow["hls"] ? ".m3u8" : ($rRow["legacy"] ? ".ts" : "")); - $rButtons = ""; + $rButtons = ""; } else { $rStreamURL = ($rRow["ssl"] ? "https" : "http") . "://" . $rRow["ip"] . ":" . $rRow["port"] . "/movie/" . $rRow["username"] . "/" . $rRow["password"] . "/" . $rRow["stream_id"] . "." . $rRow["channel_id"]; - $rButtons = ""; + $rButtons = ""; } if ((string) $rRow["stream_icon"] !== '' && $rRow["type"] == "live") { - $rIcon = ""; + $rIcon = ""; } else { $rIcon = ""; } - $rProviderData = json_decode($rRow["data"], true); - $rExpires = $rProviderData["exp_date"] ?: "Never"; - $rMaxConnections = $rProviderData["max_connections"] ?: "∞"; - $rProvider = "" . $rRow["name"] . ""; + $rProviderData = json_decode((string) $rRow["data"], true) ?: []; + $rExpires = ($rProviderData["exp_date"] ?? null) ? self::htmlValue($rProviderData["exp_date"]) : "Never"; + $rMaxConnections = ($rProviderData["max_connections"] ?? null) ? self::htmlValue($rProviderData["max_connections"]) : "∞"; + $rProvider = "" . self::htmlValue($rRow["name"]) . ""; if ($rRow["type"] == "live") { - $rReturn["data"][] = [$rIcon, $rRow["stream_display_name"], $rProvider, $rButtons]; + $rReturn["data"][] = [$rIcon, self::htmlValue($rRow["stream_display_name"]), $rProvider, $rButtons]; } else { - $rReturn["data"][] = [$rRow["stream_display_name"], $rProvider, $rButtons]; + $rReturn["data"][] = [self::htmlValue($rRow["stream_display_name"]), $rProvider, $rButtons]; } } } @@ -4921,6 +4925,20 @@ class TableController extends BaseAdminController { exit; } + /** + * A database value made safe for HTML text or a quoted attribute. Rows arrive + * with only < and > entity-encoded (Database::clean_row), so they are decoded + * first and encoded once, quotes included. + */ + private static function htmlValue(mixed $rValue): string { + return htmlspecialchars(html_entity_decode((string) $rValue, ENT_QUOTES), ENT_QUOTES); + } + + /** A database value as a JavaScript string literal, for an event-handler attribute. */ + private static function jsArgument(mixed $rValue): string { + return htmlspecialchars((string) json_encode(html_entity_decode((string) $rValue, ENT_QUOTES), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), ENT_QUOTES); + } + private function handleParentServers($rReturn, $rStart, $rLimit, $rIsAPI) { global $db, $rServers; if (!Authorization::check("adv", "servers")) {