Developer Tests Security - edmozley/freeitsm GitHub Wiki
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 |
get-must-not-write.php |
Nothing |
security-report-2026-10.php |
Database (temporary ZZ rows); the web server for most checks |
security-report-2026-10-part2.php |
Database (temporary ZZ rows); the web server and Chrome for some checks |
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) |
workflow-company-scope.php |
A scratch database it creates and drops; the mysql client |
workflow-full-record.php |
The throwaway database config.php uses (a ZZ task, removed) |
workflow-set-ticket-field.php |
The throwaway database config.php uses (two ZZ fields, removed) |
workflow-task-actions.php |
The throwaway database config.php uses (two ZZ tasks, a workflow, notes, comments and runs, removed) |
workflow-attach-checklist-scope.php |
The throwaway database config.php uses (two ZZ checklist templates, the checklist attached to the oldest ticket, a ZZ note and the runs, removed) |
workflow-category-routing.php |
The throwaway database config.php uses (ZZ categories, workflows, tickets and runs, removed; other workflows on the same triggers paused and put back) |
project-member-companies.php |
A scratch database it creates and drops; the mysql client |
people-org.php and people-org-api.php |
Database (rolled back); the API suite also a running install (tagged rows, removed) |
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 |
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.
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.
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.
php tests/web-exposure-guard.php
12 assertions, needs nothing.
"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.
That no endpoint changes data on a GET โ see
A link could change data. The CSRF check only covers the
other methods, and a SameSite=Lax cookie is sent on a top-level GET from another site.
It reads every PHP file under api/ (minus the paths that never use the login cookie),
strips comments with PHP's tokenizer, and fails for any file that contains write SQL and
none of csrfRequireWriteMethod(), csrfRequireToken() or a REQUEST_METHOD check.
The few GETs allowed to write are listed in the file with their reasons; an entry that
no longer needs to be there fails too.
๐ It tests itself first, against fixtures โ including a check that appears only in a comment (must be flagged) and SQL that appears only in a comment (must not count). Against the code in 3.15.0 it reports 277 writers with no check.
php tests/get-must-not-write.php
17 assertions, needs nothing. Runs in the Web exposure workflow on every push.
It names the files. Add, before the first write:
// Changes data: never on a GET, which a link on another site can send (csrf.php).
csrfRequireWriteMethod();If the file reads on GET and writes on POST, put the call in the write branch instead.
Only add an allow-list entry for a write that is harmless from a link โ a view counter, a
log of a read.
REQUEST_METHOD check passes it, because a scanner cannot tell a check that refuses
from one that routes. api/knowledge/permission_model.php had one of each.
Every fix from the October 2026 security report, against the real code: a GET cannot
change data; sessionIsAdmin() asks the database; a deactivated or deleted analyst's
session is destroyed; the software API keys need their permission; POST /api/v1/users
checks the company it infers; the portal list previews follow the privacy setting; chat
messages thread only within their channel; a mailbox OAuth callback only accepts a state
this session was issued.
๐ The GET checks run signed OUT. If a fix were missing, a signed-out request still
could not write โ so the test cannot empty a real trash or run a real migration. The one
signed-in GET goes to a setting it snapshots first, and is paired with a positive control
(the same save as a POST with the token still works) and a negative one (a POST without
the token is still refused).
php tests/security-report-2026-10.php
58 assertions. Needs the database; the HTTP checks need the app at
http://localhost/<folder>/ (or FREEITSM_URL) and are skipped, with a note, otherwise.
Everything it makes is named ZZ and removed at the end, and it prints whether anything was
left behind.
Each section is one fix, and its write-up says what the fix was: see A link could change data, Access outlived its removal and Smaller gaps.
The second report's fixes, one section per item (7-24, and 4b): Knowledge writes refused for a denied analyst, with the article untouched; the article-body cleaner run in a real headless Chrome (handlers stripped, a video embed kept and sandboxed, forged markers and script frames refused); no code reading the sign-in copy of the module list; a deactivated analyst's calendar feed and Watchtower key refused; a moved ticket leaving the old company's search and taking its tasks; pending and rejected catalogue requests raising no ticket; the password rule; exact address matching; database errors hidden before sign-in; a fake recording refused; the summer lockout holding; ticket history accepting only real transitions; webhook encryption; every debug tool gated; a cross-site login refused; an analyst with no company seeing nothing; Watchtower counts following the company; and a company-limited REST key unable to name or create another company's requester.
๐ Three things it deliberately does not do, because each would act on real data: create a ticket (workflows that email requesters fire on ticket.created), save a Knowledge article (it can call an AI provider for the embedding), or send mail. Those paths are proved by their refusals, each paired with a positive control showing the same request is otherwise reached. It also snapshots this machine's row in ip_login_bans before the lockout check and puts it back.
php tests/security-report-2026-10-part2.php
About 90 assertions. Database, plus the app at http://localhost/<folder>/ (or FREEITSM_URL) for the HTTP checks, and Chrome for the cleaner; each is skipped, with a note, when absent. Prints whether it left anything behind.
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.
php tests/record-preview.php
ZZPV-named rows, removed including on failure.
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.
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.
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.
php tests/record-preview-security.php
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.
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.
php tests/sso-dangling-link.php
ZZTEST-prefixed rows, removed including on failure.
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.
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.
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.
php tests/ldap/01_empty_password_guard.php
14 assertions, no directory required.
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.
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.
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.
php tests/oidc-discovery.php
27 assertions.
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.
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.
php tests/directory-sync-scopes.php
19 assertions, needs nothing.
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.
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
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
4.1.0, #209. Workflows and companies, 87 checks. It never touches your database: it imports database/freeitsm.sql into a throwaway zz_wf209_* database and drops it at the end, pass or fail. Three phases, each in its own process (tenantCount() is cached per process):
- N=1 - one company: every workflow runs, no company field, nothing changes.
- N=3 - which workflows run for which event; a company's workflow refused another company's ticket, team and assignee beside the same action succeeding on its own; the three new actions; who sees, changes, deletes and fires what in the app; the run log, its facets and the editor's recent runs; the same again through the REST handlers with synthetic keys.
- Before Database Verification - the two columns dropped on a multi-company install: everything still works, the old way.
Each app call runs the real endpoint file in its own process (endpoints exit), with the session written first and php://input fed through a stream wrapper - the harness tricks. With the company guard switched off, four of the boundary checks go red.
php tests/workflow-company-scope.php
4.1.0, #210. A project's analysts, teams and project manager against its company, 24 checks, on a scratch zz_prj210_* database it drops at the end. At N=1 anyone may be added. At N=3: an analyst or team that cannot see the company is refused beside one that can being added; a Person from another company is still refused; a new project manager who cannot see the company is refused, an old one does not block an edit; members from before the rule are flagged, not dropped; the pickers' lookups say who reaches which company. With the analyst rule switched off, four checks go red.
php tests/project-member-companies.php
4.8.0, PR #242 by vsc55. The organisation chart's visibility rule, from both sides.
-
people-org.php- 94 checks on the functions inincludes/people.php, inside a transaction that is always rolled back. Two companies and a small organisation: a manager in the other company, leavers with and without reports, a reporting line that loops, a long chain, a manager of 160 people. A restricted analyst never sees, counts or is told about somebody out of reach; a manager in another company is never named; a leaver who still manages people is shown, one nobody reports to is not; walks end on a loop; the limits hold; and the "everybody under somebody" totals (PR #265) count with and without leavers, stop at a company the reader cannot see, end on a loop without counting anybody twice, and give 0 for somebody with nobody under them. -
people-org-api.php- 43 checks onapi/people/org.phpover HTTP: what it makes of a URL - levels padded, in hex, as a list, past the limit; an id as a list; an unknown action; who may ask at all. It cannot use a transaction (the web server must see its rows), so it tags every row with one random tag, deletes them infinally, and stops first if the server cannot see them.
FREEITSM_TEST_DATABASE=<throwaway db> php tests/people-org.php
FREEITSM_TEST_URL=http://localhost/<install> FREEITSM_TEST_DATABASE=<its db> php tests/people-org-api.php
See Organisation chart - Developer Guide ยง8.
That {{<record>.full...}} works in an ordinary workflow action, not only a webhook (#275), and never for a confidential ticket. It runs real action lists through WorkflowEngine::runActionList() with a log_message action, whose result is the rendered text: a task's {{task.full.ticket_id}} and {{task.full.title}}; a ticket's {{ticket.full.ticket_number}}; a plain {{task.id}} unchanged; and, with the ticket set confidential, {{ticket.full.subject}} rendering blank.
FREEITSM_TEST_DATABASE=<the throwaway db config.php uses> php tests/workflow-full-record.php
4 checks. Control: the engine before #275 renders the first two blank. The ticket's sensitivity is put back, and the ZZ task and the runs are deleted, in finally.
Workflows for tasks (#274). Designer: every task trigger offers task.ticket_id; the three ticket merge codes are listed; ticket actions carry trigger_defaults for task. (and keep {{ticket.id}} as their default); the three task actions exist; the sample recipe uses a real trigger and action; enrichWithTaskTicket() fills number, subject and link. The example, end to end: a real active workflow on task.completed - completing the linked ZZ task through TasksService writes an internal System note on its ticket with the task title and ticket number; the unlinked task is skipped by the condition and recorded as skipped. No ticket fails with the plain message. set_task_status moves the task and the history says workflow; a required task form set to block stops completion and the step names it. assign_task sets the owner; an unknown analyst fails. add_task_comment writes a System comment (analyst_id NULL) after Database Verify relaxed the column; an unknown task fails.
FREEITSM_TEST_DATABASE=<the throwaway db config.php uses> php tests/workflow-task-actions.php
23 checks. Runs Database Verify first; puts the task-forms setting back.
The Attach SOP Checklist(s) workflow action and a checklist that is for tasks only (changelog #2801). The picker offers a ZZ checklist for tickets and not a ZZ one for tasks only. An old workflow naming one of each (a real action list through WorkflowEngine::runActionList(), followed by an Add a note action): the run succeeds (it used to fail with That checklist is for tasks only.); the ticket checklist is attached and the tasks-only one is not; the step's attention names the skipped checklist and the step still counts as success; the note action after it still runs. Only the tasks-only one: the step is skipped with the note, and the run still succeeds. Somebody finds out: both runs match the Needs a look filter's step_log LIKE '%"attention":%'; getWatchtowerData() counts them in attention_24h, clears all_clear, and names the latest run in attention_last.
FREEITSM_TEST_DATABASE=<the throwaway db config.php uses> php tests/workflow-attach-checklist-scope.php
13 checks. Runs Database Verify first. The attached checklist, the templates, the note and the runs are deleted in finally. See Workflow engine - Developer Guide.
Routing tickets by category (#298). 5.1.0 adds: old_category_id / new_category_id are category lookups, and a type change that clears the category fires ticket.category_changed (old = the category, new = null) and is caught by a condition on the old category - 24 checks. The designer: ticket.category_id is a condition field on every ticket trigger; ticket.category_changed exists with old_category_id / new_category_id; the picker labels a subcategory with its path. Subcategories: with workflow_category_match never saved, a ZZ Hardware workflow on ticket.created notes a new ticket in Hardware โ Laptop, and not one in another category or none; set to exact, the Laptop ticket no longer matches while Hardware itself still does; the run history step carries "includes_subcategories":true when it applied. The trigger: setting a ticket's category through TicketsService::updateTicket() fires ticket.category_changed and the workflow routes it, with old_category_id null and new_category_id the new category; saving the same category again fires nothing. Looked up: a note on a Laptop ticket (ticket.note_added, whose payload never carried a category) still matches, because dispatch() filled the field in.
FREEITSM_TEST_DATABASE=<the throwaway db config.php uses> php tests/workflow-category-routing.php
18 checks. Other active workflows on the three triggers it uses are paused for the run and put back; the ZZ workflows, runs, categories and tickets (and what hangs off them) are deleted in finally, and the setting is restored. See Workflow category routing - Developer Guide.
The Set a ticket field workflow action (#278). It makes two ZZ custom fields (text and number) and runs real action lists through WorkflowEngine::runActionList() against an existing ticket: the editor's lookup offers the fields; a text field is set from a merge code (T-{{ticket.id}}); the change is in ticket_audit with no analyst; a number field is set; a value the type refuses fails the step and leaves the old value; an empty value clears; a field the ticket does not have fails.
FREEITSM_TEST_DATABASE=<the throwaway db config.php uses> php tests/workflow-set-ticket-field.php
7 checks. The fields, their values, their history rows and the runs are deleted in finally.