Skip to content

Developer Tests Security

Ed Mozley edited this page Sep 21, 2026 · 1 revision

πŸ§ͺ Developer Tests β€” Security & access

Part of Developer Tests. The suites that answer "is it actually refusing?" β€” and, just as importantly, "is it still accepting the things it should?"

πŸ”‘ Read this before reading any result on this page. A guard that refuses everything β€” because of a typo in a constant, or a wrong column name β€” looks exactly like a guard working perfectly, if all you assert is that it refused. That is how a fail-open bug hides in a green suite. So every "it refused" assertion here is paired with a positive control showing the same code still accepts something legitimate. When one of these tests goes red, check which half failed before deciding how worried to be.

Test Needs
security-findings/run.php Database (read-only); optionally a base URL
web-exposure-guard.php Nothing
record-preview.php Database
record-preview-security.php Database (rolled back)
sso-dangling-link.php Database
ldap/01_empty_password_guard.php Nothing
oidc-discovery.php Nothing
directory-sync-scopes.php Nothing

tests/security-findings/run.php

What it tests

The nine reported security findings from August 2026 β€” by observable behaviour wherever it can, and by the shape of the code only where behaviour is not reachable from outside.

Almost none of these fixes have a button. There is no screen that shows "the session cookie is HttpOnly now", and no way to eyeball whether a refresh token is ciphertext at rest. This suite exists so the answer is not "trust the diff".

Finding
F1 attachments named by the sender could be written into the web root
F2 setup/ handed a privilege flag to anonymous visitors
F3 mailbox OAuth tokens (and five other secrets) stored in the clear
F5 attachments served with a sender-chosen Content-Type, SVG inline
F6 bundled TinyMCE carried four published stored-XSS CVEs
F7 no session rotation, no cookie flags, no CSRF defence
F8 default credentials permanent, brute-force protection shipped off
F9 tenant guards failed open; two confirmed cross-company leaks

Run it

php tests/security-findings/run.php
php tests/security-findings/run.php http://localhost/freeitsm-app/

Pass a base URL to include the live HTTP checks β€” most importantly the original F2 exploit chain, which is the one worth watching fail. Without a URL those are SKIPPED, not silently passed, so a green run with no URL has not tested the exploit at all.

Read-only: it writes nothing to the database and nothing to the web root.

If it fails

Any red here is a regression of a reported vulnerability. Identify the finding number from the label and treat it as blocking a release β€” these were reported from outside, so a regression is visible to the person who reported it.


tests/web-exposure-guard.php

What it tests

That nothing in tests/ is reachable over HTTP β€” see Test suite exposure for the whole story.

It checks all three layers: the PHP_SAPI guard on every script, the .htaccess/web.config covering the whole directory, and tests/ being kept out of the Docker image and 404'd by the shipped nginx config.

πŸ”‘ It walks the directory rather than a list, so a test added tomorrow is checked tomorrow. Nobody has to remember to register it.

Run it

php tests/web-exposure-guard.php

12 assertions, needs nothing.

If it fails

"every .php in tests/ refuses to run" names the files missing their guard. Add this as the first thing after <?php:

if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }

Any other failure means one of the outer layers has been removed β€” put it back rather than relying on the remaining two.


tests/record-preview.php

What it tests

At-a-glance record previews. A preview is a read, and the links that lead to one can point at records the reader may not open β€” so the refusals matter more than the happy path, and each has a positive control beside it.

Run it

php tests/record-preview.php

ZZPV-named rows, removed including on failure.

If it fails

A refusal going red means a preview is rendering a record the reader has no route to. The preview is a summary, so the leak is small but real β€” names, titles, statuses.


tests/record-preview-security.php

What it tests

The security boundary around previews, written after the direct question "is there a way a hacker could call the preview function unauthenticated?" β€” as checks rather than as a claim.

πŸ”΄ The interesting question is never "does it work" but "what does it refuse, and does the refusal say anything it should not" β€” a refusal that distinguishes "no such record" from "not yours" is itself a disclosure.

How it works

⚠️ The tenancy section inserts a restriction so there is something to test: on a single-company install analyst_tenant_access is empty, meaning nobody is limited, and the test would otherwise prove nothing. It runs inside a transaction that is always rolled back β€” nothing here is ever committed.

Run it

php tests/record-preview-security.php

If it fails

Distinguish the two kinds: a preview returned when it should not (a leak), or a refusal that differs depending on why (an oracle). The second is subtler and still worth fixing.


tests/sso-dangling-link.php

What it tests

A sign-in link that outlived its account. Reported after importing the Core demo data: an OIDC account vanished from the admin interface and became impossible to sign back into, permanently, with "Your account is no longer available."

The mechanism, which is what the test pins down:

  1. api/system/import_demo_data.php empties analysts (bar admin) and users with FOREIGN_KEY_CHECKS off, so the ON DELETE CASCADE on the two identity tables never fires and the links are left dangling.
  2. oidc_callback.php resolves a person by (provider, subject) first. A dangling link wins that lookup, the account load returns null, and the sign-in dead-ends β€” because email matching and just-in-time provisioning both live in the branch past it.
  3. So re-creating the account by hand does not help either. The link still points at the old id.

The irony worth keeping: had the cascade fired, nobody would have noticed. The link would have gone with the account, the next sign-in would have landed in the JIT branch, and the account would have quietly come back.

Run it

php tests/sso-dangling-link.php

ZZTEST-prefixed rows, removed including on failure.

If it fails

Check whether a new bulk-delete path has been added that disables foreign key checks. That is the root cause, and it will produce this symptom again for a different table.


tests/ldap/01_empty_password_guard.php

What it tests

The RFC 4513 unauthenticated bind guard. LDAP defines a bind with a DN and an empty password as an "unauthenticated bind", and many directories answer it with success. Without an explicit check, leaving the password box blank would log you in as anyone whose username you can guess.

How it works β€” and why the obvious test is worthless

⚠️ Neither test rig reproduces the dangerous server behaviour. Both OpenLDAP and Samba AD reject the empty bind themselves, so an end-to-end test against a rig proves nothing about our guard β€” it would pass just as happily with the guard deleted. The wiki claimed for months that such a test existed; it did not, and it could not have meant anything if it had.

So instead the test points the provider at an unroutable address (TEST-NET-1, RFC 5737) with a short timeout. If the guard runs, ldapAuthenticate() returns immediately with reason credentials and never opens a socket. If the guard is removed, the call instead spends the connect timeout and comes back with a config/network error.

πŸ”‘ Speed is the assertion: no network call can have happened.

Run it

php tests/ldap/01_empty_password_guard.php

14 assertions, no directory required.

If it fails

If it now takes seconds rather than returning instantly, the guard has been removed or moved below the connection attempt. Put it back before any socket is opened.


tests/oidc-discovery.php

What it tests

Validation of a provider's discovery document. The assertion that matters is not "a good document is accepted" β€” it is that a bad one is refused here, where we still know whose fault it is.

A provider publishing authorization_endpoint as a bare path (/oidc/authorize rather than https://idp.example.com/oidc/authorize) used to be passed straight through. oidcBuildAuthUrl() then produced a relative Location: header, the browser resolved it against the current origin β€” this application β€” and the user landed on our host with a 404. Every visible symptom pointed at FreeITSM. That is exactly how it was reported.

How it works

Mostly negative cases, with positive controls proving the guard is not simply refusing everything. No database, no network: oidcIsAbsoluteHttpUrl() is a pure function, which is precisely why it is the thing worth testing.

Run it

php tests/oidc-discovery.php

27 assertions.

If it fails

A bad document being accepted means the next misconfigured provider produces a support request that looks like our bug. Fix the validator, and make sure the error names the provider's field.


tests/directory-sync-scopes.php

What it tests

Which parts of a directory are in scope β€” the arithmetic that decides who gets imported. Pure logic, no fixture and no database, so it can be checked on any machine without starting a directory.

Two rules earn most of the cases:

  1. A ticked branch means the whole branch, so being "under" something is decided by DN suffix β€” and the comma in that suffix is load-bearing. Without it OU=Sales matches OU=WholesaleSales, and a carve-out silently swallows an unrelated department.
  2. An install upgraded from before the OU browser has neither column set, and must go on importing exactly who it imported yesterday. The fallback is not a nicety: without it, upgrading imports nobody, and the sanity brake is then the only thing standing between that and every person in the company being marked as having left.

Run it

php tests/directory-sync-scopes.php

19 assertions, needs nothing.

If it fails

The suffix rule is the one to check first, and the upgrade fallback is the one with the worst blast radius β€” it marks an entire company as leavers.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally