Developer Tests Web Exposure - edmozley/freeitsm GitHub Wiki
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.