Skip to content

Developer Tests Web Exposure

Ed Mozley edited this page Oct 4, 2026 · 2 revisions

πŸ”’ Test suite exposure β€” why tests/ is unreachable over HTTP

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.


😬 What was wrong

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.

Why that is worse than it sounds

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.


πŸ›‘οΈ How it is closed

Three layers. Each covers something the others cannot, which is why all three are there rather than one.

1. Every script refuses for itself

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.

2. The directory is denied

tests/.htaccess (Apache) and tests/web.config (IIS) deny the whole directory, which does cover those other file types.

⚠️ web.config deliberately contains no <handlers><clear/>, for the same reason documented in tickets/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.

3. It never ships, and nginx blocks it

  • tests/ is in .dockerignore, so the official image does not contain it.
  • deploy/nginx/freeitsm.conf returns 404 for /tests/.

πŸ”¬ The one exception

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.


βœ… Keeping it closed

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

Two things that went wrong while writing that guard

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.


🧰 scripts/ too (October 2026)

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

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally