Developer Tests Security - edmozley/freeitsm GitHub Wiki
๐งช 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 |
analysts-provisioning.php |
Database (rolled back) |
sso-claim-mapping.php |
Database (rolled back) |
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:
api/system/import_demo_data.phpemptiesanalysts(baradmin) anduserswithFOREIGN_KEY_CHECKSoff, so theON DELETE CASCADEon the two identity tables never fires and the links are left dangling.oidc_callback.phpresolves 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.- 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:
- 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=SalesmatchesOU=WholesaleSales, and a carve-out silently swallows an unrelated department. - 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.
tests/analysts-provisioning.php
3.5.0, PR #174. AnalystsService, 37 checks, rolled back:
- a new analyst starts with nothing, including no companies on a multi-company install;
- emails are unique ignoring case;
- usernames are made unique;
- each access part replaces only what is sent;
- module, company and capability keys are refused when unknown (
systemwith a pointer to admin); - capabilities sit on one managed role that leaves a hand-assigned role alone;
- you can't demote or deactivate yourself, or the last active administrator;
- the managed role goes with its analyst.
Every refusal has a control. The REST key rule (an administrator key for all companies) lives in the adapter and was checked over HTTP. See Analyst provisioning โ Developer Guide.
php tests/analysts-provisioning.php
tests/sso-claim-mapping.php
3.5.0. SSO groups (includes/sso_claims.php), 37 checks, rolled back:
- reading a group claim the way providers really send it (arrays, single strings, nested stdClass from the ID token, dotted paths, URL-named claims);
- matching ignoring case in any alphabet, for SSO and LDAP's
ldapInGroup()alike; - group โ team rows (an unknown team and a team without a group are refused; duplicates are dropped);
- applying them at sign-in: mapped teams joined and left, hand-added teams untouched, the admin group, and the last administrator kept;
- the groups-seen list and its names;
- new analysts of a provider with team rows starting with no own modules;
jit_company_access(all,provider,none) through LDAP's realldapCreateAnalyst().
The sign-in itself needs a real identity provider. It was proved against Microsoft Entra on 2026-10-12. See Single Sign-On.
php tests/sso-claim-mapping.php