427 lines
19 KiB
PHP
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']);
|
|
}
|