main
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
<?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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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.
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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()`.
|
||||
*/
|
||||
|
||||
// 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', '');
|
||||
@@ -0,0 +1,570 @@
|
||||
<?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
@@ -0,0 +1,426 @@
|
||||
<?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']);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# ircddbgateway_languages.inc
|
||||
#
|
||||
# Written for Pi-Star Digital Voice by Andy Taylor (MW0MWZ)
|
||||
# Orrigional request by Dennis Bonesky
|
||||
#
|
||||
# Updated 28-May-2017
|
||||
#
|
||||
###########################################################
|
||||
#language;ircddbgateway;timeserver
|
||||
###########################################################
|
||||
English_(UK);0;0
|
||||
Deutsch;1;4
|
||||
Dansk;2;0
|
||||
Francais;3;6
|
||||
Italiano;4;0
|
||||
Polski;5;0
|
||||
English_(US);6;2
|
||||
Espanol;7;9
|
||||
Svenska;8;8
|
||||
Nederlands_(NL);9;7
|
||||
Nederlands_(BE);10;7
|
||||
Norsk;11;10
|
||||
Portugues;12;11
|
||||
###########################################################
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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';
|
||||
$registerURL = '';
|
||||
|
||||
// On-disk locations the dashboard reads/writes.
|
||||
$configPath = '/etc';
|
||||
$gatewayConfigPath = '/etc/ircddbgateway';
|
||||
$defaultConfPath = '/etc/default';
|
||||
$sharedFilesPath = '/usr/local/etc';
|
||||
$sysConfigPath = '/etc/sysconfig';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
// Set the language
|
||||
$pistarLanguage='chinese_cn';
|
||||
include_once $_SERVER['DOCUMENT_ROOT']."/lang/$pistarLanguage.php";
|
||||
?>
|
||||
@@ -0,0 +1,193 @@
|
||||
<?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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?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';
|
||||
Reference in New Issue
Block a user