-
-
Notifications
You must be signed in to change notification settings - Fork 28
Developer Tests Web Exposure
Part of Developer Tests.
If you run FreeITSM: there is nothing for you to do. Update to 2.3.1 or later and the directory is closed. This page explains what was wrong and how it is kept closed, for anyone working on the code.
FreeITSM is deployed by putting the repository in the document root. That is what
the official Docker image does β COPY . /var/www/html/ β and how a hand install
is normally laid out. Nothing stopped tests/ coming with it, and nothing in the
directory refused to run.
A request to /tests/<anything>.php returned HTTP 200 and executed the file,
with no sign-in.
These are not pure unit tests. 36 of the 56 scripts drive the real code against
the real database. They create forms, assets, contracts, documents and
working analyst accounts β one of them with the password x, which is
printed in a public repository β and each deletes what it made only in its
closing lines.
Over HTTP that becomes:
- An unauthenticated write endpoint anyone who reads the repository can trigger, as often as they like.
- Which can be abandoned half way. A client can disconnect, or the web SAPI's 30-second execution limit can cut the script off, leaving the account and the rows behind. The command line has no such limit, which is exactly why this never happened to a developer.
- Whose failure messages print real records back to the requester. One prints the titles of actual knowledge articles.
-
And which deletes by pattern, so
DELETE FROM assets WHERE hostname LIKE 'ZZIMP-%'takes a customer's matching row with it.
The sharpest single case was tests/test_email_thread.php, which was not a test
at all but a leftover scratch page. It rendered ticket #45's email thread β
addresses and message bodies β as HTML, with no authentication check of any
kind.
To be fair about the scope: no test creates an administrator, the tidy-up is genuinely written in each one, and an attacker has to know the paths β though they are in a public repository, so that is no barrier. It was never remote code execution. It was a set of unauthenticated write endpoints nobody intended to publish.
Three layers. Each covers something the others cannot, which is why all three are there rather than one.
The first line after <?php in all 56 files:
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }This is the layer that matters most, because it is the only one that travels
with the file. It works on nginx, on Apache where AllowOverride is off, in a
plain git clone, and inside an image somebody built themselves. It needs no
server configuration and cannot be left behind by a deployment.
It cannot cover the .html and .sh files, which have no way to refuse.
tests/.htaccess (Apache) and tests/web.config (IIS) deny the whole directory,
which does cover those other file types.
β οΈ web.configdeliberately contains no<handlers><clear/>, for the same reason documented intickets/attachments/web.config: the handlers section is locked at server level on a default IIS install, so clearing it makes IIS answer HTTP 500.19 for everything underneath. A directory that 500s has not been secured, it has been broken β and it reads to an operator as FreeITSM being at fault.
nginx has no in-directory equivalent at all, which is why layer 1 exists.
-
tests/is in.dockerignore, so the official image does not contain it. -
deploy/nginx/freeitsm.confreturns 404 for/tests/.
tests/azure-openai/mock.php is a stand-in Azure endpoint. It has to answer an
HTTP request β that is its whole job β so it carries a different guard:
if (PHP_SAPI !== 'cli-server') { http_response_code(404); exit; }It runs only under the built-in server that tests/azure-openai/run.php starts
on a free port and stops again. Under Apache, nginx or php-fpm it refuses.
That test used to fetch the mock from the app's own web server at the fixed URL
http://localhost/freeitsm-app/tests/azure-openai/mock.php, which meant it only
ran on a machine whose checkout happened to sit at that path. Starting its own
server fixed both problems at once.
tests/web-exposure-guard.php asserts that no other file claims that
exception, so it stays deliberate rather than becoming a hole anyone can widen.
php tests/web-exposure-guard.php
19 assertions. It checks all three layers for tests/, and the same for scripts/ (below), and takes its list of files from the
directory, not from a list kept inside the test β so a script added tomorrow is
checked tomorrow, with nobody having to remember to register it.
If you add a test and forget the guard:
FAIL every .php in tests/ refuses to run unless PHP_SAPI is cli
β 1 without a guard: tests/my-new-test.php
Both are worth knowing, because they are easy to repeat.
The first version searched the whole file for the class name it wanted and
passed against the broken code, because the explanatory comment added by the fix
contained that name. A guard its own documentation can satisfy is not a guard.
It now looks only at each file's head, and the same correction had to be made in
tests/field-widths-agree.php for the same reason.
A regex delimiter clash hid a broken check. The nginx prefix operator is ^~,
so a pattern delimited with ~ ended in the middle of the thing being matched.
PHP warned Unknown modifier while the check still reported ok, because a ||
fell through to a loose strpos.
Both were found by running the checker against the broken state on purpose. Do that before you trust a new one.
What happened: an audit on 4 October 2026 found the same hole one folder over. 13 of the 26 command-line tools in scripts/ had no guard: the translation tools and gen_portal_flow.php. Each answered an anonymous request with HTTP 200. Most crashed straight away on the missing $argv, printing the server's file paths into the page. gen_portal_flow.php ran through and wrote files. In the same pass, five new tests in tests/ had shipped without their guard. Those were still blocked by tests/.htaccess on Apache, but not on a server that ignores it.
Why scripts/ can't just be left out: unlike tests/, it ships in production. The Intune workers, directory_sync.php, cron_token.php and db_verify_cli.php are run with php scripts/<name>.php by an administrator or a scheduled task. So the folder stays, and every PHP file in it refuses the web:
| Layer | |
|---|---|
| 1. Every script |
if (PHP_SAPI !== 'cli') { β¦ exit; } as its first statement
|
| 2. Apache |
scripts/.htaccess refuses .php, .sh, .md and .config
|
| 3. IIS |
scripts/web.config refuses the same extensions (fileExtensions, not the locked handlers) |
| 4. nginx | `location ~* ^/scripts/.+.(php |
Invoke-AssetInventory.ps1 stays downloadable on purpose: it's the inventory agent, it holds no secret, and an admin may fetch it from the server.
The check is stricter here. For tests/ the guard must sit in the first 1,200 characters. For scripts/ it must be the first statement, found with PHP's tokenizer. A comment header can be any length, and a single line of code slipped above the guard still fails. With one guard removed on purpose:
FAIL every .php in scripts/ starts with the PHP_SAPI cli guard
β 1 without it as the first statement: i18n_drift.php
And it now runs on every push. .github/workflows/web-exposure.yml runs this test in CI. It had existed since 2.3.1, but nothing was obliged to run it, which is how both folders drifted. A guard a human has to remember is not a guard.
Proved over HTTP, not only by reading files: every file in both folders was requested with no sign-in (122 requests). Every script and test answered 403. Only the .ps1 answered 200.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- 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: 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
- β³ π« 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: 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
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ 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
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ 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
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- 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
- β³ π’ 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