-
Notifications
You must be signed in to change notification settings - Fork 17
Issue 114 API Keys Refused By Our Own Guard
Every button on System β API answered with the same message - "Unsupported Content-Type. Send application/json." - and nothing was saved. Creating a key, editing one, switching one off and deleting one were all affected.
Reported in issue #114 by jbournestreamsystems, who diagnosed it correctly and attached a patch for the three lines.
Fixed in 07c06f71, released as update #1258.
The three-line fix is the small half. The interesting half is that FreeITSM broke this itself, seven weeks after the screen was built, and then explained in a comment why it could not have.
Open System β API, click Add key, give it a name and tick a permission - the reporter and I both used read on Tickets and nothing else - and press Save.
An alert appears reading "Unsupported Content-Type. Send application/json." No key is created. The same message answers Save on an existing key, the enable/disable toggle, and Delete.
Nothing appears in the browser console, because nothing went wrong in the browser. The server answered, and the answer was a refusal.
The screen makes four requests. One is a GET to list the keys, and it worked fine - which is why the list on screen was always correct, and only the buttons failed.
The other three sent a body with no headers at all:
// before
const res = await fetch(API + 'delete_key.php', {method: 'POST', body: JSON.stringify({id: id})});When you do not label a request, the browser labels it for you. Given a string body and no Content-Type, fetch sends:
Content-Type: text/plain;charset=UTF-8
And text/plain is the one content type FreeITSM deliberately refuses.
includes/request_guard.php exists because FreeITSM has no CSRF token mechanism. A JSON endpoint gets partial protection from the browser's preflight: a cross-site fetch declaring application/json is not a "simple" request, so the browser asks permission first and never sends the real one. But this is simple, needs no preflight, and arrives as valid JSON with the victim's cookie attached:
<form method="POST" action="https://desk.example/api/tickets/save_user.php"
enctype="text/plain">
<input name='{"id":1,"password":"x"}' value='' >
</form>So the guard refuses text/plain outright - correctly. The guard is not the bug. The bug is that one screen in FreeITSM was, by omission, making its requests in exactly the shape of that attack.
2026-07-02 (c35f0050) |
The API keys screen is built. The three POSTs are written without the header. They work perfectly. |
2026-08-07 (37d8fdb8) |
Security hardening F7 adds the guard. Those three requests start being refused. |
| 2026-08-27 | Reported. |
So this was not a screen that never worked. It worked for five weeks, and create, edit, enable/disable and delete had been dead for three by the time anybody said so - which for a self-hosted product means anyone setting up an integration in that window simply could not.
Worth asking, because "three" came from the reporter rather than from a search. Every fetch in the tree was scanned for a body sent with no Content-Type:
| Found | Verdict |
|---|---|
| 15 call sites | |
| 11 of them |
Correct. The body is FormData. A multipart body must not declare a content type, because the browser has to append the boundary marker itself. |
| 1 of them |
False positive. A GET with no body at all. |
| 3 of them | The API keys screen. |
So the report was complete, and the count of three is now a verified number rather than a reported one.
The three call sites say what they are sending:
// after
const res = await fetch(API + 'delete_key.php', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({id: id})});Written inline rather than wrapped in a helper, because that is what the other 481 call sites in FreeITSM already do, and one screen with a bespoke convention is how the next person gets caught out.
request_guard.php carried this in its header comment:
Our own front end sends
application/jsonand nothing else (149 fetch call sites, all of them).
That was untrue on the day it was written. These three had never sent it.
A false comment is worse than no comment, because it answers the question a reader came to ask. Anyone wondering "could this guard break our own screens?" read that line and stopped. The guard shipped, one screen went dark, and the note explaining that this could not happen sat directly above the code doing it.
So the prose claim is gone, replaced by a tested one. tests/security-findings/run.php now reads every .js and .php file in the tree, finds every fetch() that sends a JSON.stringify body, and fails if any of them omits the header:
[PASS] every fetch() in the tree that sends a JSON body declares application/json
FormData bodies are deliberately not flagged - those are correct without a header - and vendored code is excluded.
The suite already checked this guard from the attacker's side, asserting that a text/plain POST gets a 415. What it never checked was the other side: that our own front end had stopped sending one. That missing half is the gap this bug shipped through, and it is now closed.
| File | What changed |
|---|---|
system/api/index.php |
Three POSTs declare application/json. |
includes/request_guard.php |
The false invariant is replaced with a warning that this guard breaks callers who forget the header, and a pointer to the test that now enforces it. |
tests/security-findings/run.php |
A tree-wide scan, plus a two-way self-test of the scanner. |
| The guard itself | Refusing text/plain is right and stays. Relaxing it to fix this would have reopened the hole it was built to close. |
| The four endpoints |
create_key.php, update_key.php, delete_key.php and list_keys.php were never at fault. Sent a correctly labelled request, they always worked. |
The 11 FormData uploads |
Correct with no content type. |
Reproduced before anything was touched, against the running application, using the reporter's own case - a key with read on Tickets and nothing else:
| Request | Result |
|---|---|
create_key.php as text/plain
|
HTTP 415, "Unsupported Content-Type. Send application/json." |
update_key.php as text/plain
|
HTTP 415, same message |
delete_key.php as text/plain
|
HTTP 415, same message |
Positive control: byte-identical create_key.php as application/json
|
HTTP 200, key created |
The header is the only variable between the last two rows. The key created by the control was then deleted through the same endpoint, which incidentally proved the delete path as well.
The scanner was proved in both directions before being trusted, since a check that silently matches nothing is indistinguishable from a clean pass:
- Shown a
fetchwith a JSON body and no header, it must object. It does. - Shown the same call with the header, it must not. It does not.
-
Differentially, against the real tree: with the fix reverted on one line it reports
system/api/index.php:421; with the fix restored it passes.
And what the browser actually receives was checked, not just the source file - the rendered page was fetched with a logged-in session, all three call sites carry the header in the served output, and all ten of its script blocks parse.
Creating, editing, disabling and deleting API keys works again. Nothing needs to be reconfigured, and no existing key was affected - keys already issued went on working throughout, because the REST API itself was never involved. Only the screen for managing them was.
If you tried to create a key during those three weeks and gave up, nothing was half-created; the request never reached the point of writing anything.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- β¨οΈ 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: 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
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- 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: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- 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
- β³ 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)