Repository navigation
Smaller Gaps October 2026
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;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.
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 / 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).
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 β 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
- β³ π 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
- β³ π Connected assets
- β³ π Asset review
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π 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
- β³ π 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