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:
Gmer4Lfe
2026-08-02 10:28:53 -04:00
parent 987313e7dc
commit c34224effa
16 changed files with 198 additions and 67 deletions
+54 -24
View File
@@ -114,34 +114,64 @@ be the reason a page fails to load.
## ━━━ WHAT GUARDS THE API LAYER ━━━
Every endpoint under `api/` is protected by exactly one thing: **the Unraid WebGUI session.**
Anything that can reach `/plugins/varaverk/api/*.php` with a valid session can do everything
this plugin can do — stop the array, power off the host, write `master.conf` and push it to
every partner, create an lldap user, write and schedule a root-run script.
Two layers, both supplied by the platform, neither implemented in this plugin.
That is the same trust level as the rest of the WebGUI, and it is the intended model. It is
written down here because two things about it are easy to assume and wrong.
**1. nginx session auth.** `/etc/nginx/nginx.conf` applies `satisfy any; allow unix:; deny all;
auth_request /auth-request.php;` to everything it serves, `/plugins/` included. A request
without a valid WebGUI session never reaches PHP — it is redirected to the login page.
**No endpoint validates a CSRF token.** `pages/partnership.php` and `pages/scheduler.php` send
Unraid's `csrf_token` with their POSTs, which reads like the token is checked somewhere. It is
not — no file in `api/` looks at it. A request that arrives with a logged-in session cookie is
honoured whatever caused the browser to send it. Adding validation is a worthwhile hardening
pass, but it is a real change: every caller has to send the token before any endpoint requires
it, or the UI breaks silently on whichever page was missed. Do it deliberately, in one pass,
with the pages open — not opportunistically while touching one endpoint.
**2. Unraid's CSRF prepend.** `/etc/php.ini` sets
`auto_prepend_file=/usr/local/emhttp/webGui/include/local_prepend.php`, which runs before the
first line of any endpoint and, for **every POST**, requires a valid token as either a
`csrf_token` body field or an `X-CSRF-Token` header. On a mismatch it logs to syslog and
`exit`s. php-fpm inherits this — there is no pool override in `/etc/php-fpm.d/www.conf`.
**`api/webhook.php` is the exception that authenticates nothing at all.** It exists to receive
Sonarr/Radarr/Lidarr download events, which arrive from a container rather than a browser.
`master.conf` carries a `WEBHOOK_SECRET` and the standalone Node listener on `WEBHOOK_PORT`
validates it — this WebGUI-hosted path does not. Injection is not the risk (the path is
validated and escaped); triggering work is. Closing it means adding a secret check here **and**
updating the webhook URL in each arr's settings, in that order.
Anything that clears both can do everything this plugin can do: stop the array, power off the
host, write `master.conf` and push it to every partner, create an lldap user, write and
schedule a root-run script. That is the same trust level as the rest of the WebGUI, and it is
the intended model.
Two further endpoints are worth knowing about because they read wider than the rest:
`api/api_test.php` returns an API key prefix and the live GraphQL schema, and
`api/import_script.php`'s browse action and `api/manual_sync.php`'s browse actions list
directory names anywhere on either host. All three are read-only and none return file contents,
but they are the ones to look at first if the session boundary ever moves.
### The two consequences that actually matter
**GET is not covered.** The prepend inspects POSTs only. So any action that *changes something*
must be POST — over GET it would run with no token check at all. Four actions were GET and have
been converted: `create_api_key.php`, `arrs.php?action=refresh_remote`, `cert.php?action=run`
and `setup.php?action=ssh_generate`. **When adding an endpoint the rule is simply: if it changes
state, it is POST.** Read-only GETs (`monitor`, `board`, `checklist`, the browse pickers,
`api_test.php`) are bounded by the session alone, which is correct for reads.
**The webGUI's token injector is jQuery-only.** `$.ajaxPrefilter` in dynamix's
`HeadInlineJS.php` appends `csrf_token` to jQuery POSTs. Varaverk's pages use native `fetch()`,
which it does not touch — so a `fetch()` POST carries no token and the prepend kills it
silently: `csrf_terminate()` exits with an empty body, `r.json()` throws on the empty response,
and the page's own `.catch()` swallows it. There is no error anywhere except a syslog line.
`Varaverk.page` therefore installs a small `window.fetch` shim, inline and above the tab
content, that attaches `X-CSRF-Token` to same-origin `/plugins/varaverk/` requests. The header
form is deliberate: it works for `FormData`, `URLSearchParams` and raw JSON bodies alike, so no
call site has to know about it and a new one cannot forget it. A cross-origin page cannot set
a custom header without a preflight it will fail, which is what makes it a real defence rather
than a formality.
**Do not construct POSTs outside `fetch()`** — a raw `XMLHttpRequest` or a generated form —
without adding the token yourself; the shim only wraps `fetch`. A plain `<form method="POST">`
is already covered by a different platform mechanism: dynamix's `BodyInlineJS.php` appends a
hidden `csrf_token` input to every form on the page, which is what makes `VaraverkSettings.page`
work without any of this.
### api/webhook.php is dead code
It receives arr download events — but an arr has no WebGUI session and no CSRF token, so it is
blocked by *both* layers above and cannot be called at all. The live path is
`Arrs_Stack/webhook_listener.js` on `WEBHOOK_PORT`, running outside nginx entirely and
validating `WEBHOOK_SECRET` itself. `api/webhook.php` cannot be made to work by adding a secret
check — it would have to be served from outside `/plugins/` first. Removing it is the other
reasonable option.
Two endpoints read wider than the rest and are worth knowing about if the session boundary ever
moves: `api/api_test.php` returns an API key prefix and the live GraphQL schema, and the browse
actions in `api/import_script.php` and `api/manual_sync.php` list directory names anywhere on
either host. All are read-only and none return file contents.
---