string (64 hex chars; idempotent within a session) * csrf_field() -> echoes a hidden 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'; * ... *
* * 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 `