Skip to content

Smaller Gaps October 2026

Ed Mozley edited this page Oct 24, 2026 · 1 revision

Security: smaller gaps from the same reports

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.

What Who could do it Modules
1 Software API keys could be listed by any Software analyst an analyst with the Software module Software
2 A company-limited API key could create a requester in another company holder of a REST API key REST API
3 The portal list showed a preview of mail the privacy setting hides a requester Self-service
4 WhatsApp messages could thread into another company's ticket anyone who messages two companies Messaging
5 Portal privacy matched the requester's address as a substring a requester whose address is inside another's Self-service
6 Ticket history could be written freely - including the rows SLA timing reads an analyst with the ticket Tickets
7 Webhook URLs and secrets were stored readable outside the browser save anyone with the database or a backup Workflows, Forms
8 A workflow could raise a ticket for a request still awaiting approval a workflow author, by mistake Forms, Workflows
9 Portal recordings skipped the upload rules a portal user Self-service
10 One debug tool needed no sign-in anyone System

Upgrading is the fix for all of them.


1. Software API keys could be listed by any Software analyst

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 match

So 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.

2. A company-limited API key could create a requester in another company

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.

3. The portal list showed a preview of mail the privacy setting hides

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.

4. WhatsApp messages could thread into another company's ticket

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.


5. Portal privacy matched the requester's address as a substring

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;

bob@acme.com is inside jimbob@acme.com, and inside bob@acme.com.au. 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.

6. Ticket history could be written freely

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.

7. Webhook URLs and secrets were stored readable outside the browser save

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.

8. A workflow could raise a ticket for a request still awaiting approval

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.

9. Portal recordings skipped the upload rules

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.

10. One debug tool needed no sign-in

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.


πŸ“ Files changed

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

Tested

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 403 and 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 / bob@acme.com.au 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).

Do I need to do anything?

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

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally