Repository navigation
A Link Could Change Data
Fixed in 3.15.1 Β· Reported privately by a security researcher, who asked not to be named Β· Present in every release up to and including 3.15.0 Β· Modules: Tickets, System, Assets, Software, Knowledge, CMDB, Messaging, My Account
FreeITSM refuses a request from another website that tries to change something β "save this setting", "delete that" β unless it carries a secret only a FreeITSM page could know. But it only asked for that secret on POST, PUT, PATCH and DELETE, on the reasonable rule that a GET β the kind of request a plain link makes β never changes anything.
About twenty endpoints broke that rule. So a link, on any web page or in any email, could make whoever clicked it β while signed in to FreeITSM β do something they never chose to:
- empty their company's ticket trash, permanently, with every attachment, note and history row;
- as an administrator, switch module permissions from "least" to "most", widening what every analyst can open;
- wipe the SLA settings and turn off the warning and breach notifications;
- blank the branding, clear the public base URL (which breaks the ticket links in outgoing email), reset asset label and tag numbering settings, switch Apache Tika off;
- as an administrator, run Database Verification, or delete a ticket through a debug tool.
Who could do it: anybody who could get a signed-in analyst to click a link, or load a page with an image pointed at one. They needed no account. They could not see anything β the response goes to the victim's browser, not to them β so this was about damage, not theft. Each action needed a victim with the right permission: the trash needed the Tickets module, the SLA settings needed the SLA permission, the module permission switch needed an administrator.
Upgrading is the fix. Nothing else needs doing.
Every request with the session cookie runs through csrfEnforce() in includes/csrf.php, which checks where it came from and that it carries the session's token. It begins:
if (PHP_SAPI === 'cli' || !csrfIsStateChanging()) return;and csrfIsStateChanging() is true for anything except GET, HEAD and OPTIONS. So a GET is never checked β on purpose, and the file said so:
NOT CHECKED: GET/HEAD/OPTIONS (they must not change anything β¦)
The session cookie is SameSite=Lax, and Lax cookies are sent on a top-level GET from another site β that is what lets a link in an email open a ticket without signing in again. Put the two together and any endpoint that writes on a GET is reachable from a link, with the victim's session, past both checks.
api/tickets/empty_trash.php is the plainest case. It takes no input at all β it empties the trash of the active company β and it never looked at the request method. The app's own Empty trash button sends a POST. Nothing stopped a GET.
The more interesting finding came from the sweep, not the report. Most of FreeITSM's save endpoints read their input from a JSON body:
$input = json_decode(file_get_contents('php://input'), true);
$mode = ($input['mode'] ?? '') === 'least' ? 'least' : 'most';
// ... save $modeThat looks safe from a link β a link cannot send a body. But it doesn't need to. With no body, $input is null, every field falls back to its default, and if the defaults pass validation, they are saved. save_module_permission_mode.php saves most. save_sla_global_settings.php saves seven empty SLA settings. save_branding.php saves six empty header and footer slots. None of them read the query string at all, which is exactly why a scan for "endpoints that read $_GET and write" would never have found them.
The report named empty_trash.php, debug tool D002, Database Verification and the messaging channel test, and said plainly that its scan was short and not complete. Every one was real.
The sweep looked at all 339 files under api/ that contain write SQL, in three groups: those that read the query string, those that read nothing, and the 260 that read only a body. The body group was read file by file, with one question: with an empty body, does this still reach a write? Twelve of the 260 did. Then the 51 files that already checked the method were read again, because checking the method is not the same as checking it in the right place β and one of them wasn't.
| Endpoint | What a link did | The victim needed |
|---|---|---|
api/tickets/empty_trash.php |
permanently deleted the company's trashed tickets | Tickets |
api/system/debug-tools/D002_delete_ticket.php |
deleted the ticket named in ?ref=
|
administrator |
api/system/db_verify.php |
ran Database Verification | administrator |
api/messaging/test_channel.php |
?mode=simulate created and deleted a test ticket and requester |
messaging permission |
api/system/save_module_permission_mode.php |
switched "least" to "most" | administrator |
api/system/save_branding.php |
blanked the header and footer text | administrator |
api/tickets/save_sla_global_settings.php |
wiped seven SLA settings, notifications off | SLA permission |
api/tickets/save_public_base_url.php |
cleared the public base URL β [ticket_url] links in email break |
email templates permission |
api/messaging/save_base_url.php |
cleared the messaging webhook base URL | messaging permission |
api/assets/save_asset_label_settings.php |
reset the label layout | label permission |
api/assets/save_asset_tag_settings.php |
turned tag numbering off, reset the format | tag permission |
api/cmdb/save_ai_settings.php |
cleared the CMDB AI instructions | CMDB AI permission |
api/knowledge/permission_model.php |
?action=set forced the folder permission model to "containers" |
Knowledge manage |
api/system/tika_settings.php |
?action=save switched Apache Tika off |
administrator |
api/myaccount/save_profile.php |
wiped the victim's job title, department and phone numbers | any analyst |
api/myaccount/toggle_trust_device.php |
flipped trusted devices, clearing them | any analyst |
api/software/generate_apikey.php |
created an unwanted live inventory API key | API keys permission |
api/assets/get_vcenter.php |
ran a vCenter sync | Assets |
api/tickets/check_mailbox_email.php |
checked a mailbox early | Tickets |
api/tickets/set_base_url_warning_dismissed.php |
brought back a dismissed warning | email templates permission |
api/system/debug-tools/D001_demo_core_import.php |
imported the demo data - which includes working analyst accounts with a password published in the repository, so on an install that had never loaded it, one click gave an outsider a way in. The worst of the twenty-two | administrator |
api/system/debug-tools/D007_search_corpus.php |
wrote and removed a probe row (harmless) | administrator |
knowledge/permission_model.php is the one worth studying. It did look at the method β and used it to decide where to read the action from: the query string on a GET, the body otherwise. So ?action=set on a GET went straight to the save, with an empty input whose default was "containers". A method check that routes rather than refuses is not a protection.
One rule, stated where it can be enforced: an endpoint that writes refuses a GET. includes/csrf.php gains:
function csrfRequireWriteMethod(): void
{
if (PHP_SAPI === 'cli' || csrfIsStateChanging()) return;
http_response_code(405);
header('Allow: POST');
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'This request must be sent as a POST.']);
exit;
}and every endpoint under api/ that writes calls it before its first write β 276 files, not just the twenty-two. The narrow fix would have closed today's holes and left the rule exactly as unenforced as it was; the next save endpoint whose defaults happen to validate would reopen it. Where an endpoint reads on GET and writes on POST (Tika, checklists, the Knowledge permission model), the call goes in the write branch, so the reads keep working.
Three screens sent a GET to an endpoint that writes and now send a POST: System β Database Verification, the setup wizard's schema step, and the Servers page's vCenter sync. The debug tool registry marks D001, D002 and D007 as POST, which the tool page already supported.
A handful of GETs keep writing, deliberately, and are listed with their reasons in the guard test: a download log, two article view counters, marking an email read, and the single sign-on callback (which checks its own state against the session).
The tempting fix is one line in csrfEnforce(): check GETs too. It cannot work β a link to an attachment, a ticket or a download is a cross-site GET with the cookie, and must keep working. The browser's Sec-Fetch-Site header cannot tell "a link that opens a ticket" from "a link that empties the trash" either: both are top-level navigations from another site. Only the endpoint knows whether it writes, so the endpoint has to say so β and a test has to make sure it does.
The developer guide had the rule all along: "Change data on POST β¦, never on GET. A GET is not checked." It also had, three sections earlier, the reason that rule was never going to hold:
a protection that every future endpoint has to remember is not a protection β the first one somebody forgets is the hole.
So the rule is now a test. tests/get-must-not-write.php reads every file under api/, strips comments with PHP's own tokenizer, and fails for any file that contains write SQL and none of csrfRequireWriteMethod(), csrfRequireToken() or a REQUEST_METHOD check. It runs on every push, in the Web exposure workflow.
| File | Change | |
|---|---|---|
| π¦ | includes/csrf.php |
csrfRequireWriteMethod(); the header now says the GET rule is enforced, and where |
| π© | 276 files under api/
|
one call each, before the first write (three lines, a comment and the call) |
| π¨ |
api/system/tika_settings.php, api/tickets/ticket_checklists.php, api/knowledge/permission_model.php
|
the call inside the write branches only |
| π§ |
system/db-verify/index.php, setup/index.php, asset-management/servers/index.php
|
send POST
|
| π§ | system/debug-tools/includes/tools.php |
D001, D002, D007 run as POST
|
| πͺ |
tests/get-must-not-write.php, .github/workflows/web-exposure.yml
|
the guard, on every push |
| πͺ | tests/security-report-2026-10.php |
the live checks |
- The guard bites. Against the code as it was in 3.15.0 it reports 277 writers with no check; against 3.15.1, none. It tests itself first against fixtures, including a check that appears only in a comment (must fail) and SQL that appears only in a comment (must not count).
-
Live, through the real web server (
tests/security-report-2026-10.php): aGETto fifteen of the endpoints above is refused. π These run signed out on purpose β if a fix were missing, a signed-out request still could not write, so the test can never empty a real trash. One signed-in check runs as an administrator, against the module permission mode, which it snapshots first: theGETis refused with 405 and the value is unchanged; the same save as aPOSTwith the token still works (the positive control); and aPOSTwithout the token is still refused (the CSRF check is still there). - A read on
GETstill works (Tika settings,action=get), and debug tool D007 runs as thePOSTthe tool page now sends. - The rest of the suite β 32 test files, including
tests/csrf.phpβ passes unchanged.
Not run live: Database Verification and D002 as a POST. One migrates the schema and the other deletes a ticket, and the change to each was the method alone.
Upgrade. Nothing to configure. If you have your own scripts calling FreeITSM's internal endpoints (not the REST API under /api/v1/, which is unaffected) and any of them use GET to save something, they will now get 405 and must use POST.
-
An endpoint that changes anything calls
csrfRequireWriteMethod()before it writes. That is the whole rule. See CSRF protection β Developer Guide Β§8.1. - "It only reads a JSON body" is not an exemption. An empty body is a valid input; ask what your defaults save.
-
A method check must refuse, not route. If you read the action from the query string on a
GET, put the call in each write branch. - A
GETthat genuinely must write β a view counter β goes on the allow-list intests/get-must-not-write.php, with its reason.
Related: CSRF protection β Developer Guide Β· Access outlived its removal Β· Smaller gaps from the same reports Β· Bugs resolved
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- 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: Files
- β³ π 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
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π« 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: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Ticket workspace - design (PR #206)
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ 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
- β³ π Case reports - a ticket and its work as one PDF
- β³ π 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
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Linked configuration item
- β³ π Connected assets
- β³ π Asset review
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ πͺ Hyper-V and Failover Clusters
- β³ π 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
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
- 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
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ 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