This commit is contained in:
2026-07-19 18:27:27 +08:00
parent fe4b286ce1
commit 0febcf69f4
170 changed files with 19490 additions and 11878 deletions
-427
View File
@@ -1,427 +0,0 @@
<?php
/**
* Single source of truth for site-wide warning banners and the
* default-password forced-redirect.
*
* Two public entry points:
*
* pistar_warnings_enforce_redirect()
* Call ONCE near the top of every authenticated page, BEFORE
* any output. Issues a 302 + exit if the operator is on the
* default password and connecting from a remote subnet. Wired
* to /admin/* URLs only — the public root `/` is unauthenticated
* and we deliberately do NOT advertise default-password state
* to anonymous visitors.
*
* pistar_warnings_render()
* Call from the body to emit any/all of the seven warning
* banners that currently apply. Renders nothing on the public
* root (warnings target the operator, not the public).
*
* Banners managed here (consolidated from index.php and configure.php):
* 1. Pi-Star outdated (<4.1 on RPi) yellow
* 2. Upgrade available 4.1.0..<4.1.13 yellow
* 3. Upgrade available 4.2.0..<4.2.6 yellow
* 4. Upgrade available 4.3.0..<4.3.7 yellow
* 5. DMR public mode without ACL (loop risk) yellow
* 6. BM API v1 key still in use red
* 7. Default basic-auth password ('raspberry') red
*
* Layer 2 (forced redirect) precedence — most → least specific:
* - Setup-not-done flow in index.php takes priority. Redirect is
* suppressed when neither /etc/dstar-radio.{mmdvmhost,dstarrepeater}
* marker exists, so the existing "No Mode Defined" 10-second
* redirect to configure.php remains the dominant path.
* - Default password + remote client → 302 to /admin/change_password_required.php.
* - Default password + local client → red banner only (no redirect).
* - Custom password → no warning, no redirect.
*
* Helpers (private; underscore prefix):
* _pistar_banner_emit() — render one banner row
* _pistar_get_basic_auth() — extract user/password from request
* _pistar_default_password_in_use() — true iff pi-star user using 'raspberry'
* _pistar_local_subnets() — Pi's interface CIDRs (IPv4 + IPv6)
* _pistar_ip_in_subnet() — bitmask compare, family-aware
* _pistar_remote_is_local() — REMOTE_ADDR ∈ any local subnet
*/
/**
* Render a single banner row.
*
* Matches the inline-styled <div><table> shape used throughout the
* dashboard (no CSS class — same convention as the existing banners
* in index.php / configure.php this file replaces).
*
* @param string $html Banner body. INTERPOLATED RAW — callers must
* ensure any dynamic content is already escaped
* (none of the current banners include dynamic
* content, so this is a non-issue today; flagged
* for any future caller).
* @param string $level 'warn' (yellow) or 'danger' (red).
*
* @return void
*/
function _pistar_banner_emit($html, $level = 'warn')
{
$style = ($level === 'danger')
? 'background-color: #ff9090; color: #f01010;'
: 'background-color: #ffff90; color: #906000;';
echo '<div>' . "\n";
echo ' <table align="center" width="760px" style="margin: 0px 0px 10px 0px; width: 100%;">' . "\n";
echo ' <tr>' . "\n";
echo ' <td align="center" valign="top" style="' . $style . '">' . $html . '</td>' . "\n";
echo ' </tr>' . "\n";
echo ' </table>' . "\n";
echo '</div>' . "\n";
}
/**
* Extract the HTTP Basic auth user + password from the current request.
*
* On Pi-Star's stock nginx + PHP-FPM config, $_SERVER['PHP_AUTH_PW']
* is populated automatically (PHP synthesises it from the forwarded
* Authorization header). The HTTP_AUTHORIZATION fallback handles
* unusual setups where only the raw header survives.
*
* Note: the REMOTE_USER fallback only sets $user — that var contains
* the authenticated username but never the password. Callers that need
* the password (default-password check, etc.) treat empty $pw as
* "unable to verify" and fail closed.
*
* @return array{0: string, 1: string} [$user, $password]; either or
* both may be '' when basic auth
* hasn't happened (e.g. on the
* unauthenticated public root).
*/
function _pistar_get_basic_auth()
{
$user = isset($_SERVER['PHP_AUTH_USER']) ? (string)$_SERVER['PHP_AUTH_USER'] : '';
$pw = isset($_SERVER['PHP_AUTH_PW']) ? (string)$_SERVER['PHP_AUTH_PW'] : '';
if ($user !== '' && $pw !== '') {
return array($user, $pw);
}
if (!empty($_SERVER['HTTP_AUTHORIZATION'])
&& stripos($_SERVER['HTTP_AUTHORIZATION'], 'Basic ') === 0) {
$decoded = base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6), true);
if ($decoded !== false && strpos($decoded, ':') !== false) {
list($user, $pw) = explode(':', $decoded, 2);
return array($user, $pw);
}
}
if ($user === '' && !empty($_SERVER['REMOTE_USER'])) {
$user = (string)$_SERVER['REMOTE_USER'];
}
return array($user, $pw);
}
/**
* True iff the operator is the canonical 'pi-star' account AND
* still using the factory default password 'raspberry'.
*
* Account scope is deliberately narrow: the requirement is to
* protect against drive-by auth using the well-known stock
* credentials. Operators who created their own usernames are
* out of scope (they made an active choice).
*
* Constant-time compare via hash_equals — defence in depth; the
* default password is publicly documented so timing isn't really
* an oracle, but it's the right idiom for password compares.
*
* @return bool
*/
function _pistar_default_password_in_use()
{
list($user, $pw) = _pistar_get_basic_auth();
if ($user !== 'pi-star' || $pw === '') {
return false;
}
return hash_equals('raspberry', $pw);
}
/**
* The Pi's local interface subnets (IPv4 + IPv6).
*
* Read once per request from `ip -j addr show` (iproute2 JSON output;
* always present on Pi-Star). Cached in a static for repeated calls.
*
* @return array<int, array{ip:string, prefix:int}>
*/
function _pistar_local_subnets()
{
static $cached = null;
if ($cached !== null) {
return $cached;
}
$cached = array();
$out = array();
$rc = 1;
exec('ip -j addr show 2>/dev/null', $out, $rc);
if ($rc !== 0 || empty($out)) {
return $cached;
}
$ifaces = json_decode(implode('', $out), true);
if (!is_array($ifaces)) {
return $cached;
}
foreach ($ifaces as $iface) {
if (empty($iface['addr_info']) || !is_array($iface['addr_info'])) {
continue;
}
foreach ($iface['addr_info'] as $a) {
if (empty($a['local']) || !isset($a['prefixlen'])) {
continue;
}
$cached[] = array(
'ip' => (string)$a['local'],
'prefix' => (int)$a['prefixlen'],
);
}
}
return $cached;
}
/**
* Family-aware bitmask subnet membership test.
*
* Handles IPv4, IPv6, and the IPv4-mapped IPv6 form (`::ffff:a.b.c.d`)
* that nginx may surface in REMOTE_ADDR when the listener is dual-stack.
*
* @param string $remote The address to test.
* @param string $netIp The subnet's network/anchor address.
* @param int $prefix Prefix length in bits (0..32 for IPv4, 0..128 for IPv6).
*
* @return bool
*/
function _pistar_ip_in_subnet($remote, $netIp, $prefix)
{
$remoteBin = @inet_pton($remote);
$netBin = @inet_pton($netIp);
if ($remoteBin === false || $netBin === false) {
return false;
}
// Handle v4-mapped v6 (::ffff:1.2.3.4) when comparing against a
// bare v4 subnet — strip the 12-byte v4-mapped prefix. Mapping is
// one-directional by design: nginx surfaces REMOTE_ADDR in mapped
// form on dual-stack listeners while `ip -j addr show` reports the
// interface as bare v4, so the asymmetric strip is what the real
// topology produces. The reverse case (bare v4 client, mapped
// subnet) cannot occur in practice and falls through to "not a
// match" below.
if (strlen($remoteBin) === 16 && strlen($netBin) === 4
&& substr($remoteBin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") {
$remoteBin = substr($remoteBin, 12);
}
if (strlen($remoteBin) !== strlen($netBin)) {
// Different families and no compatible mapping — not in subnet.
return false;
}
$bits = strlen($remoteBin) * 8;
if ($prefix < 0 || $prefix > $bits) {
return false;
}
$bytesFull = intdiv($prefix, 8);
$bitsExtra = $prefix % 8;
if ($bytesFull > 0
&& substr($remoteBin, 0, $bytesFull) !== substr($netBin, 0, $bytesFull)) {
return false;
}
if ($bitsExtra > 0) {
$mask = chr((0xFF << (8 - $bitsExtra)) & 0xFF);
if ((substr($remoteBin, $bytesFull, 1) & $mask)
!== (substr($netBin, $bytesFull, 1) & $mask)) {
return false;
}
}
return true;
}
/**
* Is REMOTE_ADDR in the same L3 subnet as ANY of the Pi's interfaces?
*
* Loopback (127.0.0.0/8 on v4, ::1/128 on v6) is treated as local —
* those addresses appear when an admin page is hit via the device
* itself or via a CLI test harness on the Pi.
*
* @return bool
*/
function _pistar_remote_is_local()
{
$remote = isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : '';
if ($remote === '') {
return false;
}
foreach (_pistar_local_subnets() as $sub) {
if (_pistar_ip_in_subnet($remote, $sub['ip'], $sub['prefix'])) {
return true;
}
}
return false;
}
/**
* Layer 2: redirect to the forced-change page when default password +
* remote client. Must be called BEFORE any output so header() works.
*
* Skips:
* - Public (non-/admin/) URLs — anonymous visitors don't get told
* the device is on default credentials.
* - The /admin/change_password_required.php page itself — would
* loop.
* - Pages reached during the setup-not-done flow (no mode marker
* file yet) — let the existing "No Mode Defined" handler in
* index.php drive the operator through configure.php first.
*
* @return void
*/
function pistar_warnings_enforce_redirect()
{
$self = isset($_SERVER['PHP_SELF']) ? (string)$_SERVER['PHP_SELF'] : '';
if (strpos($self, '/admin/') !== 0) {
return;
}
if ($self === '/admin/change_password_required.php') {
return;
}
// Long-running operational tools — let the operator finish what
// they're doing. The Layer-1 banner still appears at the top of
// these pages so the warning isn't suppressed; only the redirect
// is. update.php and expert/upgrade.php both poll a `?ajax`
// log-tail every 1 s; redirecting on those polls causes jQuery
// to follow the 302 and append the full change-password HTML
// into the page's #tail div, "flooding" the operator with copies
// of the change-password form.
if ($self === '/admin/update.php'
|| $self === '/admin/expert/upgrade.php') {
return;
}
// Defence in depth: any GET that includes ?ajax skips the redirect.
// Catches AJAX log-tail / status-poll endpoints elsewhere in the
// dashboard (live_modem_log.php, expert/jitter_test.php, etc.) so
// a future poll-style page can't accidentally regress the same way.
// The initial GET that loaded the parent page already carried the
// banner — defaulting AJAX to "no redirect" is safe.
if (isset($_GET['ajax'])) {
return;
}
if (!file_exists('/etc/dstar-radio.mmdvmhost')
&& !file_exists('/etc/dstar-radio.dstarrepeater')) {
return;
}
if (!_pistar_default_password_in_use()) {
return;
}
if (_pistar_remote_is_local()) {
return;
}
header('Location: /admin/change_password_required.php', true, 302);
exit;
}
/**
* Render every applicable warning banner. Admin URLs only.
*
* Adding a new banner here? The body string is interpolated into the
* <td> raw — pre-escape any dynamic content with htmlspecialchars()
* before passing it to _pistar_banner_emit(). Today's seven banners
* are all string literals so the issue does not arise.
*
* @return void
*/
function pistar_warnings_render()
{
$self = isset($_SERVER['PHP_SELF']) ? (string)$_SERVER['PHP_SELF'] : '';
if (strpos($self, '/admin/') !== 0) {
return;
}
// Default-password banner FIRST — highest severity, most actionable.
if (_pistar_default_password_in_use()) {
_pistar_banner_emit(
'<b>Security Alert:</b> Your dashboard is using the default password. '
. 'Please <a href="/admin/configure.php" style="color: #f01010;"><b>change it now</b></a> '
. 'from the Configuration page.',
'danger'
);
}
// Pi-Star release-derived banners. parse_ini_file() once per request.
$release = @parse_ini_file('/etc/pistar-release', true);
$version = is_array($release) && isset($release['Pi-Star']['Version'])
? (string)$release['Pi-Star']['Version']
: '';
$hardware = is_array($release) && isset($release['Pi-Star']['Hardware'])
? (string)$release['Pi-Star']['Hardware']
: '';
if ($version !== '' && $hardware === 'RPi'
&& version_compare($version, '4.1', '<')) {
_pistar_banner_emit(
'Alert: You are running an outdated version of Pi-Star, please upgrade.<br />'
. 'New versions are available from here: '
. '<a href="http://www.pistar.uk/downloads/" alt="Pi-Star Downloads">http://www.pistar.uk/downloads/</a>.',
'warn'
);
}
if ($version !== ''
&& version_compare($version, '4.1.0', '>=')
&& version_compare($version, '4.1.13', '<')) {
_pistar_banner_emit(
'Alert: An upgrade to Pi-Star has been released, click here to upgrade now: '
. '<a href="/admin/expert/upgrade.php" alt="Upgrade Pi-Star">Upgrade Pi-Star</a>.',
'warn'
);
}
if ($version !== ''
&& version_compare($version, '4.2.0', '>=')
&& version_compare($version, '4.2.6', '<')) {
_pistar_banner_emit(
'Alert: An upgrade to Pi-Star has been released, click here to upgrade now: '
. '<a href="/admin/expert/upgrade.php" alt="Upgrade Pi-Star">Upgrade Pi-Star</a>.',
'warn'
);
}
if ($version !== ''
&& version_compare($version, '4.3.0', '>=')
&& version_compare($version, '4.3.7', '<')) {
_pistar_banner_emit(
'Alert: An upgrade to Pi-Star has been released, click here to upgrade now: '
. '<a href="/admin/expert/upgrade.php" alt="Upgrade Pi-Star">Upgrade Pi-Star</a>.',
'warn'
);
}
// DMR public mode without ACL (loop risk). Was configure.php-only;
// now appears across the admin surface so the operator sees it
// regardless of which page they land on first.
if (file_exists('/etc/dstar-radio.mmdvmhost')) {
$mmdvm = @parse_ini_file('/etc/mmdvmhost', true);
if (is_array($mmdvm)
&& isset($mmdvm['DMR']['Enable']) && $mmdvm['DMR']['Enable'] == 1
&& isset($mmdvm['DMR']['SelfOnly']) && $mmdvm['DMR']['SelfOnly'] == 0
&& isset($mmdvm['General']['Id'])
&& strlen((string)$mmdvm['General']['Id']) >= 7
&& !isset($mmdvm['DMR']['WhiteList'])) {
_pistar_banner_emit(
'Alert: You are running a hotspot in public mode without an access list for DMR, '
. 'this setup *could* participate in network loops!',
'warn'
);
}
}
// BM API v1 key (length-based heuristic, kept identical to the
// configure.php trigger this consolidates).
$bmKey = '/etc/bmapi.key';
if (file_exists($bmKey)) {
$bm = @parse_ini_file($bmKey, true);
if (is_array($bm) && !empty($bm['key']['apikey'])
&& strlen((string)$bm['key']['apikey']) <= 200) {
_pistar_banner_emit(
'Alert: You have a BM API v1 Key, click here for the announcement: '
. '<a href="https://news.brandmeister.network/introducing-user-api-keys/" alt="BM API Keys">BM API Keys - Announcement</a>.',
'danger'
);
}
}
}
+11 -31
View File
@@ -1,35 +1,15 @@
<?php
/**
* Mobile-vs-desktop CSS picker.
*
* Inspects the User-Agent string and emits two `<link rel="stylesheet">`
* tags wired up with media queries: `pistar-css.php` for desktop widths,
* `pistar-css-mini.php` for narrow / mobile widths.
*
* The big regex in this file is the standard "detectmobilebrowsers"
* UA list (https://detectmobilebrowsers.com/) and is intentionally not
* formatted — it's a single literal that must stay byte-identical to
* remain a valid match for the documented agent list.
*
* Included from the page <head> by index.php / admin/index.php and
* the configure / power / update pages.
*/
if (empty($_SERVER['HTTP_USER_AGENT'])) {
// No UA at all (curl, some CLIs, oddball clients): default to the
// desktop-vs-mobile width-based pair so behaviour matches a normal
// browser hitting the page.
if(empty($_SERVER['HTTP_USER_AGENT'])) {
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (min-width: 830px)\" href=\"css/pistar-css.php?version=0.95\" />\n";
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (max-width: 829px)\" href=\"css/pistar-css-mini.php?version=0.95\" />\n";
} else {
$useragent=$_SERVER['HTTP_USER_AGENT'];
if (preg_match('/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i',$useragent)||preg_match('/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i',substr($useragent,0,4))) {
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (min-device-width: 380px)\" href=\"css/pistar-css.php?version=0.95\" />\n";
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (max-device-width: 379px)\" href=\"css/pistar-css-mini.php?version=0.95\" />\n";
} else {
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (min-width: 830px)\" href=\"css/pistar-css.php?version=0.95\" />\n";
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (max-width: 829px)\" href=\"css/pistar-css-mini.php?version=0.95\" />\n";
} else {
$useragent = $_SERVER['HTTP_USER_AGENT'];
if (preg_match('/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i',$useragent)||preg_match('/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i',substr($useragent,0,4))) {
// Mobile UA detected — bias toward the narrow-screen stylesheet.
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (min-device-width: 380px)\" href=\"css/pistar-css.php?version=0.95\" />\n";
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (max-device-width: 379px)\" href=\"css/pistar-css-mini.php?version=0.95\" />\n";
} else {
// Desktop UA — width-based pair (same as the no-UA branch).
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (min-width: 830px)\" href=\"css/pistar-css.php?version=0.95\" />\n";
print " <link rel=\"stylesheet\" type=\"text/css\" media=\"screen and (max-width: 829px)\" href=\"css/pistar-css-mini.php?version=0.95\" />\n";
}
}
}
?>
+24 -49
View File
@@ -1,52 +1,27 @@
<?php
/**
* Pi-Star Dashboard runtime constants.
*
* Auto-generated — be careful editing by hand; configure.php may
* regenerate parts of this file when the operator changes settings.
*
* Defines paths to MMDVMHost / YSF / P25 gateway logs and config files.
* Consumed primarily by mmdvmhost/functions.php for the data-extraction
* layer (last-heard parsing, link state lookup, mode detection).
*
* The trailing `REBOOT*` / `HALTSYS` / `TEMPERATUREHIGHLEVEL` defines
* are placeholders kept blank by default; some legacy paths inspect
* them via `defined()`/`!empty()`.
*/
# This is an auto-generated config-file!
# Be careful, when manual editing this!
// All log timestamps in this dashboard are interpreted as UTC.
date_default_timezone_set('UTC');
// MMDVMHost — modem driver: log path, log filename prefix, and the
// /etc filename it reads its config from.
define('MMDVMLOGPATH', '/var/log/pi-star');
define('MMDVMLOGPREFIX', 'MMDVM');
define('MMDVMINIPATH', '/etc');
define('MMDVMINIFILENAME', 'mmdvmhost');
define('MMDVMHOSTPATH', '/usr/local/bin');
define('DMRIDDATPATH', '/usr/local/etc');
// YSFGateway (Yaesu System Fusion bridge) — same shape as MMDVM.
define('YSFGATEWAYLOGPATH', '/var/log/pi-star');
define('YSFGATEWAYLOGPREFIX', 'YSFGateway');
define('YSFGATEWAYINIPATH', '/etc');
define('YSFGATEWAYINIFILENAME', 'ysfgateway');
// P25Gateway — same shape.
define('P25GATEWAYLOGPATH', '/var/log/pi-star');
define('P25GATEWAYLOGPREFIX', 'P25Gateway');
define('P25GATEWAYINIPATH', '/etc');
define('P25GATEWAYINIFILENAME', 'p25gateway');
// ircDDBGateway — D-Star side: shared `Links.log` lives here.
define('LINKLOGPATH', '/var/log/pi-star');
define('IRCDDBGATEWAY', 'ircddbgatewayd');
// Auto-refresh hint (seconds) used by some legacy partials.
define('REFRESHAFTER', '30');
// Reserved for future use / legacy hooks. Left blank intentionally.
define('TEMPERATUREHIGHLEVEL', '');
define('REBOOTMMDVM', '');
define('REBOOTSYS', '');
define('HALTSYS', '');
define("MMDVMLOGPATH", "/var/log/pi-star");
define("MMDVMLOGPREFIX", "MMDVM");
define("MMDVMINIPATH", "/etc");
define("MMDVMINIFILENAME", "mmdvmhost");
define("MMDVMHOSTPATH", "/usr/local/bin");
define("DMRIDDATPATH", "/usr/local/etc");
define("YSFGATEWAYLOGPATH", "/var/log/pi-star");
define("YSFGATEWAYLOGPREFIX", "YSFGateway");
define("YSFGATEWAYINIPATH", "/etc");
define("YSFGATEWAYINIFILENAME", "ysfgateway");
define("P25GATEWAYLOGPATH", "/var/log/pi-star");
define("P25GATEWAYLOGPREFIX", "P25Gateway");
define("P25GATEWAYINIPATH", "/etc");
define("P25GATEWAYINIFILENAME", "p25gateway");
define("LINKLOGPATH", "/var/log/pi-star");
define("IRCDDBGATEWAY", "ircddbgatewayd");
define("REFRESHAFTER", "30");
define("TEMPERATUREHIGHLEVEL", "");
define("REBOOTMMDVM", "");
define("REBOOTSYS", "");
define("HALTSYS", "");
?>
-570
View File
@@ -1,570 +0,0 @@
<?php
/**
* Pi-Star configuration writer — safe replacement for the long-standing
* `sudo sed -i "/key=/c\\key=$value"` pattern that's repeated ~150 times
* across admin/configure.php.
*
* The previous approach concatenated `escapeshellcmd($_POST['field'])`
* into a double-quoted shell command and a sed `c\` replacement.
* `escapeshellcmd()` does not protect double-quoted shell contexts, so
* each call site was an independent command-injection sink. This helper
* replaces the family of sites with a single staged-write API: edits
* accumulate in memory, commit() writes them all at once via PHP-side
* file editing plus an atomic `sudo install`, and no shell ever sees
* an attacker-controlled byte.
*
* Usage from a POST handler:
*
* require_once $_SERVER['DOCUMENT_ROOT'] . '/config/config_writer.php';
*
* // Stage individual edits — accumulate in memory, no I/O yet.
* config_writer_stage_flat('/etc/ircddbgateway', 'aprsHostname', $aprsHost);
* config_writer_stage_flat('/etc/ircddbgateway', 'remotePassword', $confPass);
* config_writer_stage_flat('/etc/timeserver', 'callsign', $newCall);
* // ... many more ...
*
* // Commit all staged edits — one remount-rw / batched-install / remount-ro cycle.
* $errors = config_writer_commit();
* if (!empty($errors)) {
* // Each entry is a human-readable diagnostic; surface or log as you prefer.
* }
*
* Design choices:
*
* - **Allowlist of writable paths.** Any caller asking to write outside
* the allowlist gets `false` back from `config_writer_stage_flat()`
* (and an `error_log()` entry). The list is the set of flat
* key=value files the dashboard's editor surfaces actually edit.
*
* - **Stage-then-commit, single mount cycle.** The dashboard runs on
* a read-only rootfs by default. A naive per-edit `remount,rw` /
* write / `remount,ro` cycle would race when many edits land in one
* POST (and configure.php has 150+ such edits per save). Staging
* all edits in `$GLOBALS['__config_writer_stage']` and flushing in a
* single `commit()` call collapses that to one mount cycle per POST.
*
* - **Match sed's `c\` semantics.** Replace the FIRST line whose head
* is `<key>=` (column 0 — stricter than sed's "anywhere on line"
* pattern, which is actually the correct intent and avoids the
* classic substring-collision class of bug). If the key is absent,
* do nothing — same as sed. Append-if-missing would be a behaviour
* change for daemons with strict parsers and isn't safe without a
* per-file audit; defer to a later opt-in flag if needed.
*
* - **Atomic file replacement via `sudo install -m 644 -o root -g root`.**
* `cp` is not atomic — open + write + close on the destination
* leaves a window where the file is partially written. `install`
* uses rename(2) when src and dst share a filesystem; falls back to
* copy+chmod+chown when they don't (which is our case here — `/tmp`
* is tmpfs, the destination is on the SD card). The fallback is no
* less atomic than the existing `cp` pattern used elsewhere in this
* dashboard, and it sets mode/owner in one go. A future hardening
* pass could co-locate the staging file with the destination for
* true rename(2) atomicity.
*
* - **Hardcoded shell command literals.** `system()` is called with a
* fixed-shape command string; only fixed paths and `escapeshellarg()`-
* wrapped arguments are interpolated. PHP's `proc_open()` array form
* is PHP 7.4+ and this codebase targets PHP 7.0+, so we stay with
* `system()` plus `escapeshellarg()` instead.
*
* - **Whitespace preservation.** Existing files use `key=value` with
* no spaces around `=`. The helper preserves that exactly — it does
* NOT normalise existing files' formatting. Daemons in this stack
* parse their own configs with byte-precise matching; introducing
* spurious whitespace changes would risk breakage out of scope for
* this fix.
*
* - **Concurrent-edits.** Pi-Star is a single-operator embedded device
* in practice. The read-mutate-write window in commit() is wider
* than sed's, but two simultaneous configure.php POSTs are vanishingly
* rare on the target hardware. TODO: add file locking via flock()
* if this assumption ever stops holding.
*/
/**
* Allow-list of paths the helper is permitted to write via the
* flat key=value editor. Any other path passed to
* `config_writer_stage_flat()` is rejected with an error_log()
* entry and a `false` return.
*
* Defined as a function (not a constant) so the file is safe to
* `require_once` multiple times under PHP 7.0 — array constants from
* `define()` work in 7.0 but `const` arrays at the file scope are
* 5.6+ and uniform-array-syntax limits make redefinition awkward.
*
* @return array<int,string>
*/
function config_writer_allowed_paths()
{
return array(
'/etc/ircddbgateway',
'/etc/dstarrepeater',
'/etc/timeserver',
'/etc/aprsgateway',
'/etc/mobilegps',
'/etc/starnetserver',
);
}
/**
* Allow-list of paths the helper is permitted to write via the
* privileged-flat editor (`config_writer_stage_privileged_flat()`).
*
* Same column-0 `key=value` semantics as the unprivileged flat
* editor, but the read step uses `sudo cat` so the helper can
* service files that are mode-600 root:root and therefore not
* readable by www-data. The destination is restored at mode 600
* root:root via `sudo install`. Kept separate from the flat
* allowlist because the read path and the install mode differ.
*
* @return array<int,string>
*/
function config_writer_allowed_paths_privileged_flat()
{
return array(
'/root/.Remote Control', // note: literal space in filename
'/etc/hostapd/hostapd.conf', // contains wpa_passphrase=… ; mode 600 root:root
);
}
/**
* Allow-list of paths the helper is permitted to write via the
* PHP-statement editor (`config_writer_stage_php_string()`).
*
* These files are PHP source files included by the dashboard at
* runtime. Each contains one or more lines of the form
* `$varName='value';` at column 0. The PHP-statement editor
* rewrites the value with proper PHP-string escaping so the
* attacker-controlled bytes can never escape the single-quoted
* string literal — preventing PHP RCE via these files.
*
* Kept separate from the flat allow-list because the file shape
* and the editing semantics are different. A path that's writable
* under one editor is NOT automatically writable under the other.
*
* @return array<int,string>
*/
function config_writer_allowed_paths_php_string()
{
return array(
'/var/www/dashboard/config/language.php',
'/var/www/dashboard/config/ircddblocal.php',
);
}
/**
* Stage a single `key=value` edit against a flat config file.
*
* The edit is queued in process-local memory. Nothing touches disk
* until {@see config_writer_commit()} runs. Multiple stages against the
* same file accumulate; multiple stages of the same key in the same
* file overwrite each other (last write wins) and only the last is
* applied at commit time.
*
* @param string $path Absolute path. Must appear in
* {@see config_writer_allowed_paths()}.
* @param string $key The key name, e.g. `aprsHostname`. Must match
* `[A-Za-z_][A-Za-z0-9_]*` — defence in depth so
* a programming-error caller can't inject a
* regex/sed metachar via the key.
* @param string $value The new value. Must not contain NUL / CR / LF
* (those would break the line-oriented file
* format). All other bytes — including shell
* metachars like `"` `'` `;` `&` `$` — are stored
* verbatim, which is correct: the value is data,
* not a shell argument.
*
* @return bool True if staged. False if rejected (path not allow-
* listed, key malformed, or value contains NUL/CR/LF).
* On false, an error_log() entry is emitted.
*/
function config_writer_stage_flat($path, $key, $value)
{
if (!in_array($path, config_writer_allowed_paths(), true)) {
error_log("config_writer: refusing to stage edit against non-allowlisted path '$path'");
return false;
}
if (!preg_match('/\A[A-Za-z_][A-Za-z0-9_]*\z/', $key)) {
error_log("config_writer: refusing to stage malformed key '$key' for $path");
return false;
}
if (preg_match('/[\x00\r\n]/', $value)) {
error_log("config_writer: refusing to stage value with NUL/CR/LF for $path:$key");
return false;
}
if (!isset($GLOBALS['__config_writer_stage'])) {
$GLOBALS['__config_writer_stage'] = array();
}
if (!isset($GLOBALS['__config_writer_stage'][$path])) {
$GLOBALS['__config_writer_stage'][$path] = array();
}
// Last write wins for repeated stages of the same key.
$GLOBALS['__config_writer_stage'][$path][$key] = $value;
return true;
}
/**
* Stage a single PHP single-quoted string assignment edit.
*
* Targets PHP source files in
* {@see config_writer_allowed_paths_php_string()} that contain a
* line of the form `$varName='value';` at column 0. The first
* matching line is rewritten with the new value, properly escaped
* for a PHP single-quoted string literal — `\\` and `'` inside
* the value are escaped to `\\\\` and `\\'` respectively. All
* other bytes (including shell metachars `"` `;` `&` `$`) are
* stored verbatim — they are data inside a string literal, not
* code. This closes the PHP RCE class introduced by the previous
* `sudo sed -i` pattern, where attacker bytes could close the
* sed-emitted single-quote and inject arbitrary PHP statements.
*
* Like {@see config_writer_stage_flat()}, the edit is queued in
* memory until {@see config_writer_commit()} runs.
*
* @param string $path Absolute path. Must appear in
* {@see config_writer_allowed_paths_php_string()}.
* @param string $varName PHP variable name (no leading `$`).
* Must match `[A-Za-z_][A-Za-z0-9_]*`.
* @param string $value The new value to assign. Must not contain
* NUL/CR/LF (those would either break PHP
* parsing or break the line-oriented edit).
*
* @return bool True if staged. False if rejected.
*/
function config_writer_stage_php_string($path, $varName, $value)
{
if (!in_array($path, config_writer_allowed_paths_php_string(), true)) {
error_log("config_writer: refusing PHP-string stage for non-allowlisted path '$path'");
return false;
}
if (!preg_match('/\A[A-Za-z_][A-Za-z0-9_]*\z/', $varName)) {
error_log("config_writer: refusing malformed PHP var name '$varName' for $path");
return false;
}
if (preg_match('/[\x00\r\n]/', $value)) {
error_log("config_writer: refusing PHP-string value with NUL/CR/LF for $path:\$$varName");
return false;
}
if (!isset($GLOBALS['__config_writer_stage_phpstr'])) {
$GLOBALS['__config_writer_stage_phpstr'] = array();
}
if (!isset($GLOBALS['__config_writer_stage_phpstr'][$path])) {
$GLOBALS['__config_writer_stage_phpstr'][$path] = array();
}
$GLOBALS['__config_writer_stage_phpstr'][$path][$varName] = $value;
return true;
}
/**
* Stage a single `key=value` edit against a flat config file that
* lives under root-only permissions.
*
* Identical contract to {@see config_writer_stage_flat()} except the
* file is read via `sudo cat` instead of the PHP-side `file()` —
* because mode-600 root:root paths are not readable by www-data —
* and the destination is reinstalled at mode 600 root:root rather
* than 644.
*
* The only currently-allowlisted path is `/root/.Remote Control`,
* which holds the ircDDBGateway remote-control password and port.
*
* @param string $path Absolute path. Must appear in
* {@see config_writer_allowed_paths_privileged_flat()}.
* @param string $key Same key contract as the unprivileged flat
* editor.
* @param string $value Same value contract.
*
* @return bool True if staged. False if rejected.
*/
function config_writer_stage_privileged_flat($path, $key, $value)
{
if (!in_array($path, config_writer_allowed_paths_privileged_flat(), true)) {
error_log("config_writer: refusing privileged-flat stage for non-allowlisted path '$path'");
return false;
}
if (!preg_match('/\A[A-Za-z_][A-Za-z0-9_]*\z/', $key)) {
error_log("config_writer: refusing malformed privileged-flat key '$key' for $path");
return false;
}
if (preg_match('/[\x00\r\n]/', $value)) {
error_log("config_writer: refusing privileged-flat value with NUL/CR/LF for $path:$key");
return false;
}
if (!isset($GLOBALS['__config_writer_stage_priv'])) {
$GLOBALS['__config_writer_stage_priv'] = array();
}
if (!isset($GLOBALS['__config_writer_stage_priv'][$path])) {
$GLOBALS['__config_writer_stage_priv'][$path] = array();
}
$GLOBALS['__config_writer_stage_priv'][$path][$key] = $value;
return true;
}
/**
* Atomically install $newContent at $path with the given mode root:root.
*
* Internal helper shared by the flat and php-string commit paths.
* Returns null on success or a single-line diagnostic on failure.
* On failure the destination is left untouched and the temp file
* is unlinked.
*
* @param string $path Absolute destination path.
* @param string $newContent Full file content to install.
*
* @return string|null Error string, or null on success.
*/
function _config_writer_install_atomic($path, $newContent, $mode = '644')
{
// Defence in depth — $mode is never attacker-controlled (the
// helper's commit() picks one of two hardcoded values), but we
// refuse anything outside the small expected set so a future
// typo can't inadvertently widen file permissions.
if ($mode !== '644' && $mode !== '600') {
return "config_writer: refusing invalid mode '$mode' for $path";
}
$tmp = tempnam('/tmp', 'pistar_cw_');
if ($tmp === false) {
return "config_writer: tempnam() failed for $path";
}
// tempnam() creates the file mode 0600 on POSIX, but a non-default
// umask could in theory widen it. Force-narrow before writing —
// the temp content may be a freshly-set password.
@chmod($tmp, 0600);
if (file_put_contents($tmp, $newContent) === false) {
@unlink($tmp);
return "config_writer: file_put_contents() failed for $tmp; $path edits skipped";
}
$cmd = 'sudo install -m ' . $mode . ' -o root -g root '
. escapeshellarg($tmp) . ' '
. escapeshellarg($path);
$rc = 0;
$out = array();
exec($cmd . ' 2>&1', $out, $rc);
@unlink($tmp);
if ($rc !== 0) {
return "config_writer: install exit=$rc for $path: " . implode(' / ', $out);
}
return null;
}
/**
* Apply every staged edit and clear the staging buffer.
*
* For each affected file:
* 1. Read the file into a line array.
* 2. For each staged `key => value`, find the FIRST line whose head
* is `key=` (column 0) and replace it with `key=value`. If the
* key is absent, the edit is silently skipped (matches sed's
* `c\` semantics).
* 3. Write the rebuilt content to a tempnam() in /tmp.
* 4. Atomically copy back via `sudo install -m 644 -o root -g root`.
*
* Wraps the per-file install calls in a single mount-rw / mount-ro
* pair so concurrent POSTs can't race on remount toggles. Callers
* that already manage their own mount cycle (e.g. configure.php's
* top-level POST handler, which keeps `/` rw across many edits)
* should pass `$manageMount = false` to skip the helper's own
* mount-rw/ro — otherwise the helper's mount-ro will prematurely
* close the caller's write window.
*
* @param bool $manageMount Whether commit() should issue its own
* `sudo mount -o remount,rw /` and
* `... remount,ro /` around the batch.
* Default true — safe for one-off callers.
* Pass false from inside an already-managed
* mount window.
*
* @return array<int,string> Diagnostic strings — empty on full success.
* Non-empty entries describe per-file failures
* (read failure, file_put_contents failure,
* install non-zero exit). The caller decides
* whether to surface to the UI or just log.
*/
function config_writer_commit($manageMount = true)
{
$errors = array();
$flatStage = isset($GLOBALS['__config_writer_stage'])
? $GLOBALS['__config_writer_stage']
: array();
$phpStrStage = isset($GLOBALS['__config_writer_stage_phpstr'])
? $GLOBALS['__config_writer_stage_phpstr']
: array();
$privStage = isset($GLOBALS['__config_writer_stage_priv'])
? $GLOBALS['__config_writer_stage_priv']
: array();
if (empty($flatStage) && empty($phpStrStage) && empty($privStage)) {
return $errors;
}
// Optional single mount-rw / batched-install / mount-ro envelope.
// When the caller already has `/` open rw (configure.php's POST
// handler does this for the duration of a save), we MUST NOT do
// our own mount-ro at the end — that would prematurely close the
// caller's window and break later writes (e.g. the timezone
// handler that runs after this commit).
if ($manageMount) {
system('sudo mount -o remount,rw /');
}
// Pass 1 — flat key=value edits.
foreach ($flatStage as $path => $kvPairs) {
if (!is_readable($path)) {
$errors[] = "config_writer: cannot read $path; edits skipped";
error_log("config_writer: cannot read $path; " . count($kvPairs) . " edits skipped");
continue;
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
$errors[] = "config_writer: file() failed for $path; edits skipped";
error_log("config_writer: file() failed for $path; " . count($kvPairs) . " edits skipped");
continue;
}
foreach ($kvPairs as $key => $value) {
$prefix = $key . '=';
$applied = false;
foreach ($lines as $i => $line) {
if (strpos($line, $prefix) === 0) {
$lines[$i] = $prefix . $value;
$applied = true;
break;
}
}
// Silent no-op when the key isn't present — matches the
// previous sed `c\` semantics. If the caller cares, log
// here. (Quiet by default to avoid filling the log on
// version-skew between the dashboard and the gateway
// configs.)
if (!$applied) {
error_log("config_writer: $path has no '$key=' line; edit skipped");
}
}
$err = _config_writer_install_atomic(
$path,
implode("\n", $lines) . "\n"
);
if ($err !== null) {
$errors[] = $err;
error_log($err);
}
}
// Pass 2 — PHP single-quoted string assignment edits.
foreach ($phpStrStage as $path => $varValuePairs) {
if (!is_readable($path)) {
$errors[] = "config_writer: cannot read $path; PHP-string edits skipped";
error_log("config_writer: cannot read $path; "
. count($varValuePairs) . " PHP-string edits skipped");
continue;
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
$errors[] = "config_writer: file() failed for $path; PHP-string edits skipped";
error_log("config_writer: file() failed for $path; "
. count($varValuePairs) . " PHP-string edits skipped");
continue;
}
foreach ($varValuePairs as $varName => $value) {
// Match column-0 `$varName` followed by optional whitespace
// then `=`. preg_quote is belt-and-braces — the var name is
// already validated to /^[A-Za-z_][A-Za-z0-9_]*$/ by
// config_writer_stage_php_string().
$pattern = '/^\$' . preg_quote($varName, '/') . '\s*=/';
$applied = false;
foreach ($lines as $i => $line) {
if (preg_match($pattern, $line)) {
// Escape value for a PHP single-quoted string
// literal: only `\` and `'` are special inside
// `'...'`. The two-char mask "\\'" tells
// addcslashes() to backslash-escape both:
// `\` → `\\` (first char of the mask)
// `'` → `\'` (second char of the mask)
// The result is parseable PHP that decodes back
// to the original $value bytes — so attacker
// bytes are stored as data, never executed.
$escaped = addcslashes($value, "\\'");
$lines[$i] = '$' . $varName . "='" . $escaped . "';";
$applied = true;
break;
}
}
if (!$applied) {
error_log("config_writer: $path has no \$$varName= line; PHP-string edit skipped");
}
}
$err = _config_writer_install_atomic(
$path,
implode("\n", $lines) . "\n"
);
if ($err !== null) {
$errors[] = $err;
error_log($err);
}
}
// Pass 3 — privileged flat key=value edits (root-only files).
// Same column-0 `key=` semantics as pass 1, but the read step
// uses `sudo cat` so we can service mode-600 root:root paths,
// and the install mode is 600 not 644.
foreach ($privStage as $path => $kvPairs) {
$rc = 0;
$out = array();
// sudo -n: never prompt — fail loudly if sudoers ever changes.
// Output is the file content; stderr captured separately so a
// sudo / cat failure doesn't poison the parsed lines.
exec('sudo -n cat ' . escapeshellarg($path) . ' 2>/dev/null',
$out, $rc);
if ($rc !== 0) {
$errors[] = "config_writer: sudo cat exit=$rc for $path; privileged edits skipped";
error_log("config_writer: sudo cat exit=$rc for $path; "
. count($kvPairs) . " privileged edits skipped");
continue;
}
$lines = $out;
foreach ($kvPairs as $key => $value) {
$prefix = $key . '=';
$applied = false;
foreach ($lines as $i => $line) {
if (strpos($line, $prefix) === 0) {
$lines[$i] = $prefix . $value;
$applied = true;
break;
}
}
if (!$applied) {
error_log("config_writer: $path has no '$key=' line; privileged edit skipped");
}
}
$err = _config_writer_install_atomic(
$path,
implode("\n", $lines) . "\n",
'600'
);
if ($err !== null) {
$errors[] = $err;
error_log($err);
}
}
if ($manageMount) {
system('sudo mount -o remount,ro /');
}
// Clear all stages so a subsequent commit() call doesn't
// re-apply already-applied edits.
$GLOBALS['__config_writer_stage'] = array();
$GLOBALS['__config_writer_stage_phpstr'] = array();
$GLOBALS['__config_writer_stage_priv'] = array();
return $errors;
}
-426
View File
@@ -1,426 +0,0 @@
<?php
/**
* CSRF (Cross-Site Request Forgery) protection primitive for the
* Pi-Star dashboard.
*
* Threat model
* ============
*
* The dashboard sits behind Apache HTTP Basic Auth on a LAN. The
* authenticated administrator's browser will automatically attach
* the basic-auth credential to ANY request the browser sends to the
* dashboard's host — including requests triggered by a malicious
* page open in another tab. Without CSRF protection, that hostile
* page can POST to e.g. `/admin/power.php` and reboot the device,
* or POST a full configuration to `/admin/configure.php`, simply
* because the user's browser is sitting on cached basic-auth.
*
* The mitigation: every state-changing POST handler MUST verify
* that the request carries a server-issued, session-scoped token
* the attacker cannot read (same-origin policy prevents the hostile
* page from reading the dashboard's HTML to extract it).
*
* Public API
* ==========
*
* csrf_token() -> string (64 hex chars; idempotent within a session)
* csrf_field() -> echoes a hidden <input> tag for forms
* csrf_verify() -> dies with HTTP 403 if the POSTed token is missing
* or invalid; returns silently otherwise
*
* Usage
* =====
*
* In a top-level GET-rendered page that contains a POST form:
*
* require_once $_SERVER['DOCUMENT_ROOT'] . '/config/csrf.php';
* ...
* <form method="post" action="...">
* <?php csrf_field(); ?>
* ...
* </form>
*
* In the matching POST handler (top of the file, before any state
* change):
*
* require_once $_SERVER['DOCUMENT_ROOT'] . '/config/csrf.php';
* if ($_SERVER['REQUEST_METHOD'] === 'POST') {
* csrf_verify();
* }
*
* Design notes
* ============
*
* - One token per session, reused across forms. Simpler retrofit
* than per-form tokens; no UX downside (the attacker still can't
* read the token, which is what matters). The token rotates
* when the session itself rotates (typically: browser closed,
* basic-auth re-prompted, or `session_regenerate_id()` called
* elsewhere).
*
* - Token = `bin2hex(random_bytes(32))` -> 64 hex chars (256 bits
* of entropy). `random_bytes()` is PHP 7.0+ and uses the
* OS CSPRNG (`/dev/urandom` on Linux). PHP 7.0 is this codebase's
* stated floor, so no fallback needed.
*
* - Verification uses `hash_equals()` (PHP 5.6+) for constant-
* time comparison. This is overkill for a hex-vs-hex comparison
* against a 256-bit secret, but costs nothing and removes any
* theoretical timing-leak class.
*
* - Failure mode (HTTP 403): write a minimal, self-contained HTML
* page rather than a JSON blob. The dashboard's forms post
* directly and the user lands on the response body in their
* browser — they need to understand what happened.
*
* - We deliberately do NOT call session_regenerate_id() on each
* request. Several existing pages (admin/update.php,
* admin/calibration.php, admin/live_modem_log.php) store
* log-tail offsets in $_SESSION across many AJAX requests; a
* mid-session ID rotation would lose those offsets and break
* the live log tails.
*
* - GET requests are NOT verified. CSRF protection only applies
* to state-changing requests, and the dashboard's idempotent
* read pages are POST-free by convention.
*
* - Session-cookie hardening. We set HttpOnly + SameSite=Lax on
* every issued PHPSESSID, and Secure conditionally when the
* request is HTTPS (via isHttps() in security_headers.php —
* covers direct TLS to nginx AND reverse-proxy / Cloudflare
* terminations that forward via X-Forwarded-Proto). See
* {@see _csrf_set_cookie_params()}.
*/
require_once $_SERVER['DOCUMENT_ROOT'] . '/config/security_headers.php';
/**
* Configure the PHPSESSID cookie's flags for hardened delivery.
*
* Must be called BEFORE session_start(); has no effect once the
* session is active. Sets:
*
* HttpOnly — always. Blocks document.cookie access from JS, so
* a future XSS in any rendered page can't read the
* session ID. Defence in depth alongside the input
* escaping work in the rest of the security pass.
*
* SameSite=Lax — always. Browser stops sending the cookie on
* cross-site subresource fetches and cross-site POSTs;
* top-level navigation (clicking a bookmark, following
* a same-origin redirect) still carries it, so UX
* doesn't change. Belt-and-braces with the existing
* CSRF-token check.
*
* Secure — conditional on isHttps(). UNCONDITIONALLY setting
* Secure would invalidate the cookie for the (large)
* population of operators on plain-HTTP LAN access
* and silently break CSRF protection. isHttps() also
* returns true for X-Forwarded-Proto: https — so
* operators behind Cloudflare / a reverse proxy /
* Tailscale Funnel get the right Secure flag even
* though nginx itself only sees plain HTTP.
*
* Trust caveat: an attacker who can talk directly
* to nginx on port 80 (bypassing the proxy) could
* spoof X-Forwarded-Proto and trick the dashboard
* into setting Secure on their own session. The
* consequence is the spoofer's cookie won't replay
* over plain HTTP — a self-DoS, not an escalation.
*
* PHP version handling. The samesite option was added to
* session_set_cookie_params() in PHP 7.3 (the array form). On
* 7.0..7.2 we fall back to the well-known path-suffix kludge:
* appending `; SameSite=Lax` to the path argument. PHP doesn't
* validate the path string — it concatenates verbatim into the
* Set-Cookie header — and browsers parse `path=/; SameSite=Lax`
* correctly because `;` terminates the path attribute. The
* codebase floor is PHP 7.0; the production runtime is PHP 8.2.
*
* @return void
*/
function _csrf_set_cookie_params()
{
$secure = function_exists('isHttps') ? isHttps() : false;
if (PHP_VERSION_ID >= 70300) {
// Modern array form. Available since PHP 7.3.
@session_set_cookie_params(array(
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
));
} else {
// PHP 7.0..7.2: no native samesite support. The path-suffix
// kludge is the documented workaround — see PHP RFC for
// 7.3's array form, where this is acknowledged as the
// pre-7.3 idiom. lifetime/path/domain/secure/httponly here
// mirror the array values above.
@session_set_cookie_params(0, '/; SameSite=Lax', '', $secure, true);
}
}
/**
* Ensure a session is started. Idempotent.
*
* Several dashboard pages already call `session_start()` for their
* own state (log offsets, etc.), so this primitive must coexist
* gracefully with prior `session_start()` calls. PHP_SESSION_ACTIVE
* is the canonical guard introduced in PHP 5.4.
*/
function csrf_session_start()
{
if (session_status() !== PHP_SESSION_ACTIVE) {
// Pi-Star ships with `session.gc_probability=0` AND
// /var/lib/php/sessions mounted as a 64KB tmpfs (per
// /etc/fstab in the OS image). With CSRF, every dashboard
// visit creates a session file, and without automatic GC
// those accumulate until reboot — at which point the tmpfs
// fills (~15 sessions) and session_start() starts failing
// with "No space left on device", silently breaking CSRF.
//
// Force GC at session-start time by bumping the probability
// ratio to 1/1. PHP runs GC itself during session_start when
// (gc_probability / gc_divisor) > random — at 1/1 that's
// every call, but only for THIS request's session_start.
// Cost: a tmpfs `glob` + a few `unlink`s, microseconds.
// The @-suppression handles hosts that disallow ini_set on
// these keys; failure just means we fall back to PHP's
// default behaviour, same as before this fix.
//
// session_gc() (PHP 7.1+) would be cleaner but the codebase
// targets PHP 7.0. ini_set works on every supported version.
if ((int)ini_get('session.gc_probability') === 0) {
@ini_set('session.gc_probability', '1');
@ini_set('session.gc_divisor', '1');
}
// gc_maxlifetime is intentionally NOT overridden here — we
// defer to Pi-Star's stock /etc/php/*/fpm/php.ini value
// (1440 s, matching PHP's own default). The dashboard's
// AJAX-refreshing panels (lh.php, repeaterinfo.php, the
// bm_links / tgif_links partials, etc.) do not load csrf.php,
// so they don't update the session file's mtime — meaning the
// session counts as "idle" from the moment csrf_verify() last
// ran on a top-level page load, even while the dashboard is
// visibly active in the operator's tab. Anything shorter than
// ~24 min would routinely 403 BM-manager / TGIF-manager /
// configure.php POSTs whenever the operator left the dashboard
// tab open between page loads. tmpfs containment is the
// pre-emptive prune below, not maxlifetime.
//
// Pi-Star's /var/lib/php/sessions tmpfs is sized 64 KB (per
// /etc/fstab in the OS image) — about 15 session files at
// a 4 KB tmpfs block each. csrf.php is only loaded behind
// basic auth on /admin/*, so the only session-creators are
// authenticated operators (whose browsers reuse one cookie =
// one session) and tooling that hits the dashboard with
// fresh cookie jars per request. The latter has filled the
// tmpfs in practice; once full, session_start() fails with
// "No space left on device" and CSRF silently breaks for
// the operator — they get a 403 on the next form submit
// because $_SESSION['csrf_token'] could not be persisted.
//
// Belt-and-braces safety net: cap the session directory at
// 12 files BEFORE session_start() tries to write a new one.
// GC alone won't help here because a burst of fresh-cookie
// requests can fill the 64 KB tmpfs faster than gc_maxlifetime
// expires anything. This pre-emptive prune deletes the
// oldest sess_* files until 12 remain, leaving ~3 slots of
// headroom under the ~15-file tmpfs cap.
//
// Best-effort: any failure here (permissions, missing dir,
// glob/unlink errors) is silently ignored — session_start()
// will still try and either succeed or fall through to the
// existing failure-logging path below. Worst case for an
// evicted session is an operator gets a 403 on the next
// form submit and a page reload re-issues a token, which
// is far better than the disk-full failure this guards
// against.
$sessSaveDir = (string)@ini_get('session.save_path');
if ($sessSaveDir !== '') {
$sessFiles = @glob($sessSaveDir . '/sess_*');
if (is_array($sessFiles) && count($sessFiles) > 12) {
usort($sessFiles, function ($a, $b) {
return (int)@filemtime($a) - (int)@filemtime($b);
});
$sessExcess = count($sessFiles) - 12;
for ($i = 0; $i < $sessExcess; $i++) {
@unlink($sessFiles[$i]);
}
}
}
// Harden the PHPSESSID cookie flags BEFORE session_start()
// emits the Set-Cookie header. See _csrf_set_cookie_params()
// for the per-flag rationale.
_csrf_set_cookie_params();
// Suppress notices about headers already sent — some
// dashboard pages emit output before this is reached.
// The session won't be usable in that scenario, but
// failing closed (csrf_verify rejects the POST) is the
// correct outcome. Log the underlying cause so a future
// maintainer who accidentally reorders requires above an
// echo can see why their POSTs started 403'ing.
if (@session_start() === false) {
error_log('csrf_session_start: session_start() failed '
. '(headers already sent? require_once order?)');
}
}
}
/**
* Return the session's CSRF token, issuing a fresh one on first
* call within a session.
*
* @return string 64-character hex string.
*/
function csrf_token()
{
csrf_session_start();
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Echo a hidden form input carrying the CSRF token.
*
* Place INSIDE the `<form>` element, before the submit button.
* Output is htmlspecialchars-safe: a hex string never contains
* any character that needs escaping, but we encode anyway as a
* defence against future changes to the token format.
*
* For sites that build form HTML into a string variable rather
* than echoing inline, see {@see csrf_field_html()}.
*/
function csrf_field()
{
echo csrf_field_html();
}
/**
* Return a hidden form input carrying the CSRF token, as a
* string. Useful for code that accumulates form HTML into a
* `$output` variable (e.g. wifi.php's wpa_conf form) where an
* `echo` mid-expression doesn't compose.
*
* @return string The hidden-input HTML.
*/
function csrf_field_html()
{
$tok = htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8');
return '<input type="hidden" name="csrf_token" value="' . $tok . '" />';
}
/**
* Verify the POSTed CSRF token. On mismatch, emit HTTP 403 and exit.
*
* Call this from EVERY state-changing POST handler before any side
* effect (file write, system call, session mutation, etc.). It is
* a no-op for GET / HEAD / OPTIONS requests — those are read-only
* by convention in this dashboard.
*
* The function does not return on failure: it sets the response
* code, prints a minimal HTML error page, and calls exit().
*/
function csrf_verify()
{
// Bootstrap the session up front, even on GET, so the
// Set-Cookie header gets emitted before any HTML output.
// csrf_field() (called inside the page's <form> tags) is
// lazy and may not run until well after output has started,
// so without an early csrf_verify() call sites that don't
// already have their own pre-output session_start() (most
// pages — power.php is the exception) never get a session
// cookie. Without a cookie the GET-issued token has no way
// to reach the POST handler.
//
// Pages should call csrf_verify() near the top of the file,
// BEFORE any output. On GET it bootstraps the session and
// returns; on POST it bootstraps, validates, and either
// returns silently or emits 403 + exit().
csrf_session_start();
if (!isset($_SERVER['REQUEST_METHOD']) ||
$_SERVER['REQUEST_METHOD'] !== 'POST') {
// Only POST is gated — GET pages render the token via
// csrf_field() and don't need verification.
return;
}
$expected = isset($_SESSION['csrf_token']) ? $_SESSION['csrf_token'] : '';
$supplied = isset($_POST['csrf_token']) ? $_POST['csrf_token'] : '';
// Distinguish "session has no token" from "session has a token
// and the supplied one is wrong". The former is almost always a
// benign expired-session POST (operator left the dashboard tab
// open past gc_maxlifetime, then clicked submit) and shouldn't
// throw the alarmist 403 page at them. Redirect 303 to the same
// URL — the browser switches to GET, the page re-renders fresh
// (forms that pre-populate from disk come back filled in; the
// session_start() in csrf_session_start() issues a new token),
// and the operator's submit retry just works.
//
// This is safe against forgery because a real attacker would be
// sending against an ACTIVE operator session whose
// $_SESSION['csrf_token'] is non-empty — that path stays on the
// strict 403 below. The only thing the redirect "lets through"
// is a fresh form render, which any GET would also serve.
if ($expected === '' || !is_string($expected) || strlen($expected) !== 64) {
$uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/admin/';
// Defence in depth: REQUEST_URI is typically same-origin
// already, but force a relative path so a crafted Host or
// proxy can't turn this into an open redirect.
$uri = '/' . ltrim(preg_replace('#^https?://[^/]*#i', '', $uri), '/');
header('Location: ' . $uri, true, 303);
header('Cache-Control: no-store');
exit;
}
// Reject only when the session actually had a token but the
// supplied one is missing or doesn't match — that IS a forgery.
if (!is_string($supplied) || strlen($supplied) !== 64 ||
!hash_equals($expected, $supplied)) {
// Best-effort log line for the operator. The remote IP is
// typically a LAN address but worth recording in case a
// rogue device is fingerprinted by repeated 403s.
$remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '?';
$uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '?';
error_log("csrf_verify: rejected POST from $remote to $uri");
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
// Don't pollute browser history with this response.
header('Cache-Control: no-store');
// English-only by design: the dashboard's lang/ system
// requires config/language.php, which reads /etc/pistar-release
// and the gateway configs. Pulling that whole stack into an
// error path that only fires under attack is disproportionate.
echo '<!DOCTYPE html><html lang="en"><head>'
. '<meta charset="utf-8" /><title>403 Forbidden</title></head>'
. '<body><h1>403 Forbidden</h1>'
. '<p>This request did not include a valid CSRF token. '
. 'If you reached this page by clicking a link from another site, '
. 'that other site may have been trying to perform an action on '
. 'your behalf without your consent.</p>'
. '<p>If you reached this page by submitting a form on the '
. 'dashboard, your session may have expired. Reload the page '
. 'and try again.</p>'
. '</body></html>';
exit;
}
// Token verified — strip it from $_POST so downstream handlers
// that iterate $_POST (e.g. fulledit_bmapikey.php and
// fulledit_dapnetapi.php's INI writers, which treat each top-
// level POST key as an [INI section]) don't accidentally write
// a stray `[csrf token]` block into /etc/<file>. Without this,
// every successful submit on those editors prepended an empty
// `[csrf token]` section to the saved config and rendered a
// ghost table titled "csrf_token" on the response page.
// Centralising the unset here means every current and future
// POST handler is immune without needing to remember the dance.
unset($_POST['csrf_token']);
}
+12 -25
View File
@@ -1,28 +1,15 @@
<?php
/**
* Local paths and identity for ircDDBGateway-related dashboard code.
*
* Defines where the gateway's logs and config files live on the device,
* plus the local callsign placeholder. The dashboard reads these paths
* to render the D-Star and CCS panels and to back the configure.php
* editor. `configure.php` rewrites `$callsign=` here when the operator
* changes their callsign.
*/
// Log directory and the four ircDDBGateway-managed log files.
$logPath = '/var/log/pi-star';
$starLogPath = $logPath . '/STARnet.log';
$linkLogPath = $logPath . '/Links.log';
$hdrLogPath = $logPath . '/Headers.log';
$ddmode_log = $logPath . '/DDMode.log';
// Local node identity (rewritten by configure.php).
$callsign = 'M1ABC';
$logPath='/var/log/pi-star';
$callsign='M1ABC';
$registerURL = '';
// On-disk locations the dashboard reads/writes.
$configPath = '/etc';
$starLogPath = $logPath . '/STARnet.log';
$linkLogPath = $logPath . '/Links.log';
$hdrLogPath = $logPath . '/Headers.log';
$ddmode_log = $logPath . '/DDMode.log';
$configPath='/etc';
$gatewayConfigPath = '/etc/ircddbgateway';
$defaultConfPath = '/etc/default';
$sharedFilesPath = '/usr/local/etc';
$sysConfigPath = '/etc/sysconfig';
$defaultConfPath = '/etc/default';
$sharedFilesPath = '/usr/local/etc';
$sysConfigPath = '/etc/sysconfig';
?>
-193
View File
@@ -1,193 +0,0 @@
<?php
/**
* Security headers for the Pi-Star Dashboard.
*
* Centralises Content-Security-Policy, X-Frame-Options, X-Content-Type-Options,
* X-XSS-Protection, Referrer-Policy, Permissions-Policy, and HSTS so every
* dashboard entry-point sets the same baseline.
*
* Three flavours are provided because dashboard pages fall into three
* distinct categories:
*
* - {@see setSecurityHeaders()} — top-level pages
* (index, admin, configure, editors). Locks frame ancestors and frame-src
* to same-origin to prevent the page being framed by a hostile site.
*
* - {@see setEmbeddableSecurityHeaders()} — AJAX-loaded partials
* (last-heard list, local TX, mode info, etc.). Same CSP minus the frame
* restrictions, because the partial is itself loaded into a parent page
* via $.load() and would otherwise refuse to embed.
*
* - {@see setSecurityHeadersAllowDifferentPorts()} — pages that iframe
* services on different ports of the same host (e.g. shellinabox SSH on
* a non-80 port). Adds frame-src for the same hostname on any port.
*
* HSTS is only emitted when the request is over HTTPS (direct or via an
* upstream proxy reporting X-Forwarded-Proto). All three functions are
* idempotent and bail early if headers have already been sent.
*
* The CSP intentionally allows 'unsafe-inline' for both scripts and styles
* because the dashboard inlines large amounts of JS and inline `style="…"`
* attributes; tightening this would require a much larger refactor.
*/
/**
* Detect whether the current request is being served over HTTPS.
*
* Checks both direct indicators ($_SERVER['HTTPS'], port 443) and proxy
* headers (X-Forwarded-Proto, X-Forwarded-SSL) so we still detect HTTPS
* correctly when sitting behind a TLS-terminating proxy.
*
* @return bool True if the request is over HTTPS, false otherwise.
*/
function isHttps() {
// Check standard HTTPS indicators
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
return true;
}
if (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) {
return true;
}
// Check for proxy/load balancer headers
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
return true;
}
if (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] === 'on') {
return true;
}
return false;
}
/**
* Set full security headers for non-embeddable pages
* Use for: Admin pages, configuration editors, main entry points
*/
function setSecurityHeaders() {
// Only set headers if they haven't been sent yet
if (!headers_sent()) {
$isHttps = isHttps();
header("X-Frame-Options: SAMEORIGIN");
header("X-Content-Type-Options: nosniff");
header("X-XSS-Protection: 1; mode=block");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
// Build CSP based on protocol
// Allow external images via both http: and https: since we can't control external links
$imgSrc = $isHttps ? "'self' data: https:" : "'self' data: http: https:";
$csp = "default-src 'self'; " .
"script-src 'self' 'unsafe-inline'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src {$imgSrc}; " .
"connect-src 'self'; " .
"frame-ancestors 'self'";
header("Content-Security-Policy: " . $csp);
// Only add HSTS if served over HTTPS
if ($isHttps) {
// HSTS: Force HTTPS for 1 year, but don't include subdomains (might be on local network)
header("Strict-Transport-Security: max-age=31536000");
}
}
}
/**
* Set embeddable security headers for display components
* Use for: Status displays, last heard lists, info panels meant to be embeddable
*/
function setEmbeddableSecurityHeaders() {
// Only set headers if they haven't been sent yet
if (!headers_sent()) {
$isHttps = isHttps();
// Note: X-Frame-Options omitted to allow embedding
header("X-Content-Type-Options: nosniff");
header("X-XSS-Protection: 1; mode=block");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
// Build CSP based on protocol
$imgSrc = $isHttps ? "'self' data: https:" : "'self' data: http: https:";
$csp = "default-src 'self'; " .
"script-src 'self' 'unsafe-inline'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src {$imgSrc}; " .
"connect-src 'self'";
header("Content-Security-Policy: " . $csp);
// Only add HSTS if served over HTTPS
if ($isHttps) {
header("Strict-Transport-Security: max-age=31536000");
}
}
}
/**
* Set security headers for pages that embed content from different ports on same host
*
* This allows iframes from the same hostname but different ports
*/
function setSecurityHeadersAllowDifferentPorts() {
// Only set headers if they haven't been sent yet
if (!headers_sent()) {
$isHttps = isHttps();
header("X-Frame-Options: SAMEORIGIN");
header("X-Content-Type-Options: nosniff");
header("X-XSS-Protection: 1; mode=block");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
// HTTP_HOST is client-controllable, and it's about to be
// interpolated INTO an HTTP response header value (CSP). A
// CRLF in the Host header would split the response and let
// an attacker inject a second header. A semicolon, space,
// or quote could break out of the frame-src directive (e.g.
// closing it early to widen frame-ancestors). The original
// `preg_replace('/:\d+$/', '', ...)` only stripped the port
// — anything else in HTTP_HOST flowed straight into the CSP.
//
// Tighten the regex to keep only hostname-shaped characters.
// Same set as ssh_access.php's #19 fix: DNS names, IPv4,
// IPv6 bracketed, optional `:port`. Strip the port suffix
// afterwards (frame-src already says `:*` so a specific
// port would be redundant).
$rawHost = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
$hostnameOnly = preg_replace(
'/[^a-zA-Z0-9.\-\[\]]/',
'',
preg_replace('/:\d+$/', '', $rawHost)
);
if ($hostnameOnly === '') {
// No usable host (empty header, all-bad chars). Fall
// back to a safe constant so frame-src is well-formed
// — `localhost` will simply not match any real iframe
// target and the CSP rejects the embedding cleanly.
$hostnameOnly = 'localhost';
}
// Build CSP that allows frames from same hostname on any port
$imgSrc = $isHttps ? "'self' data: https:" : "'self' data: http: https:";
// Allow frames from same hostname with any port (for shellinabox, etc.)
$csp = "default-src 'self'; " .
"script-src 'self' 'unsafe-inline'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src {$imgSrc}; " .
"connect-src 'self'; " .
"frame-src 'self' http://{$hostnameOnly}:* https://{$hostnameOnly}:*; " .
"frame-ancestors 'self'";
header("Content-Security-Policy: " . $csp);
// Only add HSTS if served over HTTPS
if ($isHttps) {
header("Strict-Transport-Security: max-age=31536000");
}
}
}
+2 -8
View File
@@ -1,9 +1,3 @@
<?php
/**
* Pi-Star Dashboard version string.
*
* Bumped on each release. Format: YYYYMMDD (calver).
* Surfaced in the dashboard banner and in the `meta generator` tag.
*/
$version = '20260623';
$version = '20260705';
?>