Skip to content

CSRF Protection Developer Guide

Ed Mozley edited this page Oct 2, 2026 · 2 revisions

CSRF protection β€” Developer Guide

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.


Contents

  1. What CSRF is, and what FreeITSM had before
  2. The design: two checks, both central
  3. Request lifecycle, step by step
  4. The origin check
  5. The token
  6. How pages get the token
  7. The browser side β€” csrf.js
  8. Building new pages, endpoints and modules
  9. The exceptions, and how to add one
  10. When a request is refused
  11. Testing that it works
  12. Troubleshooting
  13. Files

1. What CSRF is, and what FreeITSM had before

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.

2. The design: two checks, both central

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 fetch calls". 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 that writes must protect itself β€” see Β§8.4.

3. Request lifecycle, step by step

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.php

and 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

4. The origin check

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 address

Decisions worth knowing:

  • The scheme is not compared. Behind a TLS-terminating proxy without TRUST_PROXY_HTTPS, PHP sees http while the browser says https. 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 rewrite Host. It costs a database query, so it is only consulted when the plain Host comparison fails.
  • Origin: null (sandboxed iframes, some redirects) is refused β€” never trusted.
  • same-site with no Origin is refused: that is the sibling-subdomain attack.
  • No header at all is null and allowed: real browsers send at least one on a POST, so this is a non-browser client, and it has no victim cookie to abuse.

5. The token

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;
}

Renewal at sign-in

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 before

Why: 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 token

6. How pages get the token

csrfStartPageInjection() 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 (strpos($html, 'name="csrf-token"') !== false) 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>, so csrf.js wraps fetch before any other script can keep a copy of the original.
  • The ?v= is the file's modification time, so a change to csrf.js is 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>.

7. The browser side β€” csrf.js

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

8. Building new pages, endpoints and modules

8.1 A new API endpoint β€” nothing to do

<?php
session_start(['read_and_close' => true]);
require_once '../../config.php';
require_once '../../includes/functions.php';   // ← this is the protection

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, $_SESSION is empty when the guard runs and the token check is skipped (the origin check still applies).

  • Change data on POST (or PUT, PATCH, DELETE), never on GET. A GET is not checked.

  • If your endpoint deliberately does not load functions.php, require the guard yourself:

    require_once __DIR__ . '/../../includes/request_guard.php';   // origin + CSRF checks (S4)

8.2 A new page β€” nothing to do

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(); ?>

8.3 A new module

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. 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.

8.4 A GET that changes something

Avoid it. If you cannot β€” 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;

8.5 Sending data from JavaScript

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 fetch to 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.

8.6 Endpoints that a third party calls

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.

9. The exceptions, and how to add one

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:

  1. 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.
  2. Add it with a comment saying how it authenticates.
  3. 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.

10. When a request is refused

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 error in 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.

11. Testing that it works

11.1 The automated test

php tests/csrf.php

28 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.

11.2 By hand, with curl

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_mytest

11.3 In the browser

Open any FreeITSM page and the developer tools:

  1. Elements β€” the first child of <head> is <meta name="csrf-token" …>, followed by csrf.js.
  2. Console β€” window.__freeitsmCsrf shows {token: "…"}.
  3. Network β€” save something: the request headers include X-CSRF-Token.
  4. Network β€” a refused request is a 403 with X-CSRF-Refused.

11.4 Your own tests and tools

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.

11.5 After changing anything here

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.

12. Troubleshooting

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

13. Files

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

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally