Skip to content

Issue 114 API Keys Refused By Our Own Guard

Ed Mozley edited this page Aug 28, 2026 · 1 revision

The API keys screen was refused by our own CSRF guard (issue #114)

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.


1. What you saw

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.


2. What was actually happening

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.

Why it refuses it

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.

The timeline is the whole diagnosis

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.


3. Was it only three?

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.


4. The fix

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.

The part that matters more

request_guard.php carried this in its header comment:

Our own front end sends application/json and 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.


5. πŸ“ The files involved

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.

πŸ—ƒ Already correct

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.

6. How it was verified

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


7. What this means for you

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.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally