Carry the CSRF token on fetch requests and put mutations behind POST
Unraid already enforces CSRF on every POST via auto_prepend, but its injector is jQuery-only — the plugin's native fetch() calls carried no token and were being terminated before the endpoint ran, silently, because csrf_terminate exits with an empty body that r.json() swallows.
This commit is contained in:
@@ -59,8 +59,8 @@
|
||||
// A key prefix, the cached API debug log, and the live schema are all visible to anyone
|
||||
// with a WebGUI session. That is acceptable for a diagnostic reachable only by typing
|
||||
// its URL, but it is the reason it is not linked from any page and should not be
|
||||
// wrapped in one. See the CSRF note in README-unraid.md for the session-only guard this
|
||||
// shares with the rest of the api layer.
|
||||
// wrapped in one. It is a pure GET read, so Unraid's POST-only CSRF guard does not
|
||||
// apply to it — the WebGUI session is its whole boundary. See README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET, no parameters
|
||||
|
||||
@@ -49,8 +49,9 @@
|
||||
// scripts write; nothing here triggers a scan, cleanup, or import.
|
||||
//
|
||||
// REQUEST
|
||||
// GET cached (300s) full payload
|
||||
// GET ?action=refresh_remote&host=host<n> re-run the partner cache writer for one host
|
||||
// GET cached (300s) full payload
|
||||
// POST action=refresh_remote host=host<n> re-run the partner cache writer for one host
|
||||
// (POST, so Unraid's CSRF guard applies)
|
||||
//
|
||||
// RESPONSE
|
||||
// normal vv_arrs_all() verbatim — local node live, remote nodes cached with cache_age
|
||||
@@ -70,9 +71,17 @@ require_once dirname(__DIR__) . '/include/config.php';
|
||||
define('VV_ARRS_REFRESH_TIMEOUT', 120);
|
||||
|
||||
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
|
||||
$_action = trim($_GET['action'] ?? '');
|
||||
// POST only. The refresh executes a script, and Unraid's CSRF prepend validates POSTs while
|
||||
// ignoring GETs entirely — so reaching this over GET would mean running it with no token
|
||||
// check. A GET naming the action is refused rather than falling through to the cached read,
|
||||
// so a stale caller fails visibly instead of silently appearing to succeed.
|
||||
if (trim($_GET['action'] ?? '') === 'refresh_remote') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'POST only']); exit;
|
||||
}
|
||||
$_action = ($_SERVER['REQUEST_METHOD'] === 'POST') ? trim($_POST['action'] ?? '') : '';
|
||||
if ($_action === 'refresh_remote') {
|
||||
$host = strtolower(trim($_GET['host'] ?? ''));
|
||||
$host = strtolower(trim($_POST['host'] ?? ''));
|
||||
if (!preg_match('/^host\d+$/', $host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
|
||||
}
|
||||
|
||||
@@ -58,10 +58,12 @@
|
||||
// authoring a fresh config — a config it created would carry no rules and a default
|
||||
// policy, which is an accidental open door.
|
||||
//
|
||||
// Known gap: no CSRF token is validated on the POST actions.
|
||||
// The endpoint is guarded by the Unraid WebGUI session alone, and this is the highest-
|
||||
// value endpoint in the plugin to reach — it can create a user and open a proxy host.
|
||||
// Shared with the rest of the api layer; see the CSRF note in README-unraid.md.
|
||||
// The POST actions are CSRF-guarded by the platform, not by this file.
|
||||
// Unraid's auto_prepend (webGui/local_prepend.php) validates a token on every POST and
|
||||
// terminates the request before any code here runs. That guard is the reason every
|
||||
// mutating action lives in the POST arm — this is the highest-value endpoint in the
|
||||
// plugin to reach, since it can create a user and open a proxy host. The five GET
|
||||
// actions are reads and are deliberately outside it. See README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET ?action=npm_proxies | npm_certs | lldap_users | lldap_groups | authelia_rules
|
||||
|
||||
@@ -67,15 +67,17 @@
|
||||
// response is treated as a certificate list — so an unreachable NPM is reported as such
|
||||
// instead of rendering as zero certificates.
|
||||
//
|
||||
// Accepted: the run action is reachable over GET.
|
||||
// It re-runs a read-only monitor and writes only its own cache, so repeating it is
|
||||
// harmless. Guarded by the Unraid WebGUI session; see the CSRF note in README-unraid.md.
|
||||
// The run action is POST only, which is what places it behind Unraid's CSRF guard.
|
||||
// The platform prepend validates the token on every POST and inspects no GET at all, so
|
||||
// an action that executes a script must not be reachable by GET. The three read actions
|
||||
// stay GET-reachable because they change nothing.
|
||||
//
|
||||
// REQUEST
|
||||
// GET cached status, or the configured domains when no cache exists
|
||||
// GET|POST ?action=npm live certificate list from NPM's API
|
||||
// GET|POST ?action=domains configured domains and thresholds, no checks run
|
||||
// GET|POST ?action=run re-run cert_monitor.sh, then return its fresh cache
|
||||
// POST action=run re-run cert_monitor.sh, then return its fresh cache
|
||||
// (POST, so Unraid's CSRF guard applies)
|
||||
//
|
||||
// RESPONSE
|
||||
// default {"ok":true,"checked_at","host","warn_days","crit_days","domains":[…]}
|
||||
@@ -167,6 +169,13 @@ if ($action === 'domains') {
|
||||
|
||||
// ── Run cert_monitor.sh now ───────────────────────────────────────────────────
|
||||
if ($action === 'run') {
|
||||
// POST only. This executes a script, and Unraid's CSRF prepend validates POSTs while
|
||||
// ignoring GETs entirely — over GET it would run with no token check at all.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'POST only']);
|
||||
exit;
|
||||
}
|
||||
$script = SCRIPTS_DIR . '/Monitors/cert_monitor.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh not found']);
|
||||
|
||||
@@ -46,14 +46,14 @@
|
||||
// Cache-Control: no-store, no-cache. A cached provisioning response would report an old
|
||||
// key preview after a genuine renewal — the one moment the preview matters.
|
||||
//
|
||||
// Known gap: this is a state-changing action served over GET.
|
||||
// Both callers (pages/setup.php, pages/partnership.php) fetch it, and POST bodies are
|
||||
// unreliable on this nginx/PHP setup. It is guarded by the Unraid WebGUI session rather
|
||||
// than by method or token. Documented rather than silently accepted — see the CSRF note
|
||||
// in README-unraid.md.
|
||||
// POST only, which is what places it behind Unraid's CSRF guard.
|
||||
// The platform prepend validates the token on every POST and inspects no GET at all, so
|
||||
// a state-changing action reachable by GET is a state-changing action with no CSRF
|
||||
// protection. This was previously a GET; the callers now POST and the token travels as
|
||||
// an X-CSRF-Token header set by the shim in Varaverk.page. See README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET, no parameters (the target host is the local host, by construction)
|
||||
// POST, no parameters (the target host is the local host, by construction)
|
||||
//
|
||||
// RESPONSE
|
||||
// {"ok":true,"key_preview":"abcd1234...wxyz"} or "registered" when the key is not readable
|
||||
@@ -67,6 +67,14 @@ header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
// POST is what puts this behind Unraid's CSRF guard — the platform prepend validates every
|
||||
// POST and ignores every GET, so a state-changing action must not be reachable by GET.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'POST only']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$host = vv_detect_host();
|
||||
if (!preg_match('/^host\d+$/', $host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Cannot detect local host']);
|
||||
|
||||
@@ -67,8 +67,9 @@
|
||||
//
|
||||
// Accepted exposure: the browser can list any directory on the host.
|
||||
// It returns directory names and .sh filenames only — no file contents, and nothing
|
||||
// outside those two types. That is the minimum a file picker rooted at / can do, and it
|
||||
// is guarded by the Unraid WebGUI session; see the CSRF note in README-unraid.md.
|
||||
// outside those two types. That is the minimum a file picker rooted at / can do. browse
|
||||
// is a GET read, so the WebGUI session is its whole boundary; the import itself is POST
|
||||
// and is CSRF-guarded by Unraid's auto_prepend. See README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET ?action=browse&path=/absolute/dir list subdirectories and .sh files
|
||||
|
||||
@@ -93,8 +93,9 @@
|
||||
// output in /tmp indefinitely. /tmp is tmpfs, so an abandoned one clears at reboot anyway.
|
||||
//
|
||||
// Accepted exposure: browse can list any directory on either host.
|
||||
// Directory names only — no file contents, no file names. Guarded by the Unraid WebGUI
|
||||
// session; see the CSRF note in README-unraid.md.
|
||||
// Directory names only — no file contents, no file names. browse and poll are GET reads
|
||||
// bounded by the WebGUI session; run and stop are POST and are CSRF-guarded by Unraid's
|
||||
// auto_prepend before any code here runs. See README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET ?action=hosts partners, Tailscale state, key presence
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
// Credential fields are returned as they appear in the conf.
|
||||
// The partnership sections carry SSH key paths and API keys, and this endpoint returns
|
||||
// them for editing. That is the same exposure the raw conf editor has, over the same
|
||||
// WebGUI session; see the CSRF note in README-unraid.md.
|
||||
// WebGUI session. This is a GET read, so Unraid's POST-only CSRF guard does not apply —
|
||||
// the session is its whole boundary. See README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET, no parameters
|
||||
|
||||
@@ -54,8 +54,9 @@
|
||||
//
|
||||
// Accepted by design: this endpoint writes an executable root-run script from a browser.
|
||||
// That is the entire feature, and it is why it is confined to a directory outside the
|
||||
// repo with a flat namespace and a strict name pattern. It is guarded by the Unraid
|
||||
// WebGUI session; see the CSRF note in README-unraid.md.
|
||||
// repo with a flat namespace and a strict name pattern. The save and delete paths are
|
||||
// POST, so Unraid's auto_prepend validates a CSRF token before any of this runs; the
|
||||
// WebGUI session is the outer boundary. See README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET ?id=Custom/<name>.sh read (empty content when absent)
|
||||
|
||||
@@ -82,13 +82,17 @@
|
||||
// The SCP target is the local conf path, composed here — no part of the request names a
|
||||
// destination file.
|
||||
//
|
||||
// Accepted: this endpoint writes credentials and runs setup scripts as root.
|
||||
// It is the setup wizard; that is its function. It is guarded by the Unraid WebGUI
|
||||
// session; see the CSRF note in README-unraid.md.
|
||||
// Every action that changes anything is POST only, which is what places them behind
|
||||
// Unraid's CSRF guard.
|
||||
// The platform prepend validates the token on every POST and inspects no GET at all.
|
||||
// Only `detect` remains GET-reachable, and it is a pure read. This endpoint writes
|
||||
// credentials and runs setup scripts as root — that is its function, and it is why the
|
||||
// method boundary is the one that matters here.
|
||||
//
|
||||
// REQUEST
|
||||
// GET ?action=detect hostname, Unraid version, boot transport, suggested mode
|
||||
// GET|POST action=ssh_generate generate the local keypair, return the public key
|
||||
// POST action=ssh_generate generate the local keypair, return the public key
|
||||
// (POST, so Unraid's CSRF guard applies)
|
||||
// POST action=populate run conf_populate.sh --no-push
|
||||
// POST action=pull partner: SCP master.conf from HOST1, create host conf
|
||||
// [my_slot] [my_hostname] [host1_hostname]
|
||||
@@ -149,6 +153,13 @@ if ($action === 'detect') {
|
||||
|
||||
// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────
|
||||
if ($action === 'ssh_generate') {
|
||||
// POST only. This generates a keypair, and Unraid's CSRF prepend validates POSTs while
|
||||
// ignoring GETs entirely — over GET it would run with no token check at all.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'POST only']);
|
||||
exit;
|
||||
}
|
||||
$script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
|
||||
|
||||
@@ -64,15 +64,22 @@
|
||||
// Output is appended to a dedicated log with stdin detached, so a backgrounded handler
|
||||
// cannot hold the request's file descriptors open.
|
||||
//
|
||||
// Known gap: this endpoint authenticates nothing.
|
||||
// master.conf carries WEBHOOK_SECRET, and the standalone Node listener on WEBHOOK_PORT
|
||||
// validates it — this WebGUI-hosted path does not. Anyone who can reach the URL can
|
||||
// make it run the handler against any absolute path that passes validation. Injection
|
||||
// is not the risk (the path is escaped); triggering work is. Left as-is deliberately
|
||||
// rather than fixed in passing: adding a secret check here would break whichever arr
|
||||
// instances are currently configured against this URL, and that is a change to make
|
||||
// with the arr configs open, not as part of a documentation pass. See also the CSRF
|
||||
// note in README-unraid.md.
|
||||
// UNREACHABLE — this endpoint cannot currently be called by an arr at all.
|
||||
// Two platform layers stand in front of it, and an arr satisfies neither. nginx applies
|
||||
// `auth_request` to everything under /plugins/, so a request without a WebGUI session is
|
||||
// redirected to the login page. And Unraid's auto_prepend terminates every POST that
|
||||
// does not carry a valid CSRF token — which an arr has no way to obtain.
|
||||
//
|
||||
// The live path is the standalone Node listener, Arrs_Stack/webhook_listener.js, running
|
||||
// on WEBHOOK_PORT outside nginx entirely and validating WEBHOOK_SECRET itself. That is
|
||||
// what the arrs are pointed at, and it is why it exists.
|
||||
//
|
||||
// This file is therefore dead code kept for reference, not a second live entry point.
|
||||
// Nothing here authenticates, because nothing here can be reached. Do not "fix" it by
|
||||
// adding a secret check and re-pointing an arr at it — it would still be blocked by the
|
||||
// two layers above. If it is ever wanted as a real entry point it needs to be served
|
||||
// from outside /plugins/, at which point it needs WEBHOOK_SECRET validation first.
|
||||
// Removing it outright is the other reasonable option. See README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// POST <arr webhook JSON body>
|
||||
|
||||
Reference in New Issue
Block a user