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
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)

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/get-must-not-write.php

What it tests

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.

Run it

php tests/get-must-not-write.php

17 assertions, needs nothing. Runs in the Web exposure workflow on every push.

If it fails

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.

โš ๏ธ A 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.


tests/security-report-2026-10.php

What it tests

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).

Run it

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.

If it fails

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.


tests/security-report-2026-10-part2.php

What it tests

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.

Run it

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.


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.


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 (system with 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 real ldapCreateAnalyst().

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

tests/workflow-company-scope.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):

  1. N=1 - one company: every workflow runs, no company field, nothing changes.
  2. 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.
  3. 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

tests/project-member-companies.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

tests/people-org.php and tests/people-org-api.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 in includes/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 on api/people/org.php over 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 in finally, 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.


tests/workflow-full-record.php

What it tests

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.


tests/workflow-task-actions.php

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.

tests/workflow-attach-checklist-scope.php

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.

tests/workflow-category-routing.php

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.

tests/workflow-set-ticket-field.php

What it tests

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.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ