CSRF Protection Developer Guide - edmozley/freeitsm GitHub Wiki
Closes S4 from the 2026-08 security review. Shipped in 3.0.0.
If you only read one thing: you do not need to do anything to protect a new endpoint or a new page. Write the endpoint the normal way (start the session, require includes/functions.php), write the page the normal way (an HTML page with a <head>), send data with fetch the normal way β the protection is applied for you. This page explains how, what to do in the few cases that are not normal, and how to prove it is working.
- What CSRF is, and what FreeITSM had before
- The design: two checks, both central
- Request lifecycle, step by step
- The origin check
- The token
- How pages get the token
- The browser side β csrf.js
- Building new pages, endpoints and modules
- The exceptions, and how to add one
- When a request is refused
- Testing that it works
- Troubleshooting
- Files
Cross-site request forgery: a page on another website makes a signed-in person's browser send a request to FreeITSM. The browser attaches the person's login cookie, so FreeITSM cannot tell it apart from something they did themselves.
<!-- on evil.example - the victim only has to open the page -->
<form method="POST" action="https://helpdesk.example.com/api/system/save_analyst.php"
enctype="application/x-www-form-urlencoded">
<input name="username" value="attacker"><input name="is_admin" value="1">
</form>
<script>document.forms[0].submit()</script>What FreeITSM had before 3.0.0, and why it was not enough:
| Defence (before 3.0.0) | What it stopped | What it did not |
|---|---|---|
Session cookie SameSite=Lax (F7) |
The form above, from a different site, in current browsers | A page on a sibling subdomain β www.example.com and helpdesk.example.com are the same site, so the cookie is sent. Older browsers. |
| JSON bodies | A browser will not send Content-Type: application/json cross-origin without a preflight FreeITSM never answers |
Endpoints that also accept form-encoded bodies |
request_guard.php refusing text/plain
|
The "JSON hidden in a text/plain form" trick | Everything else β it stopped the proof of concept, not the class |
The sibling-subdomain case is the realistic one for FreeITSM: an MSP that hosts customer websites under the same domain as its helpdesk.
| Layer | Question it answers | Applies to |
|---|---|---|
| Origin check | Did this request come from a FreeITSM page? | Every state-changing request that carries the session cookie |
| Token | Does it carry the secret only a FreeITSM page for this session can know? | Every state-changing request from a signed-in session |
Both run in one place (csrfEnforce()), and pages get the token in one place (csrfStartPageInjection() + csrf.js).
π Why central. S4 stayed open for weeks because the obvious fix was "add a token check to ~370 endpoints and a header to ~600
fetchcalls". That is a huge diff, and worse, a protection that every future endpoint has to remember is not a protection β the first one somebody forgets is the hole. Here an endpoint is protected by being reached, and a page by being rendered.
"State-changing" means any method except GET, HEAD and OPTIONS. A GET is never checked β so an endpoint that writes must refuse a GET, with one line: csrfRequireWriteMethod() (Β§8.1).
π΄ This guide used to say "never change data on a GET" and leave it at that β the one rule in it that every endpoint had to remember, which is exactly the kind of protection the paragraph above says is not one. About twenty endpoints did change data on a GET, and a
SameSite=Laxcookie is sent on a top-level GET from another site, so a link was enough. Fixed in 3.15.1, and now enforced by a test rather than remembered: A link could change data.
Every endpoint starts the same way:
<?php
session_start(['read_and_close' => true]); // 1. the session is loaded
require_once '../../config.php';
require_once '../../includes/functions.php'; // 2. functions.php requires request_guard.phpand includes/request_guard.php ends with:
rejectSimpleRequestForgery(); // the older text/plain guard
// S4: the origin check and the session token, then the token into every page.
require_once __DIR__ . '/csrf.php';
csrfEnforce(); // 3. refuse a forged request (exits with 403)
csrfStartPageInjection(); // 4. pages only: put the token into <head>So by the time your endpoint's own code runs, a forged request has already been stopped. session_start() runs before functions.php in 1,044 of 1,045 files, which is what lets the guard read $_SESSION β even a session opened read_and_close has $_SESSION populated.
The whole decision, from includes/csrf.php:
function csrfEnforce(): void
{
if (PHP_SAPI === 'cli' || !csrfIsStateChanging()) return;
if (csrfIsExempt(csrfRequestPath())) return;
if (!csrfHasSessionCookie()) return; // no cookie, nothing to forge
if (csrfSameOrigin() === false) csrfRefuse('origin');
if (!isset($_SESSION) || !csrfSessionIsAuthenticated()) return; // signed-out forms: origin only
$expected = (string)($_SESSION[CSRF_SESSION_KEY] ?? '');
$given = csrfProvidedToken();
if ($expected === '' || $given === '' || !hash_equals($expected, $given)) csrfRefuse('token');
}Read top to bottom:
| Situation | Result |
|---|---|
| Command line (cron, scripts) | not checked |
GET / HEAD / OPTIONS
|
not checked |
| An exempt path | not checked |
No session cookie (webhooks, agents, curl) |
not checked β there is no victim |
| Cookie present, from another origin | refused: origin |
| Cookie present, signed out (login, register, password reset) | allowed β origin is enough |
| Cookie present, signed in, token missing or wrong | refused: token |
| Cookie present, signed in, right token, right origin | allowed |
csrfSameOrigin() returns true, false, or null ("the browser did not say"). It asks the browser's own label first:
$site = strtolower((string)($_SERVER['HTTP_SEC_FETCH_SITE'] ?? ''));
if ($site === 'same-origin' || $site === 'none') return true; // 'none' = typed, bookmark
if ($site === 'cross-site') return false;
// 'same-site' (a sibling subdomain) falls through: the Origin must prove it is this host.then Origin (or, without one, Referer), compared by host and port:
$host = strtolower((string)parse_url($origin, PHP_URL_HOST));
$port = parse_url($origin, PHP_URL_PORT);
// ...
return (bool)array_intersect($candidates, csrfOwnHosts())
|| (bool)array_intersect($candidates, csrfOwnHosts(true)); // + the configured public addressDecisions worth knowing:
-
The scheme is not compared. Behind a TLS-terminating proxy without
TRUST_PROXY_HTTPS, PHP seeshttpwhile the browser sayshttps. An attacker cannot make a victim's browser claim this host, so host + port is the real boundary. -
The configured public address (read by
publicBaseUrlSetting()) is accepted too, for proxies that rewriteHost. It costs a database query, so it is only consulted when the plainHostcomparison fails. -
Origin: null(sandboxed iframes, some redirects) is refused β never trusted. -
same-sitewith no Origin is refused: that is the sibling-subdomain attack. -
No header at all is
nulland allowed: real browsers send at least one on aPOST, so this is a non-browser client, and it has no victim cookie to abuse.
| Stored |
$_SESSION['csrf_token'] β 64 hex characters (bin2hex(random_bytes(32))) |
| Created | on first need, by csrfToken() β normally when the first page of a session is rendered |
| Sent as |
X-CSRF-Token header Β· _csrf_token form field Β· ?_csrf= query string |
| Compared |
hash_equals() β constant time |
| Renewed | at every sign-in |
| Required | on state-changing requests from a signed-in analyst (analyst_id) or portal user (ss_user_id) |
csrfToken() copes with sessions opened read_and_close by reopening them just long enough to save:
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION[CSRF_SESSION_KEY] = $token;
return $token;
}
// read_and_close: $_SESSION is populated but the file is shut. Reopen to save.
if (session_id() !== '' && !headers_sent()) {
@session_start();
$_SESSION[CSRF_SESSION_KEY] = $token;
session_write_close();
return $token;
}sessionPromoteToAuthenticated() β which every sign-in path calls: analyst login, portal login, both OTP steps, OIDC β now rotates the token:
function sessionPromoteToAuthenticated(bool $newIdentity = true): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
return;
}
if ($newIdentity) {
require_once __DIR__ . '/csrf.php';
csrfRotate();
}
// ...then the session id is regenerated as beforeWhy: regenerating the session id copies $_SESSION to the new id. Someone who planted a session before the victim signed in (session fixation) could have loaded a page with it and read the token. Rotating at sign-in makes that token worthless.
A password change passes false β it is the same person, and the page they changed it on must keep working:
sessionPromoteToAuthenticated(false); // same person: keep the CSRF tokencsrfStartPageInjection() runs from the guard for every request outside api/ and cron/ that has a session. It creates the token first (while headers can still be sent), then starts an output buffer that inserts the tags straight after the page's <head>:
$tags = csrfHeadTags(); // create the token NOW, while headers can still be sent
if ($tags === '') return;
ob_start(function (string $html) use ($tags) {
foreach (headers_list() as $h) {
if (stripos($h, 'content-type:') === 0 && stripos($h, 'text/html') === false) return $html;
}
if (str_contains($html, 'name="csrf-token"')) return $html; // already there
$n = 0;
$out = preg_replace_callback('/<head\b[^>]*>/i', fn($m) => $m[0] . $tags, $html, 1, $n);
return ($n && $out !== null) ? $out : $html;
});What every page ends up with:
<head><meta name="csrf-token" content="3f9cβ¦64 hexβ¦"><script src="/freeitsm-app/assets/js/csrf.js?v=1790966591"></script>
<meta charset="UTF-8">
β¦the page's own headβ¦- It goes first in
<head>, socsrf.jswrapsfetchbefore any other script can keep a copy of the original. - The
?v=is the file's modification time, so a change tocsrf.jsis picked up without anyone bumping a cache-buster. - A response that is not
text/html(a PDF, CSV, image or JSON) passes through untouched, as does anything without a<head>.
It reads the token from the meta tag and wraps the five ways a page sends data. Same-origin requests only β the token is never sent to another site.
| How the page sends | What csrf.js does |
|---|---|
fetch(url, {method: 'POST', β¦}) |
adds X-CSRF-Token (string URLs and Request objects; existing headers kept) |
XMLHttpRequest |
adds X-CSRF-Token in send(), unless the code already set it |
<form method="post"> |
adds a hidden _csrf_token on submit, and in form.submit() / requestSubmit(), which fire no submit event |
navigator.sendBeacon(url, data) |
appends ?_csrf= β a beacon cannot carry headers |
new EventSource(url) |
appends ?_csrf= β neither can a stream, and some streams write |
The fetch wrapper, for example:
var origFetch = window.fetch;
window.fetch = function (input, init) {
try {
var isReq = typeof Request !== 'undefined' && input instanceof Request;
var url = isReq ? input.url : String(input);
var method = String((init && init.method) || (isReq ? input.method : 'GET')).toUpperCase();
if (!SAFE[method] && sameOrigin(url)) {
init = Object.assign({}, init || {});
var h = new Headers(init.headers || (isReq ? input.headers : undefined));
if (!h.has(HEADER)) h.set(HEADER, TOKEN);
init.headers = h;
}
} catch (e) { /* never break a request over the token */ }
return origFetch.call(this, input, init);
};You can check it is active in any page's console:
window.__freeitsmCsrf // { token: "3f9cβ¦" } - undefined means this page has no token<?php
session_start(['read_and_close' => true]);
require_once '../../config.php';
require_once '../../includes/functions.php'; // β the origin and token checks
// Changes data: never on a GET, which a link on another site can send (csrf.php).
csrfRequireWriteMethod(); // β the one line you add
header('Content-Type: application/json');
if (!isset($_SESSION['analyst_id'])) { echo json_encode(['success' => false, 'error' => 'Not authenticated']); exit; }
requireModuleAccessJson('mymodule');
// ... your code. A forged POST never gets here.Rules:
-
Start the session before requiring
functions.php. If you start it afterwards,$_SESSIONis empty when the guard runs and the token check is skipped (the origin check still applies). -
If it changes anything, call
csrfRequireWriteMethod()before the first write. It answers405toGET,HEADandOPTIONSand does nothing otherwise. A read-only endpoint leaves it out. -
"It only reads a JSON body" is not an exemption. A link sends no body, every field falls back to its default, and an endpoint whose defaults pass validation saves them. That is how a link could switch module permissions to "most" and wipe the SLA settings: neither endpoint read the query string at all.
-
An endpoint that routes on an
actionβ reads onGET, writes onPOSTβ puts the call inside each write branch, not at the top.api/system/tika_settings.phpandapi/tickets/ticket_checklists.phpare the pattern. -
tests/get-must-not-write.phpfails the build for any file underapi/that contains write SQL and none ofcsrfRequireWriteMethod(),csrfRequireToken()or aREQUEST_METHODcheck. Its allow-list of harmless GET writes β a view counter, a download log, a read receipt β is in the file, each with its reason. -
If your endpoint deliberately does not load
functions.php, require the guard yourself:require_once __DIR__ . '/../../includes/request_guard.php'; // origin + CSRF checks (S4)
Any page that loads functions.php (directly, or through its module's header) and outputs an HTML document with a <head> gets the token and csrf.js automatically, and every way it sends data is covered.
If a page does not load functions.php β auth/force_password_change.php is the one example today β print the tags yourself, first thing in <head>:
<head>
<?php require_once __DIR__ . '/../includes/csrf.php'; echo csrfHeadTags(); ?>Nothing CSRF-specific. Follow the normal module pattern β pages that require functions.php, a <module>/includes/header.php, and API endpoints under api/<module>/ that start the session and require functions.php β and every page and endpoint is covered. Its write endpoints each call csrfRequireWriteMethod() (Β§8.1) β the guard test will tell you if one does not. Add checks to tests/csrf.php only if the module brings something unusual: a GET that writes, an exemption, or a page that skips functions.php.
Avoid it. A view counter, a "last opened" stamp or a download log is fine on a GET β add it to the allow-list in tests/get-must-not-write.php with the reason. Anything that changes what the system does is not. If you cannot avoid it β an EventSource stream that writes is GET by necessity β call csrfRequireToken() straight after functions.php:
session_start(['read_and_close' => true]);
require_once '../../includes/functions.php';
// A GET that writes: it must carry the session's CSRF token, which
// assets/js/csrf.js adds to EventSource URLs. (S4)
csrfRequireToken();On the page nothing changes: new EventSource(url) gets ?_csrf= from csrf.js. The five RFP Builder AI streams work this way.
Never make a delete or an approval reachable from a link. Read ids from the POST body only:
// POST only: a delete must never be reachable from a link (S4).
$id = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' ? (int)($_POST['id'] ?? 0) : 0;Use fetch as normal. No token header is needed β csrf.js adds it:
const r = await fetch(API_BASE + 'save_thing.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // still required - see request_guard.php
body: JSON.stringify({ id, name })
});Two things will not carry the token, and should not be used to call FreeITSM:
- a
fetchto an absolute URL on another host β the token is deliberately not sent cross-origin; - anything inside a Web Worker β it has no
csrf.js; send the request from the page instead.
Webhooks, agents and the REST API authenticate with a key or a signature and never use the login cookie, so they are unaffected β a request with no session cookie is not checked. You only need an exemption if a browser will call the endpoint cross-origin while carrying the session cookie, which is rare.
const CSRF_EXEMPT_PATHS = [
'api/v1/', // REST API: API key, CORS * by design
'api/external/', // inventory agents: API key
'api/messaging/webhook.php', // Telegram, Slack, WhatsApp, Twilio: signed
'api/calendar/graph_notify.php', // Microsoft Graph change notifications
'api/webchat/start.php', // the chat widget on customers' websites
'api/webchat/send.php',
'api/webchat/escalate.php',
'api/webchat/config.php',
'api/webchat/poll.php',
'cron/', // scheduled jobs over HTTP: ?token=
];Paths are relative to the app root; a trailing / means "everything below". To add one:
- Make sure the endpoint authenticates some other way β an API key, a signature, a token in the URL. Exempting an endpoint that relies on the session cookie re-opens CSRF for it.
- Add it with a comment saying how it authenticates.
- Run
php tests/csrf.php. Its last section checks every exempt path still exists, so a renamed file cannot leave a stale exemption behind.
What is not exempt: api/webchat/save_widget.php, delete_widget.php and get_widgets.php are analyst screens and are checked normally.
HTTP 403, with an X-CSRF-Refused: origin or X-CSRF-Refused: token header.
-
API (
api/β¦, or a JSON request):{"success": false, "code": "csrf_token", "error": "Your page is out of date or your session changed. Reload the page and try again."} {"success": false, "code": "csrf_origin", "error": "This request came from another website, so it was refused."}Existing screens already show
errorin their usual toast or message, so no front-end change was needed. -
A plain form post gets a one-line page with a Back link.
The commonest real csrf_token refusal is harmless: a tab opened before an upgrade has no token. Reloading fixes it, and the release notes say so.
php tests/csrf.php28 checks through the real web server. It needs the app at http://localhost/<folder>/, or set FREEITSM_URL. Every POST goes to the contract save with an empty body, which is refused for a missing contract number after the CSRF layer β so nothing is ever written, and "reached" versus "refused" is unambiguous.
| Section | Checks |
|---|---|
| The token | missing β refused; wrong β refused; right header β reached; right form field β reached |
| The origin |
Sec-Fetch-Site: cross-site, another Origin, a sibling subdomain and Origin: null β refused even with the token; this host β reached |
| Who is not checked | no cookie; signed out (origin checked, no token needed); the REST API; GET
|
| GETs that write | an RFP stream without ?_csrf= β refused; with it β reached |
| Pages | the meta tag carries this session's token, and csrf.js is the first thing in <head>
|
| Exemptions | every path in CSRF_EXEMPT_PATHS exists |
It is proven to bite: with the csrf lines removed from request_guard.php, 10 of the 28 fail.
php tests/get-must-not-write.phpFiles only β no database, no web server β so it runs in the Web exposure workflow on every push. It strips comments with PHP's own tokenizer (so neither a check nor SQL in a comment counts), tests itself against fixtures first, then reads every file under api/ that contains write SQL. A stale allow-list entry fails too. Run against the code before 3.15.1 it reports 277 writers with no check.
php tests/security-report-2026-10.php is the live half: it sends real GETs to the endpoints that were affected β signed out, so a missing fix still could not write β and one signed-in GET to a setting it snapshots first, with a positive control that the same save as a POST with the token still works.
Forge a signed-in session with a token you know (session files live in session.save_path):
T=$(php -r 'echo bin2hex(random_bytes(32));')
printf 'analyst_id|i:1;analyst_name|s:13:"Administrator";is_admin|i:1;csrf_token|s:64:"%s";' $T > c:/wamp64/tmp/sess_mytest
U=http://localhost/freeitsm-app/api/contracts/save_contract.php
# refused - no token
curl -s -b PHPSESSID=mytest -H 'Content-Type: application/json' -X POST -d '{}' $U
# {"success":false,"error":"Your page is out of date...","code":"csrf_token"}
# refused - right token, but from another site
curl -s -b PHPSESSID=mytest -H 'Content-Type: application/json' -H "X-CSRF-Token: $T" \
-H 'Origin: http://evil.example' -X POST -d '{}' $U
# {"success":false,"error":"This request came from another website...","code":"csrf_origin"}
# reached - right token, this origin
curl -s -b PHPSESSID=mytest -H 'Content-Type: application/json' -H "X-CSRF-Token: $T" \
-H 'Sec-Fetch-Site: same-origin' -X POST -d '{}' $U
# {"success":false,"error":"'contract_number' is required."}
rm c:/wamp64/tmp/sess_mytestOpen any FreeITSM page and the developer tools:
-
Elements β the first child of
<head>is<meta name="csrf-token" β¦>, followed bycsrf.js. -
Console β
window.__freeitsmCsrfshows{token: "β¦"}. -
Network β save something: the request headers include
X-CSRF-Token. -
Network β a refused request is a 403 with
X-CSRF-Refused.
Anything that forges a session and POSTs must now carry the token:
$csrf = bin2hex(random_bytes(32));
file_put_contents($sessFile, 'analyst_id|i:1;analyst_name|s:13:"Administrator";is_admin|i:1;csrf_token|s:64:"' . $csrf . '";');
// ...
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'X-CSRF-Token: ' . $csrf]);A browser harness that is not a FreeITSM page β the tests/*-live.html files, copied to the web root to run β has no token of its own. Borrow it from a real page it has loaded, as tests/report-packs-designer-live.html does:
let CSRF = '';
async function borrowToken() {
const f = document.getElementById('f');
f.src = ROOT + 'index.php';
await new Promise(r => { f.onload = r; });
const m = f.contentDocument.querySelector('meta[name="csrf-token"]');
CSRF = m ? m.getAttribute('content') : '';
}
// ...then send 'X-CSRF-Token': CSRF on the harness's own POSTs
β οΈ Watch clean-up steps. Three live tests "put things back" with a direct POST at the end. Without the token that POST is refused silently, and the test leaves its change in the data. They now send the token.
php tests/csrf.php
php tests/security-findings/run.php # includes the session-rotation checks
php tests/module-access-coverage.phpβ¦and crawl every page as a signed-in analyst, checking that each rendered page still carries name="csrf-token" and shows no PHP error. When 3.0.0 was built, 209 pages were crawled: every page that rendered carried the token, and none broke.
| Symptom | Cause | Fix |
|---|---|---|
| "Your page is out of dateβ¦" after an upgrade | The tab was opened before the upgrade, so it has no token | Reload the page |
| The same, on one page every time | The page has no token: it skips functions.php, or prints no <head>
|
echo csrfHeadTags(); in its <head> (Β§8.2) |
β¦from a fetch to an absolute URL |
The URL's host differs from the page's (127.0.0.1 vs localhost), so csrf.js treats it as another origin |
Use a relative URL or BASE_URL
|
| "This request came from another website" behind a proxy | The proxy rewrites Host, so the browser's Origin does not match |
Set the public base URL in System so that address is accepted |
| A webhook or integration is refused | It sends the session cookie and calls cross-origin | It should not use the cookie; if it must, exempt it |
| A test that used to pass now gets 403 | It forges a session and POSTs without the token | Β§11.4 |
A page's fetch is not stamped |
Something kept a copy of window.fetch before csrf.js loaded |
csrf.js must be first in <head> β injection guarantees it; a hand-written page must print csrfHeadTags() before any other script |
| File | Role |
|---|---|
includes/csrf.php |
Everything server-side: csrfEnforce(), csrfSameOrigin(), csrfToken(), csrfRotate(), csrfRequireToken(), csrfHeadTags(), csrfStartPageInjection(), CSRF_EXEMPT_PATHS
|
includes/request_guard.php |
Runs csrfEnforce() and csrfStartPageInjection() on every request that loads functions.php
|
includes/session_security.php |
sessionPromoteToAuthenticated(bool $newIdentity = true) rotates the token at sign-in |
assets/js/csrf.js |
Adds the token to fetch, XHR, forms, beacons and streams |
tests/csrf.php |
The 28-check test |
api/rfp-builder/generate_*.php, restyle_*.php, run_consolidation.php
|
GET streams that call csrfRequireToken()
|
See also: Security review response 2026-08 Β· Round three β Developer Guide Β· Issue #114 β API keys refused by our own guard Β· Developer tests β security