Files
cdn-star/config/csrf.php
T
2026-07-19 18:01:05 +08:00

427 lines
19 KiB
PHP

<?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']);
}