fix(security): escape provider catalogue values in the admin streams search

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2uBUbGApb4A7Rbcoi7dxA
This commit is contained in:
root
2026-09-17 07:40:53 +00:00
co-authored by Claude Opus 5
parent fadf0aaeb4
commit 0408e7d8d3
@@ -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 = "<a href=\"javascript: void(0);\" onClick=\"addStream('" . str_replace("'", "\\'", $rStreamURL) . "');\"><button type=\"button\" class=\"btn btn-light waves-effect waves-light btn-xs\"><i class=\"mdi mdi-check\"></i></button></a>";
$rButtons = "<a href=\"javascript: void(0);\" onClick=\"addStream(" . self::jsArgument($rStreamURL) . ");\"><button type=\"button\" class=\"btn btn-light waves-effect waves-light btn-xs\"><i class=\"mdi mdi-check\"></i></button></a>";
} else {
$rStreamURL = ($rRow["ssl"] ? "https" : "http") . "://" . $rRow["ip"] . ":" . $rRow["port"] . "/movie/" . $rRow["username"] . "/" . $rRow["password"] . "/" . $rRow["stream_id"] . "." . $rRow["channel_id"];
$rButtons = "<a href=\"javascript: void(0);\" onClick=\"addStream('" . str_replace("'", "\\'", $rRow["stream_display_name"]) . "', '" . str_replace("'", "\\'", $rStreamURL) . "');\"><button type=\"button\" class=\"btn btn-light waves-effect waves-light btn-xs\"><i class=\"mdi mdi-check\"></i></button></a>";
$rButtons = "<a href=\"javascript: void(0);\" onClick=\"addStream(" . self::jsArgument($rRow["stream_display_name"]) . ", " . self::jsArgument($rStreamURL) . ");\"><button type=\"button\" class=\"btn btn-light waves-effect waves-light btn-xs\"><i class=\"mdi mdi-check\"></i></button></a>";
}
if ((string) $rRow["stream_icon"] !== '' && $rRow["type"] == "live") {
$rIcon = "<img loading='lazy' src='" . $rRow["stream_icon"] . "' height='32px' />";
$rIcon = "<img loading='lazy' src='" . self::htmlValue($rRow["stream_icon"]) . "' height='32px' />";
} else {
$rIcon = "";
}
$rProviderData = json_decode($rRow["data"], true);
$rExpires = $rProviderData["exp_date"] ?: "Never";
$rMaxConnections = $rProviderData["max_connections"] ?: "&infin;";
$rProvider = "<span class='tooltip' title='Expires: " . $rExpires . "<br/>Connections: " . $rProviderData["active_connections"] . " / " . $rMaxConnections . "'>" . $rRow["name"] . "</span>";
$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"]) : "&infin;";
$rProvider = "<span class='tooltip' title='Expires: " . $rExpires . "<br/>Connections: " . self::htmlValue($rProviderData["active_connections"] ?? '') . " / " . $rMaxConnections . "'>" . self::htmlValue($rRow["name"]) . "</span>";
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")) {