Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
86a377e
spotify-control: harden deleteLocal against path-traversal + shell in…
wowa1990 May 8, 2026
e7aee81
bluetooth scripts: validate MAC, fix typos in pair_bt.sh and remove_b…
wowa1990 May 7, 2026
2dd79b4
admin: gate backup/fullbackup/debug/pm2logs/support_data/backend behi…
wowa1990 May 7, 2026
649c996
admin: gate hshutdown/hreboot/hchromerestart/hrefreshdatabase behind …
wowa1990 May 7, 2026
7e9735a
admin: harden Backup-Restore against unauth upload, ZIP-slip and shel…
wowa1990 May 7, 2026
dd0d8d1
admin: harden jsoneditor with CSRF token, interfacelogin pin, two-sta…
wowa1990 May 7, 2026
438116d
telegram receiver: enforce chatId-based authorization (security fix)
wowa1990 May 6, 2026
614b8c8
admin: global CSRF helpers + enforce on service / tweaks / mupihat (M…
wowa1990 May 8, 2026
de72adb
admin: require auth on update_wifiicon / batteryicon / fanicon / mupi…
wowa1990 May 8, 2026
d33c8c7
admin: escape shell args / validate input across spotify, bluetooth, …
wowa1990 May 7, 2026
7d8fcac
admin: htmlspecialchars on data.json + host fields to block stored XS…
wowa1990 May 8, 2026
74ecbbb
backend-api: harden /api/rssfeed against SSRF (MED-2)
wowa1990 May 8, 2026
e4d7b27
spotify-api.service: SHA-256 cache filenames to block path-traversal …
wowa1990 May 8, 2026
30c6f4b
AR5-3: drop npm-squat http and userland path-polyfill from deps
wowa1990 May 8, 2026
3b14dc2
fix(admin): PHP security cleanup (M10, M11, AR5-15, AR5-17)
wowa1990 May 15, 2026
d04f460
fix(admin): validate Custom battery config inputs (AR5-16)
wowa1990 May 15, 2026
d5003f7
fix(hat): bind debug Flask server to loopback (AR5-20)
wowa1990 May 15, 2026
10f8cee
H6: cpugovernor whitelist (mupi.php + tweaks.php)
wowa1990 May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 77 additions & 14 deletions AdminInterface/www/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,99 @@ function write_json($data)
exec("sudo -i -u dietpi /usr/local/bin/mupibox/./restart_kiosk.sh");
}

// Narrow header-only auth gate for the submitfile upload handler below.
// The handler lives ABOVE `include 'includes/header.php'`, so without
// this an unauthenticated LAN POST can drop a crafted zip and have it
// extracted to / via `unzip -d /`. All other POST handlers in this
// file run AFTER the include — header.php's own auth gate already
// blocks them on unauth, so we explicitly do NOT block other POSTs
// here. In particular the login POST (password=...) must flow through
// to header.php so the user can authenticate in the first place.
session_start();
$__authJson = file_get_contents('/etc/mupibox/mupiboxconfig.json', true);
$__authCfg = json_decode($__authJson, true);
$__loginRequired = !empty($__authCfg['interfacelogin']['state']);
$__loggedIn = isset($_SESSION['logged_in']) && $_SESSION['logged_in'] === true;
if ($__loginRequired && !$__loggedIn && !empty($_POST['submitfile'])) {
http_response_code(403);
exit('Authentication required');
}

$shutdown=0;
$reboot=0;

if( $_POST['submitfile'] )
if( !empty($_POST['submitfile']) )
{
$target_dir = "/tmp/";
$target_file = $target_dir . basename($_FILES["fileToUpload"]["name"]);
// Strip any directory components from the user-controlled filename.
// The filename is later interpolated into a shell command, so even
// after escapeshellarg() we want the basename so the file lands in
// /tmp/ and not somewhere else via a relative path inside the name.
$rawName = basename($_FILES["fileToUpload"]["name"]);
// Conservative whitelist on filename: letters, digits, dot, dash,
// underscore. Anything else (spaces, quotes, semicolons, …) is
// rejected outright. Backup zips produced by backup.php/fullbackup.php
// match this pattern.
$uploadOk = 1;
$FileType = strtolower(pathinfo($target_file,PATHINFO_EXTENSION));
// Allow zip file format
if($FileType != "zip" )
{
if (!preg_match('/^[A-Za-z0-9._-]+\.zip$/', $rawName)) {
$uploadOk = 0;
}
}
$target_file = $target_dir . $rawName;
// Check if $uploadOk is set to 0 by an error
if ($uploadOk == 0)
{
$CHANGE_TXT=$CHANGE_TXT."<li>WARNING: Please upload a .zip-File!</li>";
$CHANGE_TXT=$CHANGE_TXT."<li>WARNING: Please upload a .zip-File! (only A-Z, 0-9, ._- allowed in filename)</li>";
$change=0;
}
else
{
if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file))
{
// ZIP-Slip / arbitrary-path defence. backup.php and
// fullbackup.php only ever pack files under three roots —
// reject any zip entry that escapes them. Without this,
// `unzip -o -a -d /` happily writes anywhere on disk.
$allowedPrefixes = [
'home/dietpi/MuPiBox/media/',
'etc/mupibox/mupiboxconfig.json',
'home/dietpi/.mupibox/Sonos-Kids-Controller-master/server/config/data.json',
];
$zip = new ZipArchive();
$zipOk = false;
$badEntry = '';
if ($zip->open($target_file) === true) {
$zipOk = true;
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
// Normalise: strip leading slash, forbid `..`
$norm = ltrim($entry, '/');
if (strpos($norm, '..') !== false) {
$zipOk = false;
$badEntry = $entry;
break;
}
$matched = false;
foreach ($allowedPrefixes as $p) {
if (strpos($norm, $p) === 0) { $matched = true; break; }
}
if (!$matched) {
$zipOk = false;
$badEntry = $entry;
break;
}
}
$zip->close();
}
if (!$zipOk) {
exec("sudo rm " . escapeshellarg($target_file));
$CHANGE_TXT=$CHANGE_TXT."<li>ERROR: Backup rejected (entry outside whitelist: ".htmlspecialchars($badEntry).")</li>";
$change=0;
} else {
$string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true);
$data = json_decode($string, true);
$old_version = $data["mupibox"]["version"];

$command = "sudo unzip -o -a '".$target_file."' -d / >> /tmp/restore.log";
#$command = "sudo su - -c \"unzip -o -a '".$target_file."' -d / >> /tmp/restore.log && sleep 1\"";
#$command = "sudo su - -c 'tar xvzf ".$target_file." >> /tmp/restore.log'";
$command = "sudo unzip -o -a " . escapeshellarg($target_file) . " -d / >> /tmp/restore.log";
exec($command, $output, $result );
exec("sudo chown root:www-data /etc/mupibox/mupiboxconfig.json");
exec("sudo chmod 644 /etc/mupibox/mupiboxconfig.json");
Expand All @@ -55,17 +117,18 @@ function write_json($data)
$data["mupibox"]["version"] = $old_version;
write_json($data);

$command = "sudo /boot/dietpi/func/change_hostname " . $data["mupibox"]["host"];
$command = "sudo /boot/dietpi/func/change_hostname " . escapeshellarg($data["mupibox"]["host"]);
$change_hostname = exec($command, $output, $change_hostname );
$command = "sudo su dietpi -c '/usr/local/bin/mupibox/./set_hostname.sh'";
exec($command);
$command = "sudo rm '".$target_file."'";

$command = "sudo rm " . escapeshellarg($target_file);
exec($command, $output, $result );
$change=99;
$CHANGE_TXT=$CHANGE_TXT."<li>Backup-File restored! The MuPiBox will reboot now!</li>";
$reboot=1;
}
}
else
{
$CHANGE_TXT=$CHANGE_TXT."<li>ERROR: Error on uploading Backup-File!</li>";
Expand Down
5 changes: 5 additions & 0 deletions AdminInterface/www/backend.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<?php
require __DIR__ . '/includes/auth_check.php';

// backend.php streams pm2 log contents and service status as plain text
// for XHR consumers in the admin UI. Use the header-only gate so the
// fetch() body stays free of HTML chrome.
$logfiles = [
'server-error' => '/home/dietpi/.pm2/logs/server-error.log',
'server-out' => '/home/dietpi/.pm2/logs/server-out.log',
Expand Down
9 changes: 8 additions & 1 deletion AdminInterface/www/backup.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
<?php
$command = "sudo rm /var/www/config_backup.zip; sudo zip -r /var/www/config_backup.zip /home/dietpi/MuPiBox/media/cover/* /etc/mupibox/mupiboxconfig.json /home/dietpi/.mupibox/Sonos-Kids-Controller-master/server/config/data.json;sudo chmod 777 /var/www/config_backup.zip; sudo chown www-data:www-data /var/www/config_backup.zip";
require __DIR__ . '/includes/auth_check.php';

// The backup zip exposes the bcrypt password hash from interfacelogin and
// every Spotify/Telegram credential in mupiboxconfig.json. auth_check.php
// (NOT header.php — header.php would render the admin chrome HTML before
// we get a chance to set Content-Type: application/octet-stream below)
// short-circuits with 401 for unauthenticated callers.
$command = "sudo rm /var/www/config_backup.zip; sudo zip -r /var/www/config_backup.zip /home/dietpi/MuPiBox/media/cover/* /etc/mupibox/mupiboxconfig.json /home/dietpi/.mupibox/Sonos-Kids-Controller-master/server/config/data.json;sudo chmod 600 /var/www/config_backup.zip; sudo chown www-data:www-data /var/www/config_backup.zip";
exec($command );

//Define header information
Expand Down
67 changes: 51 additions & 16 deletions AdminInterface/www/bluetooth.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
default-agent
scan on
*/
// AR5-15: bluetooth.php was missed by the Phase-5 CSRF sweep. Every
// POST handler below runs `sudo systemctl` or `sudo /usr/local/bin/
// mupibox/*_bt.sh` — a cross-site request from another admin tab
// (or a logged-in admin opening a hostile page) could toggle
// Bluetooth, pair an attacker MAC, or remove a paired device. Gate
// all writes behind csrf_check() before any other code runs.
require_once __DIR__ . '/includes/csrf.php';
csrf_check();
include ('includes/header.php');

if( $_POST['change_btac'] == "enable & start" )
Expand All @@ -24,25 +32,43 @@
$CHANGE_TXT=$CHANGE_TXT."<li>BT-Autoconnect-Service disabled</li>";
}

// Both BT-handlers feed a MAC address into a shell exec. The receiving
// scripts (pair_bt.sh / remove_bt.sh) already validate the MAC via
// regex since CRIT-7, but the shell command line itself is built here
// — if we don't validate, an attacker (admin-authenticated, but still)
// could squeeze backticks or `; rm -rf` into the parameter and the
// shell would expand it before pair_bt.sh ever runs. Defence in depth:
// reject anything that isn't a canonical AA:BB:CC:DD:EE:FF MAC, then
// escapeshellarg() the value as well.
$btMacRegex = '/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/';
if( $_POST['remove_selected'] )
{
$command = "sudo -u dietpi /usr/local/bin/mupibox/./remove_bt.sh ".$_POST['remove_mac'];
exec($command, $output, $result );
$CHANGE_TXT=$CHANGE_TXT."<li>Pairing removed [".$_POST['remove_mac']."</li>";
$command = "sudo -u dietpi /usr/local/bin/mupibox/./stop_bt.sh";
exec($command, $output, $result );
$command = "sudo -u dietpi /usr/local/bin/mupibox/./start_bt.sh";
exec($command, $output, $result );

$change=1;
$mac = $_POST['remove_mac'] ?? '';
if (!preg_match($btMacRegex, $mac)) {
$CHANGE_TXT=$CHANGE_TXT."<li>ERROR: invalid MAC, refused</li>"; $change=1;
} else {
$command = "sudo -u dietpi /usr/local/bin/mupibox/./remove_bt.sh " . escapeshellarg($mac);
exec($command, $output, $result );
$CHANGE_TXT=$CHANGE_TXT."<li>Pairing removed [" . htmlspecialchars($mac) . "]</li>";
$command = "sudo -u dietpi /usr/local/bin/mupibox/./stop_bt.sh";
exec($command, $output, $result );
$command = "sudo -u dietpi /usr/local/bin/mupibox/./start_bt.sh";
exec($command, $output, $result );
$change=1;
}
}

if( $_POST['pair_selected'] )
{
$command = "sudo -u dietpi /usr/local/bin/mupibox/./pair_bt.sh ".$_POST['bt_device'];
exec($command, $output, $result );
$CHANGE_TXT=$CHANGE_TXT."<li>Device is paired [".$_POST['bt_device']."</li>";
$change=1;
$mac = $_POST['bt_device'] ?? '';
if (!preg_match($btMacRegex, $mac)) {
$CHANGE_TXT=$CHANGE_TXT."<li>ERROR: invalid MAC, refused</li>"; $change=1;
} else {
$command = "sudo -u dietpi /usr/local/bin/mupibox/./pair_bt.sh " . escapeshellarg($mac);
exec($command, $output, $result );
$CHANGE_TXT=$CHANGE_TXT."<li>Device is paired [" . htmlspecialchars($mac) . "]</li>";
$change=1;
}
}


Expand Down Expand Up @@ -151,11 +177,20 @@
foreach($pairoutput as $device)
{
$split_device=explode(" ", $device);
// AR5-15: the MAC comes from `bluetoothctl devices` so it's normally
// a safe AA:BB:CC:DD:EE:FF value, but a paired device with a
// hostile-name BT stack could in theory emit a forged second
// column. escapeshellarg for the shell side, htmlspecialchars
// for the form/HTML side.
$mac = $split_device[1] ?? '';
$name = $split_device[2] ?? '';
$macHtml = htmlspecialchars($mac, ENT_QUOTES);
$nameHtml = htmlspecialchars($name, ENT_QUOTES);
print "<form class='appnitro' method='post' action='bluetooth.php' id='remform'>";
print "<input type='hidden' name='remove_mac' value='".$split_device[1]."'>";
print "<input type='hidden' name='remove_mac' value='".$macHtml."'>";
print "<input id='saveForm' class='button_text' type='submit' name='remove_selected' value='Remove' />&ensp;";
print $split_device[2]." [".$split_device[1]."]";
$command = "sudo -u dietpi bluetoothctl info ".$split_device[1]." | grep 'Connected: yes'";
print $nameHtml." [".$macHtml."]";
$command = "sudo -u dietpi bluetoothctl info ".escapeshellarg($mac)." | grep 'Connected: yes'";
unset($connoutput);
exec($command, $connoutput, $connresult );
if( $connoutput[0] )
Expand Down
11 changes: 8 additions & 3 deletions AdminInterface/www/content.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
<p>Audio books, music and streams can be added here. Control is the same as on the display, but the display is not synchronized. Playing music cannot be started here.</p>
<?php
$ip=exec("hostname -I | awk '{print $1}'");

print "<div style='max-width:800px;'><p><embed src='http://".$data["mupibox"]["host"].":8200' id='remotecontrol' width='800px' height='480px'></p></div>";
print "<p><a href='http://".$ip.":8200' id='remotecontrol' target='_blank'>If it doesn't display properly or can't be served, try this Link and click me...</a></p>";
// LOW-2: same XSS vector as vnc.php — host attribute escape.
// hostname -I output is system-controlled and shouldn't contain
// dangerous chars, but escape it too just in case (hostname could
// theoretically be a value an attacker has set elsewhere).
$h = htmlspecialchars((string)$data["mupibox"]["host"], ENT_QUOTES, 'UTF-8');
$hIp = htmlspecialchars((string)$ip, ENT_QUOTES, 'UTF-8');
print "<div style='max-width:800px;'><p><embed src='http://".$h.":8200' id='remotecontrol' width='800px' height='480px'></p></div>";
print "<p><a href='http://".$hIp.":8200' id='remotecontrol' target='_blank'>If it doesn't display properly or can't be served, try this Link and click me...</a></p>";

?>

Expand Down
50 changes: 40 additions & 10 deletions AdminInterface/www/cover.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,41 @@

if( $_POST['deleteimage'] )
{
$file2delete = "/var/www/cover/".$_POST['image'];
exec("sudo rm " . $file2delete);
$change=1;
$CHANGE_TXT=$CHANGE_TXT."<li>Image " . $file2delete . " deleted!</li>";
// `sudo rm /var/www/cover/$image` ran as root, with $image straight
// from POST. POSTing image=../../etc/mupibox/mupiboxconfig.json
// would happily wipe the box's config. basename() collapses any
// path components, and a name whitelist (only filename-safe chars)
// rejects shell metacharacters before escapeshellarg.
$rawName = basename($_POST['image'] ?? '');
if (!preg_match('/^[A-Za-z0-9._-]+$/', $rawName)) {
$CHANGE_TXT=$CHANGE_TXT."<li>ERROR: invalid image filename, refused</li>";
$change=1;
} else {
$file2delete = "/var/www/cover/" . $rawName;
exec("sudo rm " . escapeshellarg($file2delete));
$change=1;
$CHANGE_TXT=$CHANGE_TXT."<li>Image " . htmlspecialchars($file2delete) . " deleted!</li>";
}
}
if( $_POST['submitfile'] )
{
$target_dir = "/var/www/cover/";
$target_file = $target_dir . basename($_FILES["fileToUpload"]["name"]);
// M10: same filename whitelist as the deleteimage branch. basename()
// alone strips path components but happily passes "<", quotes, "&",
// spaces and unicode lookalikes — those land in the file listing
// below and (without htmlspecialchars there) inject into the <img>
// tag rendered for every admin who loads cover.php afterwards.
// Reject anything that isn't filename-safe ASCII before we even
// look at the bytes.
$rawName = basename($_FILES["fileToUpload"]["name"] ?? '');
$uploadOk = 1;
//$FileType = strtolower(pathinfo($target_file,PATHINFO_EXTENSION));
if (!preg_match('/^[A-Za-z0-9._-]+\.(jpe?g|png|gif|webp)$/i', $rawName)) {
$CHANGE_TXT=$CHANGE_TXT."<li>ERROR: invalid image filename. Use letters, digits, dot, underscore, dash only, with .jpg/.jpeg/.png/.gif/.webp extension.</li>";
$uploadOk = 0;
}
$target_file = $target_dir . $rawName;

if (is_file($target_file)) {
if ($uploadOk && is_file($target_file)) {
$CHANGE_TXT=$CHANGE_TXT."<li>There is already an image with this name! This file will be overwritten!</li>";
}

Expand Down Expand Up @@ -93,13 +115,21 @@

$files = glob('/var/www/cover/*.{jpeg,jpg,png,gif,webp}', GLOB_BRACE);
print "<div style='margin:30px;'>";
// M10 defence-in-depth: escape every basename and the host into HTML
// attribute context. The upload branch now rejects unsafe names, but
// older files from before this patch may still live on disk — escape
// at render time so legacy data can't break out of the attributes.
$hostHtml = htmlspecialchars($data["mupibox"]["host"] ?? '', ENT_QUOTES);
foreach($files as $file) {
$name = basename($file);
$nameHtml = htmlspecialchars($name, ENT_QUOTES);
$nameUrl = rawurlencode($name);
print "<div style='float: left;margin-right:15px;margin-top:10px;margin-bottom:15px;' align='center'>";
print "<form method=\"post\" action=\"cover.php\" id=\"form\" enctype=\"multipart/form-data\">";
print "<img src='/cover/".basename($file)."' style='max-width:280px;'>";
print "<img src='/cover/".$nameUrl."' style='max-width:280px;'>";
print "<br>";
print "<p>URL: <a href='http://".$data["mupibox"]["host"]."/cover/".basename($file)."' target='_blank'>http://".$data["mupibox"]["host"]."/cover/".basename($file)."</a>";
print "<input type=\"hidden\" name=\"image\" value=\"" . basename($file) . "\">";
print "<p>URL: <a href='http://".$hostHtml."/cover/".$nameUrl."' target='_blank'>http://".$hostHtml."/cover/".$nameHtml."</a>";
print "<input type=\"hidden\" name=\"image\" value=\"".$nameHtml."\">";
print "<br><input type=\"submit\" class=\"button_text\" value=\"Delete Image\" name=\"deleteimage\" ></p>";
print "</form></div>";
}
Expand Down
6 changes: 6 additions & 0 deletions AdminInterface/www/debug.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
<?php
require __DIR__ . '/includes/auth_check.php';

// chrome_debug.log can contain Spotify OAuth redirect URLs (with the
// `code` parameter), Authorization headers, and console output from the
// frontend. auth_check.php gates without emitting HTML so the file
// download below still streams cleanly.
$command = "sudo cat /home/dietpi/.config/chromium/chrome_debug.log";
exec($command, $output, $result );
//Define header information
Expand Down
7 changes: 6 additions & 1 deletion AdminInterface/www/fullbackup.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<?php
$command = "sudo rm /var/www/full_backup.zip; sudo zip -r /var/www/full_backup.zip /home/dietpi/MuPiBox/media/* /etc/mupibox/mupiboxconfig.json /home/dietpi/.mupibox/Sonos-Kids-Controller-master/server/config/data.json;sudo chmod 777 /var/www/full_backup.zip; sudo chown www-data:www-data /var/www/full_backup.zip";
require __DIR__ . '/includes/auth_check.php';

// Full backup contains every credential in mupiboxconfig.json plus the
// entire media tree. auth_check.php (header-only gate, no HTML output)
// keeps the binary download clean.
$command = "sudo rm /var/www/full_backup.zip; sudo zip -r /var/www/full_backup.zip /home/dietpi/MuPiBox/media/* /etc/mupibox/mupiboxconfig.json /home/dietpi/.mupibox/Sonos-Kids-Controller-master/server/config/data.json;sudo chmod 600 /var/www/full_backup.zip; sudo chown www-data:www-data /var/www/full_backup.zip";
exec($command );

//Define header information
Expand Down
Loading