Smaller Gaps October 2026 - edmozley/freeitsm GitHub Wiki
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
The same two reports as A link could change data listed smaller findings alongside it. Each needs somebody who already has some access - or, for the debug tool, gives away only a map - and each lets them reach a little further than they should. They are written up together because they share a shape: a check that existed in one place and was missing from the place next to it.
Upgrading is the fix for all of them.
What was wrong. Software โ Settings โ API keys holds the keys inventory agents use to send in their software lists. Generating and deleting a key needed the API keys permission. Listing them โ which returns the keys themselves โ and switching one on or off needed only the Software module:
requireModuleAccessJson('software'); // get_apikeys.php: all it checked
requireCapabilityJson(Cap::SOFTWARE_API_KEYS); // generate_apikey.php: what it should matchSo any analyst with Software could copy a key and submit inventory as if they were an agent. (The keys are stored readable, which is an older choice and a separate matter; the missing check is what was reported.)
The fix. get_apikeys.php and toggle_apikey.php require the same permission as their siblings. The toggle was not in the report โ it was found by reading the other three files in the same folder.
What was wrong. A REST API key can be limited to some companies. POST /api/v1/users checked that limit when you named a company:
if (array_key_exists('tenant_id', $body) && ...) {
if (!apiKeyCanAccessTenant($conn, $apiKey, $tenantId)) apiError(403, ...);
} else {
$tenantId = resolveTenantForNewUser($conn, $email); // from the address's domain - no check
}Leave tenant_id out and the company came from the email address's domain โ with no check at all. A key limited to company A could create a requester in company B by using an address on B's domain.
The fix. The inferred company gets the same check as a named one. A key that cannot reach it gets a 403 saying how to go on: pass a company it can reach, or null to put the requester in triage.
What was wrong. Tickets โ Settings โ Privacy decides whether a requester sees correspondence on their ticket that did not involve them โ a supplier's reply, a colleague who was copied in. With it on hide, the ticket page left those messages out. But the portal's ticket lists โ My Tickets and a manager's Team tickets โ show a line of the newest message under each subject, and that line came straight from SQL:
(SELECT LEFT(e.body_preview, 160) FROM emails e
WHERE e.ticket_id = t.id ORDER BY e.received_datetime DESC LIMIT 1) AS previewโ the newest message, whoever it was from. So the first 160 characters of a supplier's reply showed in the list, under a ticket whose page hid it.
The fix. New portalVisiblePreviews() in includes/portal_visibility.php, beside the rule the ticket page uses. It reads the candidate messages for a page of tickets in one query and keeps, for each ticket, the newest one the ticket's requester is allowed to see โ the requester's, not the viewer's, so a manager's team list shows exactly what the requester would. A ticket with nothing visible shows no preview at all.
What was wrong. When a message arrives on a chat channel, FreeITSM looks for the person's open ticket so the conversation stays in one place. Telegram, Mattermost and Teams had each been taught, one at a time, to look only on the channel the message came in on โ a Telegram chat id is the same on every bot, so without it a message to company B's bot could land on company A's ticket. WhatsApp was left matching by phone number across every channel. A phone number is just as much the same on every company's WhatsApp number, so someone who contacted two companies through one FreeITSM could have a message filed on the other company's open ticket.
The code even said so, honestly:
WhatsApp is left exactly as it was: its sender rule predates this and is not part of the Telegram change.
The fix. Every channel type now matches within its own channel. Rather than adding WhatsApp to the list of exceptions, the list is gone โ the next channel type does not have to remember to join it. Every channel message has recorded its channel since the first channel shipped, so conversations already open keep threading; that was checked on a real install before the change, and no channel message was missing it.
What was wrong. Section 3's rule asks "is this message part of the requester's own correspondence?" โ and answered it like this:
$haystack = strtolower($from . ' ' . $to . ' ' . $cc);
return strpos($haystack, $needle) !== false;[email protected] is inside [email protected], and inside [email protected]. So with the privacy setting on, Bob saw โ on the ticket page, in the list preview, and as attachment downloads โ exactly the third-party mail it exists to withhold, whenever a colleague's or supplier's address happened to contain his.
The fix. portalAddressSet() pulls every address out of the stored value โ a bare address, a ; or , list, Name <a@b>, or the JSON some older rows hold โ and the requester's must be one of them, exactly.
What was wrong. The inbox records its own changes by posting to api/tickets/log_ticket_audit.php, which accepted any field name and any values from any analyst who could open the ticket. That history is not just a record: SLA timing is built from its Status rows (includes/sla.php). So a made-up "Status โ On Hold" row paused a ticket's SLA clock while the ticket itself stayed Open, and anything else could be written into the history a case report prints.
The fix. Only the thirteen fields the inbox actually logs are accepted. A Status row is accepted only when it names the ticket's real current status and differs from the last Status row โ so it can record a real transition and never invent one. ๐ The obvious fix, "never accept Status here", would have been worse: this is the only way the inbox's status changes reach the history, so it would have quietly stopped SLA from seeing any of them.
What was wrong. A webhook URL for Slack, Teams or Discord is a password in all but name, and the signing secret is one outright. The browser's workflow save encrypted both. POST and PATCH /workflows in the REST API did not, and nor did any form's action list โ so those sat readable in the database and in every backup.
The fix. webhookEncryptActions() in includes/webhook_delivery.php, used by every save route; the form designer decrypts for display, as the workflow editor already did. Delivery already decrypted when needed and passes plain text through, so nothing stored before stops working; it is encrypted the next time it is saved.
What was wrong. A catalogue form that needs approval fires catalogue_request.submitted, and the Workflows help said a rule on it "cannot step over an approval". It could: a Create a ticket rule on that event raised the ticket while the request was still pending, a rule on .rejected raised one for a refused request, and a rule on .approved raised a second ticket beside the one the approval had already raised.
The fix. catalogueRaiseTicketFromSubmissionId() refuses a pending or rejected request, and returns the ticket an approved request already has. The help text now says what is true.
What was wrong. Every upload in FreeITSM goes through includes/uploads.php, which checks the file's extension and then its actual contents. The portal's screen-recording upload had its own rules instead: if the browser said the file was video/mp4, nothing was checked at all. The files were stored out of web reach, so this was weak checking rather than a way in โ but it is exactly what SECURITY.md asks to hear about.
The fix. uploadStoreFile() with the two video types the recorder produces, and recordings are served with X-Content-Type-Options: nosniff.
What was wrong. Every debug tool under System โ Debug Tools begins by checking for an administrator โ except D005, Endpoint permission coverage, which had no check at all. Anybody could fetch its list of every endpoint, ranked by how weakly each is guarded. It prints no secrets and the source is public anyway, but it is a ready-made "where to look" list.
The fix. The same administrator block as the other tools โ and a test now fails for any debug tool without one.
| File | Change | |
|---|---|---|
| ๐ฆ |
api/software/get_apikeys.php, api/software/toggle_apikey.php
|
require the API keys permission |
| ๐ฉ | api/v1/resources/users.php |
check the inferred company |
| ๐จ | includes/portal_visibility.php |
portalVisiblePreviews() |
| ๐จ |
api/self-service/get_tickets.php, api/self-service/get_team_tickets.php
|
use it |
| ๐ง | includes/messaging/ingest.php |
findOpenChannelTicket() scopes every channel type |
| ๐จ | includes/portal_visibility.php |
portalAddressSet() - an exact address match |
| ๐ฆ | api/tickets/log_ticket_audit.php |
only the fields the inbox logs; Status only as a real transition |
| ๐ฉ |
includes/webhook_delivery.php, api/workflow/save.php, api/v1/resources/workflows.php, includes/services/forms.php, api/forms/get_form.php
|
encrypted on every route |
| ๐ง |
includes/catalogue_approvals.php, lang/en/workflow.php
|
never past an approval; the help corrected |
| ๐จ |
api/self-service/upload_recording.php, api/self-service/get_recording.php
|
through uploads.php; nosniff
|
| ๐ฆ | api/system/debug-tools/D005_endpoint_permissions.php |
the administrator gate |
tests/security-report-2026-10.php, sections 4 to 7, against the real database and web server, with temporary data it removes afterwards:
- API keys: an analyst with Software but not the API keys permission is refused by both endpoints.
-
REST API: with two temporary company domains and a temporary key limited to one company, an address on the other company's domain is refused with
403and no requester is created; naming that company is refused as before; an address on the key's own company still creates the requester (201), in the right company. - Portal preview: with hide on, a ticket where a supplier replied last shows the requester's message; a ticket where the requester replied last shows theirs (the positive control); a ticket with only supplier mail shows nothing; no marked supplier text appears anywhere in the result.
- WhatsApp: the open ticket is found from the same channel and not from another; Telegram's existing rule still holds.
tests/security-report-2026-10-part2.php covers sections 5 to 10: the bob / jimbob / [email protected] cases and the positive controls in each stored address format (15); an invented field and a non-matching Status row refused, a real one recorded once (19); encryption, idempotence and the round trip (20); pending and rejected requests raising nothing (13); a text file declared as video/mp4 refused and nothing stored (17); D005 refusing someone signed out, and every debug tool carrying the gate (21).
Upgrade. If you keep software inventory API keys and want to be sure, generate new ones and switch the old ones off โ the permission now decides who can see them.
Related: A link could change data ยท Access outlived its removal ยท Bugs resolved