-
Notifications
You must be signed in to change notification settings - Fork 15
Security Review Response 2026 08
The point-in-time record of how FreeITSM responded to a private security audit by
Erlend Volden, reported against commit f7f1e9dd on 7 August 2026.
π What this page is. The response document itself, as sent to the reporter β the findings, two corrections to the report, what was verified and how, and a round-two section added when he re-reviewed the fixes. It is kept as a record of what was said at the time rather than rewritten as things moved on.
For the current position, read the per-round pages instead: round two (dev guide) Β· round three (dev guide) Β· Security for the overview.
β οΈ The Outstanding section near the bottom is historical. It lists what was open in August 2026; round three has since closed most of it. What genuinely remains open is on the round-three page.
Nine security findings and three correctness findings. Every security finding was verified against the code and every one of them was real. All eight in scope were fixed.
β οΈ This table has been re-pointed atmain. It originally referenced thesecurity/findings-2026-08branch and its pre-rebase commit ids. That branch has since been merged and deleted, and the rebase rewrote every id β so in the version of this document originally sent to the reporter, the branch link and all eight commit ids are now dead. The ids below are the ones onmain.
Eleven commits, one per finding, so each could be reviewed and accepted on its own. 130 files changed β but 62 of those are the TinyMCE upgrade blob, so the reviewable surface was 68 files.
| Commit | Finding | Severity |
|---|---|---|
a8bd2430 |
F2 setup/ granted a privilege flag to anonymous visitors |
Critical |
8f3c37ad |
F3 M365 / Gmail refresh tokens stored in cleartext | Critical |
d3d1579f |
F5 SVG served inline with a sender-supplied Content-Type | High |
fe70e58a |
F1 attachments written to the web root under the sender's filename | Critical |
1e6fdc2c |
F9 tenant guards failed open; two confirmed cross-tenant paths | High |
5b05b38c |
F6 bundled TinyMCE 8.3.2, four published XSS CVEs | High |
37d8fdb8 |
F7 no session regeneration, no CSRF tokens, no cookie flags | High |
ed8bdba9 |
F8 default credentials permanent; lockout disabled by default | High |
2a3a93c2 |
SECURITY.md β a private reporting channel |
β |
1d92296d |
A verification suite for all of the above | β |
F5 is deliberately out of sequence. It reached main as a cherry-pick ahead of the
rest of the branch, so the rebase correctly dropped the duplicate β its content is in
the tree under the id above.
Out of scope by decision, and tracked separately: F4 (SLA determinations not
reproducible) and F11 (no subject-access or erasure capability). Both are features
rather than fixes and deserve their own design work.
F10 (db_verify runs destructive DDL with no dry-run) is acknowledged and not yet
addressed. β F10 was closed in round three, along with a read-only preview of what
a run would change.
Offered in the same spirit the report was written in β neither changes a finding.
F3. The report lists workflow/includes/engine.php:1722,
includes/sla_notifications.php:306 and includes/self_service_email.php:43 as
readers that access token_data without going through decryptMailboxRow(). They do
read $mailbox['token_data'] directly, but in every case the row was decrypted
upstream β by templateGetMailboxForTicket(), sla_get_first_active_mailbox() and
ssGetSendingMailbox() respectively. Tracing all eleven readers found none that
needed changing; registering the column fixed the read path entirely.
F6. The report notes TinyMCE is initialised at self-service/new-ticket.php,
"which is reachable without logging in". It is not β it 302s to the portal login. The
underlying point stands regardless, since tinymce.min.js is a static file anyone can
fetch and read the version out of, which is how it would be fingerprinted anyway.
F1 is four ingest paths, not three. saveChannelMediaAttachment() in
includes/messaging/ingest.php β the WhatsApp / web-chat / Slack media path β has
exactly the same shape: sender-supplied filename, no allow-list, written into
tickets/attachments/. The report lists that file under F5 for its content_type
handling but not under F1. It is fixed alongside the other three.
The suggested fix was to route the writers through uploadStoreFile(). That is not
available, and the reason is worth setting out. Its first two acts are
$file['error'] !== UPLOAD_ERR_OK and is_uploaded_file($file['tmp_name']), and it
finishes with move_uploaded_file(). None of the four ingest paths have an uploaded
file: inbound email is decoded MIME, the two portal paths are base64 out of a JSON
body, and the messaging path is media we fetched over HTTP. Writing the bytes to a temp
file first does not rescue it either, because is_uploaded_file() consults the real
upload list and correctly refuses a file we wrote ourselves β so taking the suggestion
literally would mean removing the check that makes that function safe for the form
uploads it does serve.
So: uploadStoreBytes() as its sibling in includes/uploads.php instead, with the
same three gates β same extension allow-list, same finfo cross-check against the
content, same random stored name drawn from our own list. The sender's filename is kept
in the database for display only, and the content_type recorded is the one we
detected, not the one declared.
On the fallback β "force the stored extension from an allow-list and reject any
filename starting with .". The first is done, and it is what makes the second
unnecessary: the whole stored name is ours, not only the extension, so a name
beginning with . cannot reach disk in any form. .htaccess in particular fails the
allow-list for carrying no extension at all.
It deliberately does not throw on a disallowed file the way uploadStoreFile()
does. Inbound email is not somebody at a form who can choose a different file, and one
odd attachment must never cost a person their ticket.
And one place the sibling is deliberately stricter than its parent.
uploadStoreFile() applies the content check only when it has an answer
($mime !== null && !in_array(β¦)), so on a server without finfo it accepts on the
extension alone. uploadStoreBytes() fails closed instead: an undetectable type is
recorded as application/octet-stream, which appears in no allow-list entry, so the
file is quarantined rather than trusted. We have not retrofitted that onto
uploadStoreFile() β it would change live behaviour on the change-management upload
path, which is outside this finding β so the two functions genuinely disagree on that
one case today. Listed under Outstanding rather than left to be discovered.
New setting, and the reasoning behind it. The obvious fix β reject the file β loses a customer's attachment. The alternative β keep it inert β means storing content the operator has declared they do not want. Both are defensible, so it is a choice:
System β Security β Attachments β "When a file type is not accepted" Keep it, download only (default) Β· Do not keep it
Either way the outcome is written onto the ticket, so nothing disappears silently. See the UI section below.
Directory protection. The report is right that .htaccess does nothing on nginx
and IIS. web.config added to the four PHP-served upload directories, and
tickets/attachments and change-management/attachments changed from "deny script
extensions" to denying the whole directory β the old rule blocked .php but happily
served .html and .svg from our own origin. Both files are whitelisted in
.gitignore so they actually ship.
The honest framing, though: gate 3 is the fix and the directory rules are the net.
A file that cannot be named .php cannot be executed by any server, configured or
not. nginx and IIS installs are now protected by the primary defence rather than by
one that was never there for them.
An attacker-supplied .htaccess is now stored as .bin like anything else, which
closes the third bullet of the finding β the one marked likely rather than confirmed.
Migration. A live install still had a .html attachment on disk from before the
change. db_verify now renames existing attachments carrying an executable or web
extension to .bin and updates file_path; the displayed filename is a separate
column and is untouched, so it still downloads under its own name.
The flag is gone rather than guarded. installIsUnprovisioned() in the new
includes/setup_state.php asks the database the question the flag was standing in
for, so there is no longer a token to forge β the unauthenticated bootstrap path
exists only while it is genuinely the only path.
It fails closed. A missing analysts table is the one error that really means
"fresh install"; a dropped connection, a lock timeout or a permissions error returns
false and an administrator is required. The new includes/db_errors.php is what tells
those apart, and F9 uses it too.
The disclosures are gated separately: every check now carries a path-free twin, swapped in unless the viewer is a fresh install or a signed-in administrator. Statuses are untouched β you still see which check failed, just not the paths, hosts or accounts involved.
One suggestion not taken. setup/ is not in .dockerignore. README.md:58 tells
Docker users to open /setup/ to build the schema and create their admin account, and
docker/entrypoint.sh only generates the encryption key β nothing else builds the
schema, so removing the folder breaks first-run. The reasoning in the finding looks
like it assumed the page was diagnostics-only; it is also the bootstrap. Since the
folder now grants nothing and shows nothing, shipping it seemed the lesser problem.
Happy to revisit if you disagree.
token_data added to ENCRYPTED_MAILBOX_COLUMNS, which fixes every reader, plus the
eleven writers wrapped in encryptValue(). db_verify migrates existing rows in
place, across every column in the list so a future addition migrates itself.
The structural note was worth more than the finding. Taking "a wildcard SELECT filtered by an allow-list of known secrets fails open" seriously and auditing a live install turned up five more secrets nobody had registered, all in plaintext:
csat_token_secret integration_cron_token sla_cron_token
webhook_cron_token workflow_cron_token
The four cron tokens authenticate endpoints deliberately reachable without a login, so
they are bearer credentials. isEncryptedSettingKey() is now a rule first and a list
second β *_password / *_secret / *_token / *_api_key is encrypted the moment it
exists, with SETTING_KEYS_NEVER_ENCRYPT (currently empty) as a documented escape
hatch. Forgetting now means "encrypted something harmless" rather than "leaked
something that mattered".
Masking was deliberately not inverted. Encryption is invisible to the user, so defaulting it on costs nothing. Masking changes what an administrator can see and copy β the cron tokens have to stay readable to build a cron URL β so it stays an explicit per-key decision.
A trap worth flagging. There is no central system_settings accessor; the cron
scripts read setting_value straight from SQL. Auto-encrypting would have broken every
cron job. decryptValue() passes plaintext through untouched, which is what makes the
migration safe in both directions.
The served type now comes from the file extension against ATTACHMENT_SERVE_TYPES in
includes/uploads.php β our string, never theirs β and anything unrecognised is
application/octet-stream as a download. A file named .png whose bytes are HTML is
served as image/png with nosniff, which a browser will not execute. SVG is absent
from the map for the same reason it is absent from UPLOAD_TYPES_IMAGE. Filenames are
stripped of quotes and CR/LF so they cannot break out of the header.
It lives in includes/uploads.php because that file is already the one home for these
rules, and api/change-management/get_attachment.php was already doing it correctly β
this is the same idea kept in one place rather than a third copy.
PDF stays inline deliberately: it is what the reading pane previews, and browsers run PDF script inside the viewer sandbox rather than as page script on our origin. Noted rather than silently kept.
8.3.2 β 8.8.2, downloaded from the npm registry and verified against
dist.shasum before extracting. Replaced file-for-file over the existing tree: 136
files, 62 of which actually differed. The 130 files 8.8.2 has that we do not are all
unminified twins β we carry only the production .min.* set.
The more useful half of the finding was that there is no package.json or
composer.json, so no scanner sees any of it. VENDOR.md now lists all eight bundled
libraries with version, path, source and licence, how to check each for updates, and
the procedure for replacing one. Versions not embedded in the file say so and give the
date vendored rather than guessing.
session_regenerate_id appeared zero times in the tree, as reported.
sessionPromoteToAuthenticated() in the new includes/session_security.php rotates
the id and re-issues the cookie, called at all ten identity points β the eight sign-in
paths plus both password-change endpoints.
Why the ini settings ship as configuration rather than code, since this was the
interesting constraint: 809 of the 818 files that call session_start() do so
before they include config.php. There is no PHP file early enough in the request to
set cookie parameters. So they ship three times:
| File | Covers |
|---|---|
.user.ini |
PHP-FPM / CGI / FastCGI β nginx and IIS, the case the report called out |
.htaccess |
Apache with mod_php, which does not read .user.ini
|
docker/php.ini |
the image, which previously shipped no php.ini at all |
session.cookie_secure is in none of them, deliberately: it cannot be conditional
in a static file, and forcing it on an install served over plain HTTP would stop the
cookie being sent at all. It is applied per request where the scheme is known.
X-Forwarded-Proto is only believed when TRUST_PROXY_HTTPS is defined, since
otherwise a client could flag its own cookie Secure over HTTP and lock itself out.
Because a server might read none of those three files, the authenticated cookie is
also re-issued explicitly at sign-in β but only when the server's own configuration
did not already produce the right attributes, so a correctly configured install emits
one Set-Cookie rather than two.
CSRF: request_guard.php stops the proof-of-concept, not the class. This wording is
deliberately weaker than the first draft's, which claimed more than the code delivers β
the correction is Erlend's and it is right.
SameSite=Lax stated explicitly is the real fix here. Chrome already defaults to it, and
saying so extends the same protection to Firefox and Safari. includes/request_guard.php
then refuses any state-changing request declaring text/plain, which closes the exact
<form enctype="text/plain"> attack in the report.
What it does not do is stop cross-site request forgery generally. urlencoded and
multipart are CORS-safelisted too, so three lines of JavaScript resend the identical
JSON body under a different label, no preflight, and an endpoint that only ever calls
json_decode(file_get_contents('php://input')) parses it happily:
fetch('https://desk.example/api/cmdb/delete_object.php', {
method: 'POST', mode: 'no-cors', credentials: 'include',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: '{"id":42}'
});Those two types are not refused because ordinary HTML forms and file uploads need them.
So the guard's only unique coverage is the JavaScript-free form variant β real enough to
keep, far short of a defence. Two related corrections to the first draft: the front end
does not send application/json at all fetch sites (calendar/index.php sends
urlencoded and six sites send FormData, which is itself why those types cannot be
refused), and auth/login.php is still a tokenless form POST, so login-CSRF is open.
And SameSite is same-site, not same-origin: for a desk on desk.corp.example, an XSS
anywhere under corp.example gives unmitigated CSRF against every endpoint.
A token layer across all 369 endpoints that read php://input therefore remains
outstanding and is the actual fix. It stays on the list rather than being considered
handled β which is the whole reason for restating this section.
analysts.must_change_password added, set on the seeded admin account, cleared when
a password is changed.
And enforced, which it previously was not β for a more interesting reason than
"nobody wrote a guard". A guard did exist, in includes/waffle-menu.php: if
$_SESSION['password_expired'] is set, redirect. But it only runs on the 28 pages
that draw the waffle menu, and it explicitly exempts any URL containing api/. So
/tickets/ β which does not include that file β let a flagged account straight
through, and every API endpoint was exempt by design. Confirmed before fixing: a
flagged account reached the inbox with a 200.
That is the shape worth naming, because it is the same shape as the fail-open catch in
F9: a control that exists, reads as present in the documentation, and has holes exactly
where nobody looked. includes/password_gate.php now runs on every request from
functions.php; HTML gets a redirect, API callers get a 403 carrying
password_change_required so a fetch() does not chase a 302 to an HTML page. Without
it the fix would have been cosmetic, which is the one outcome worth avoiding when the
finding is "the default credentials are permanent".
The five brute-force settings are now seeded. The report was right to flag the UI
hardest: seeding the values the screen was already claiming is what makes it honest.
The phantom || '5' and || '2' fallbacks are gone, along with the matching static
HTML defaults; an unset value now shows 0, which is what the login code will really
do.
password_expiry_days stays 0 on purpose. Forced rotation on a timer is not good
practice, and unlike the others, switching it on at upgrade would lock people out of
their own service desk.
Trade-off stated rather than hidden: an attacker who knows a username can now lock that account for the configured window. That beats unlimited guessing, and the IP ban covers a sweep across many usernames.
Both OTP verifiers now count failures in the session and abandon the challenge after five, sending the user back to the password step β which is rate-limited. The session is the right home for the counter because the challenge is session-bound: discarding the session to reset the count means presenting the password again.
tenancyDegradeAllowed() forgives only the two errors that genuinely mean the schema
has not caught up. Anything else β lock wait, dropped connection, permissions,
deadlock, or a non-database exception β denies and is logged. Applied at all six sites
plus analystCanAccessArticle(), which also returned true when the row was not
found and so could not deny anything for an id that did not resolve.
Both confirmed leaks are closed. get_recording.php now gates analysts on the Tickets
module and analystCanAccessTicket(); a pending recording with no ticket belongs
to the portal user who made it and to nobody else. save_user.php gained
analystCanAccessUser() β guarding the destination company without guarding the
subject guarded nothing.
The two stale comments are corrected: tenancy.php no longer claims "nothing wires
these into queries yet" and tenancy-switcher.php no longer claims it is not wired
into the header.
Five things a person can see. Everything else is under the floorboards.
The setting from F1. Two options, defaulting to keeping the file:
-
Keep it, download only β stored under a name of ours with an inert
.binextension, and it can only ever come back out as anoctet-streamdownload. - Do not keep it β not written at all.
Under it: "Either way the ticket says what happened and why, so nobody has to wonder where their file went."
When an attachment is not accepted, an amber block is appended to the message naming the file and the reason β "kept, but php files are not accepted, so it can only be downloaded, never opened in the browser", or "not saved, because php files are not accepted". This is the part that makes the setting honest: a customer who thinks they attached something is told what happened to it.
The IP-ban boxes previously pre-filled 5 and 2 from a hardcoded fallback while the
login code read the missing settings as off. They now show what is actually stored,
and 0 where nothing is.
A blue banner β "Setup is complete on this install, so paths, connection errors and credentials are hidden. Sign in as an administrator to see full detail." β and the same checks with the same ticks and crosses, minus every path, error, credential and the Database Verify button. Sign in as an administrator and it all returns.
admin / freeitsm now lands on the change-password interstitial, and cannot leave
it by typing a different URL.
All new strings are in en and pt-BR.
Almost none of this has a button, so there is a script:
php tests/security-findings/run.php # code + database checks
php tests/security-findings/run.php https://your-install.example/ # adds the live checks92 checks, currently all passing. Read-only β nothing written to the database or
the web root, the upload checks work in the temp folder and clean up after
themselves. Exit code 1 on failure. Without a base URL the live checks report SKIP,
never a silent pass.
It tests behaviour wherever behaviour is reachable, not just code shape: it feeds
shell.php to the upload handler and inspects what lands on disk, asks the attachment
server what it would send for an SVG, hands tenancyDegradeAllowed() a simulated
lock-wait timeout, reads the real cookie attributes off a live response β and runs the
original F2 exploit chain against a running install to watch it return 401.
Two things it does on purpose:
Every refusal is paired with a positive control. Showing shell.php is rejected
proves nothing on its own: a guard broken by a typo in a constant rejects everything
and reads as a clean pass. So each one is followed by "and a real PNG is still
accepted", "a real image still previews inline", "the same POST as application/json
is still allowed".
It strips comments before matching source. The first run reported four fixes as
missing β it had found $_SESSION['setup_access'] = true,
s.max_ip_attempts || '5' and session.cookie_secure inside the comments explaining
that those things had been removed.
The suite is itself checked against a deliberate regression:
mv tickets/attachments/web.config{,.bak} β 83 passed, 1 failed
restored β 84 passed, 0 failed
tests/security-findings/README.md covers the six things a script cannot judge β the
setup page staying quiet, the note on a refused attachment, images still previewing,
the editor still working, the cookie flags in devtools, and the Security page showing
real values β plus how to set up the second company needed to see the isolation fix
first-hand.
Every fix was exercised against a running install with a real database, rather than reasoned about:
| F1 |
shell.php through the real portal β stored as β¦β.bin, note on the ticket; policy switched to drop β nothing written, note says so |
| F2 |
GET /setup/ then POST db_verify on one cookie β 401; admin session β 200 |
| F3 | Microsoft Graph and Gmail both collected mail on encrypted tokens; cron auth: wrong token 403, right token ran |
| F5 | a planted SVG carrying <script>alert(document.domain)</script> β application/octet-stream, attachment
|
| F6 | editor initialised in headless Chrome β version=8.8.2 ui=rendered plugins=14, with a negative control |
| F7 | portal and analyst logins both rotated the id; forged id refused and replaced; text/plain POST β 415, JSON β 200 |
| F8 | flagged account blocked from every module page and the API; released on compliance; five wrong codes abandoned the MFA challenge, the real code still worked |
| F9 | analyst scoped to one company β 404 on another company's recording, "User not found" on their customer; positive controls passed with access granted |
Testing caught two things code review had not: the CA-bundle path still leaking
through setup.detail.ssl_verified after F2 looked finished, and the F8 gate first
redirecting to auth/force_password_change.php, which auth/.htaccess deliberately
404s β stranding the user with no way to comply and no way back.
Erlend Volden reviewed the branch above and returned nine items: four functional regressions this branch had itself caused, and five security points. All nine were verified against the code before anything was changed, and all nine held β including his own three corrections to us, which were also right.
Two of them were regressions of a shape worth naming, because the test suite was
actively hiding them. Inverting the secret rule (F3) widened what is ciphertext on disk,
and every place still doing a bare SELECT setting_value carried on handing that
ciphertext to whatever it fed. Neither threw an error. And the suite's assertion β
"every secret in the database is ciphertext" β passed happily throughout, because a read
path that never decrypts looks identical to a correct one from the storage side.
Storage is half a round-trip; assert the whole one.
| What was wrong | Fix | |
|---|---|---|
| R1 |
system/webhooks/index.php read the cron token raw, so the copy-paste URL contained ?token=ENC%3A⦠and the worker answered 403 |
New settingsGetDecrypted() accessor. Four cron setup docs corrected, plus a new scripts/cron_token.php
|
| R2 |
knowledge_email_smtp_password encrypted at rest, read raw, written raw β article sharing authenticated to SMTP with the literal string ENC:β¦
|
Decrypt on read, encrypt on write, masked in the settings screen |
| R3 | The attachment allow-list quarantined 9 of the 19 types our own messagingExtForMime() produces β WhatsApp voice notes, video, HEIC photos, contact cards |
Audio/video/mail types added; which types are accepted is now a setting |
| R4 |
api/self-service/change_password.php opened with read_and_close, so the session rotation was a silent no-op |
Plain session_start()
|
| S1 | The destination check was gated on $tenantSent; omitting tenant_id skipped it, and the create path then resolved a company from the email domain |
Authorise the resolved destination |
| S3 |
tenancyTablesReady() still had the bare catch F9 removed everywhere else β and it is the master switch every guard depends on |
Only a missing table degrades |
| S5 |
database/freeitsm.sql seeded admin without must_change_password, and Docker mounts it as an initdb script, so db_verify's seed never ran |
Flag set in the SQL seed and a catch-up migration |
| S7 | The quarantine migration's rename() had no destination check; two colliding names destroyed a file and pointed both rows at the survivor |
Find a free name; denylist widened |
| S9 |
oidc_callback.php set analyst_id without consulting must_change_password
|
SSO honours the flag |
-
S1 β the suggested fix (apply the check to the resolved id) is exactly right, but
applying it alone would have broken every single-company install. An ordinary analyst
there has no rows in
analyst_tenant_access, sogetAccessibleTenantIds()returns[]andanalystCanAccessTenant()refuses everything β creating any requester at all would have started failing. The check is therefore behindisMultiTenant(), matching whatanalystCanAccessTicket()andanalystCanAccessUser()already do on their first line. This is also why the original$tenantSentgate existed: it was the wrong guard, but it was not thoughtless. - S3 was filed "soon after" and was pulled forward to block the merge. Shipping a commit titled "stop the tenant guards failing open" while the switch that gates all of them still failed open would have left the finding looking fixed when it was not.
-
R3 became a setting rather than a wider constant. Both were reasonable; the
right list is genuinely local β an email-only desk may not want to store video, while a
WhatsApp desk needs audio or the module does not work. It can only ever narrow: the
value is intersected with the catalogue in
includes/uploads.php, so an unknown extension is ignored rather than trusted and no text box can make.phpacceptable. -
S8 β the
TRUST_PROXY_HTTPSdocumentation gap is fixed (it now appears inconfig.php,docker/config.phpanddocker-compose.yml), along with comma-list tolerance forX-Forwarded-Proto. TheSameSite=Strictoverwrite was a real bug in F7's own code and is fixed β the check is now "at least as strong as", not "equal to". Thecookie_lifetimepoint is not addressed. -
S6 β the two bugs in the new code are fixed (the
unset()that ran before the logging call read what it removed, so every abandoned-MFA row loggedunknown). Moving the OTP counter out of the session and into the database is not done: it is a real finding, correctly reasoned, and it is a design change rather than a correction. -
S2 β the four sibling endpoints (
delete_user.php, the v1PATCH /users/{id}twin,escalate_ticket's preview branch,test_channel.php) are not fixed here. None is a regression and the branch does not touch any of them; folding an unrelated four-endpoint sweep into a branch under review would make it harder to review, not safer. They are listed under Outstanding and are the next piece of work. -
S9's remaining items β
ticket_merge.phpwriting its own.html, the unauthenticated PHP-version disclosure insetup/, SVG branding uploads, the IIS<handlers>behaviour, andget_attachment.php's missingrealpath()containment β are all accepted and all outstanding.
The criticism was fair: it was largely strpos() over source text, which is why it
certified R4 as passing while the call did nothing. It now carries a Round two section
that asserts behaviour β secrets round-tripped through the accessor rather than merely
being ciphertext at rest, and uploadStoreBytes() fed real Ogg/Opus, MP4, 3GP, HEIC,
vCard, iCalendar and RFC822 bytes with assertions on the returned stored_name, against
negative controls (shell.php, shell.phtml, evil.svg, page.html, .htaccess,
and a traversal-plus-null-byte name) that must still land on .bin. The check that a
file "rotates the session id" now also asserts the file keeps the session open, so the
exact shape of R4 cannot pass again.
131 checks pass, 0 fail.
Everything that was in scope for this branch is done. Nothing on this list is: these are open findings, several of them live and exploitable in the shipped product, deferred because they are separate work β not because they were judged minor.
In the application
-
The four sibling endpoints from S2 β live cross-tenant bugs, reachable today.
api/tickets/delete_user.php(no tenancy check at all),api/v1/resources/users.php(thePATCH /users/{id}twin),api/integrations/escalate_ticket.php(preview=1returns before the service is called),api/messaging/test_channel.phpandslack_diagnose.php. These are not regressions and this branch does not touch them, but neither fact makes them harmless: they are exploitable in the shipped product now, and deferring them was a sequencing decision about what belongs in one reviewable branch β not a judgement that the risk is small. This class is open. -
S6 β open, by design decision rather than by fix. The MFA attempt counter is session-scoped, and a correct password step resets the account and IP counters, so an attacker holding a valid password can loop for unlimited guesses at ~25% more requests. Needs a per-account or per-IP counter in the database.
-
S3's cousins and S9's remaining items, listed above.Both closed in round three (main, 2026-08-12). S3's cousin turned out to be a single guard βTasksService::ticketAccessible()inincludes/services/tasks.php, whose barecatch { return true; }made a lock-wait timeout read as authorisation.β οΈ It was very nearly missed, and the reason is worth keeping: this entry named a class rather than a file, so working down the list, every item with a filename got done and this one did not. It surfaced only when the summary sentence on the wiki was fact-checked. The suite now sweepsincludes/services/*.phpfor the pattern, so the next one cannot be added quietly. -
CSRF tokens across the 369 endpoints that read
php://inputβ open. F7 shipped the minimum viable defence; this is the real one, and until it lands the layer should be read as partial rather than present. -
F10 β
db_verifystill has no dry-run, preview or backup prompt, and the header comment claiming it "never drops anything" is still wrong. -
F4 and F11 β SLA snapshotting and subject access / erasure. Both features.
-
uploadStoreFile()still skips the content check whenfinfois unavailable, where its F1 siblinguploadStoreBytes()refuses. The parent should adopt the stricter rule, on its own change rather than inside a security fix. -
lms/contentandsystem/uploads/brandingdid not get the deny-all treatment; both may legitimately be fetched by the browser and breaking SCORM playback or branding to close a lesser risk was not a trade worth making blind.
Process, from the report's own suggestions
- Private vulnerability reporting is being enabled β
SECURITY.mdalready points at it, and it is the channel we would rather this had arrived on. - Release tagging, so an advisory can say "fixed in 1.4.2" rather than quoting a SHA.
Thank you. The structural observations β the allow-list that fails open, the untyped catch, the missing manifest β were consistently more valuable than the individual bugs they came attached to, and the fixes are shaped around them rather than around the symptoms.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)