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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user