Repository navigation
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.
- 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 that writes must protect itself β see Β§8.4.
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 (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>, 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'; // β 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,$_SESSIONis empty when the guard runs and the token check is skipped (the origin check still applies). -
Change data on
POST(orPUT,PATCH,DELETE), never onGET. AGETis 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)
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. 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. 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;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.
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
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96