Repository navigation
Knowledge Writes Ignored Permissions
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 Β· Modules: Knowledge, REST API
Knowledge lets you restrict a folder, or a single article, to named analysts and teams. Somebody left off the list does not see it in the tree, in search, or by opening it.
But the actions that change an article only checked the article's company β not its folder or its own access list. So an analyst who could not read an article could still, knowing its id:
- edit it β title and body;
- move it into another folder, or change its audience (one at a time, or with the bulk audience change);
- archive it, restore it from the recycle bin, or purge it for good.
And anybody could file a new article into a folder they could not read.
Through the REST API, the same gap had a second half: creating, updating or restoring an article returned the whole article, body included, without asking whether the key was allowed to read it.
Who could do it: an analyst with the Knowledge module, or a REST API key that can write articles. They needed the article's id β ids are sequential, so that is a matter of counting. They could not see a restricted article's text through the Knowledge page; they could damage or delete it, and through the REST API they could read it in the response.
It only matters if you use access rules. An install with no restricted folders, no restricted articles and no access lists has nothing to bypass β every analyst may read every article in their company.
Upgrading is the fix. Nothing else needs doing.
Every write in includes/services/knowledge.php β saveArticle(), archiveArticle(), restoreArticle(), purgeArticle() β begins with the same guard, assertCanAccessArticle(). It was:
private static function assertCanAccessArticle(PDO $conn, ActorContext $ctx, int $articleId): void
{
$scope = $ctx->companyScope;
if (!is_array($scope)) return; // all companies
$stmt = $conn->prepare("SELECT tenant_id FROM knowledge_articles WHERE id = ?");
$stmt->execute([$articleId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return; // loadArticleRow raises the 404
if ($row['tenant_id'] === null) return; // shared with everyone
if (!in_array((int)$row['tenant_id'], array_map('intval', $scope), true)) {
throw new ServiceError('forbidden', 'forbidden', 'That article belongs to a company you do not have access to.');
}
}Its name says "can access article". What it checks is "same company". Those were the same question before folder permissions existed, and the guard was never revisited when they arrived. Note the first early return: an analyst who can reach all companies β the commonest kind on a single-company install β was waved through before anything was looked at.
The bulk audience change (api/knowledge/knowledge_bulk_audience.php) is a loop over saveArticle(), so it inherited the same gap.
When a save named a folder, resolveFolderId() checked only this:
$st = $conn->prepare("SELECT 1 FROM knowledge_folders WHERE id = ?");
$st->execute([$id]);
if (!$st->fetchColumn()) {
throw new ServiceError('validation', 'invalid_field', "'folder_id' is not a known folder.");
}β whether the folder exists, not whether you may read it.
The folder tree's drag-and-drop, api/knowledge/folders.php, moves an article between folders too, and it was right all along. It checks both ends: the folder you are dropping into, and the article you are dragging.
requireFolderAccess($conn, $analystId, $folder);
$viewer = KnowledgeViewer::forAnalyst($conn, $analystId);
if (!knowledgeCanRead($conn, $viewer, $articleId, ['lifecycle' => 'any'])) {
echo json_encode(['success' => false, 'error' => 'Article not found']);
return;
}So "move this article" was checked when you dragged it, and not checked when you changed its folder in the editor. Same action, two doors, one locked.
api/v1/resources/knowledge.php loads an article through apiLoadArticle(), which already runs the key's read check β when it is given the key:
if ($apiKey !== null
&& !knowledgeCanRead($conn, KnowledgeViewer::forApiKey($conn, $apiKey), $articleId, ['lifecycle' => 'any'])) {
apiError(404, 'not_found', 'Article not found.');
}Create, update and restore called it without one:
apiRespond(apiSerializeArticle($conn, apiLoadArticle($conn, $res['id']), true), 201);so the check was skipped and the full article came back.
assertCanAccessArticle() keeps the company check, and adds the read rule:
$scope = $ctx->companyScope;
if (is_array($scope) && $row['tenant_id'] !== null // null = shared with everyone
&& !in_array((int)$row['tenant_id'], array_map('intval', $scope), true)) {
throw new ServiceError('forbidden', 'forbidden', 'That article belongs to a company you do not have access to.');
}
$viewer = self::viewerFor($conn, $ctx);
if ($viewer && !knowledgeCanRead($conn, $viewer, $articleId, ['lifecycle' => 'any'])) {
throw new ServiceError('not_found', 'not_found', 'Article not found.');
}The early "all companies" return is gone, so an all-companies analyst now reaches the read check too. knowledgeCanRead() is the function that decides what the Knowledge page shows, so a write is now allowed exactly when a read would be. lifecycle => any is there because restore and purge act on archived articles, which the default read would not include.
π The refusal says "Article not found." β the same words as an id that does not exist. A different message would tell a caller which ids are real articles they are not allowed to see.
knowledgeCanRead() needs to know who is asking, as a KnowledgeViewer. The service is handed an ActorContext, so it builds one:
private static function viewerFor(PDO $conn, ActorContext $ctx): ?KnowledgeViewer
{
if ($ctx->actorId <= 0) return null;
return $ctx->source === 'api'
? KnowledgeViewer::forApiKey($conn, ['analyst_id' => $ctx->actorId, 'company_scope' => $ctx->companyScope])
: KnowledgeViewer::forAnalyst($conn, $ctx->actorId);
}An analyst is judged as the analyst; an API key by its own company scope; the system actor (internal callers, with no analyst) is not restricted, as before.
Holders of the Knowledge manage permission keep the override they already had: knowledgeCanRead() lets them in and writes an admin_override row to the article's history, which is what keeps a restricted folder recoverable when its only member leaves.
resolveFolderId() now takes the actor, and treats an unreadable folder exactly like a missing one:
$viewer = self::viewerFor($conn, $ctx);
if (!$st->fetchColumn() || ($viewer && !knowledgeFolderIsReadable($conn, $viewer, $id))) {
throw new ServiceError('validation', 'invalid_field', "'folder_id' is not a known folder.");
}knowledgeFolderIsReadable() used to be a private helper inside api/knowledge/folders.php. It has moved, unchanged, into includes/knowledge/visibility.php, beside the rest of the read rules, and both the folder tree and the article service now call the same copy. Writing a second, similar check into the service would have fixed today's hole and set up the next one: two rules for "may this person reach this folder" drift, and the copy that falls behind is the one nobody is looking at.
Create, update and restore now hand apiLoadArticle() the key:
apiRespond(apiSerializeArticle($conn, apiLoadArticle($conn, $res['id'], $apiKey), true), 201);| File | Change | |
|---|---|---|
| π¦ | includes/services/knowledge.php |
assertCanAccessArticle() also requires knowledgeCanRead(); new viewerFor(); resolveFolderId() checks the folder is readable |
| π© | includes/knowledge/visibility.php |
knowledgeFolderIsReadable(), moved here |
| π¨ | api/knowledge/folders.php |
its copy removed; uses the shared one |
| π§ | api/v1/resources/knowledge.php |
create, update and restore pass $apiKey to apiLoadArticle()
|
| πͺ | tests/security-report-2026-10-part2.php |
section 8 |
tests/security-report-2026-10-part2.php, section 8, against the real database, with a temporary folder, article and two analysts it removes afterwards. The folder is given an access list naming one analyst (the owner); the other (denied) is left off.
- Setup holds: the denied analyst cannot read the article; the owner can. π The second line is the positive control β without it, a broken access list that denied everybody would make every refusal below look like a pass.
-
Every write is refused for the denied analyst, by calling the service directly: edit by id, archive, restore and purge each fail with
not_foundβ the same code as a missing article. - Nothing changed: the article's title and archived flag are read back from the database afterwards, untouched.
-
No filing into an unreadable folder: a new article naming that folder fails with
invalid_field, and no article with that title exists afterwards. - POSITIVE CONTROL: the owner may archive the same article β so the refusals are the access rule, not a broken service.
-
REST: the test reads
api/v1/resources/knowledge.phpand checks that create, update and restore all pass$apiKeytoapiLoadArticle().
Upgrade. If you use restricted folders or articles and want to check nothing was changed by somebody who should not have, Knowledge writes a history row for every article edit (naming the fields that changed β folder and audience among them), archive, restore and purge, with the analyst who did it.
-
A write must pass the read check, and then its own. If someone cannot see a thing, they cannot change it. Start every write guard from the function that decides reads β here
knowledgeCanRead()β rather than from a narrower one that happens to have the right name. -
When a new kind of permission arrives, revisit every guard named for the old one.
assertCanAccessArticle()was correct when "access" meant "company". Folder permissions changed what the word meant; the function did not change with it. -
One rule, one copy. If a check you need exists as a private helper in an endpoint, move it into
includes/and call it from both places. Do not write a second one. -
Refuse in the same words as "missing".
not_foundfor an article, "not a known folder" for a folder. -
Pass the caller to the loader. A loader with an optional "who is asking" argument is only as safe as its least careful caller. If you call
apiLoadArticle(), pass the key.
Related: Knowledge folders and security β developer guide Β· Knowledge folders and permissions Β· Knowledge articles could run scripts Β· Company boundaries β seven places they did not hold Β· 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
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Ticket workspace - design (PR #206)
- β³ π 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
- β³ π³ The Docker image was missing PHP extensions
- β³ π 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