-
Notifications
You must be signed in to change notification settings - Fork 15
Security Hardening 2026 08 Developer Guide
The second round of the August 2026 security work: nine items raised by Erlend Volden when he re-reviewed our fixes to his original report, plus four bugs in our own first-round code.
The plain-language version is Security hardening 2026-08. The first round is on Security, and the full response document lives in the repository at docs/security-review-2026-08.md.
β οΈ Status: not yet merged. All of this is onsecurity/findings-2026-08, rebased ontomainand force-pushed, pending Erlend's re-check.maindoes not have it yet, so anything below describing current behaviour describes the branch.Branch note. The F5 commit is absent by design β it reached
mainearlier as cherry-pickd3d1579f, so the rebase correctly dropped it. Its content is present in the tree. One commit per finding, as before, so each can be reviewed in isolation.
Colour key: ποΈ schema Β· βοΈ shared service Β· π API Β· π₯οΈ page Β· π§° script Β· π§ͺ tests Β· π i18n Β· π docs
| π¨ | File | What changed |
|---|---|---|
| βοΈ | includes/tenancy.php |
S3. tenancyTablesReady(), tenantCount(), tenancyColumnExists() β fail closed |
| π | api/tickets/save_user.php |
S1. Authorise the resolved destination tenant, not the sent one |
| π | api/self-service/change_password.php |
R4. session_start() instead of read_and_close
|
| π | api/auth/oidc_callback.php |
S9. Honour must_change_password
|
| βοΈ | includes/encryption.php |
R1. New settingsGetDecrypted() / settingGetDecrypted()
|
| π₯οΈ | system/webhooks/index.php |
R1. Decrypt before printing the cron URL |
| π§° | scripts/cron_token.php |
R1. New. Prints decrypted cron tokens; --url for full URLs |
| π | api/knowledge/send_share_email.php |
R2. Decrypt on read, driven by the rule |
| π | api/knowledge/save_email_settings.php |
R2. encryptValue() + isMaskedNoChangeValue()
|
| π | api/knowledge/get_email_settings.php |
R2. maskSecret() instead of the raw column |
| βοΈ | includes/uploads.php |
R3. UPLOAD_TYPES_AUDIO/VIDEO/MAIL, attachmentAllowedTypes()
|
| βοΈ | includes/settings_keys.php |
R3. Registers attachment_allowed_extensions
|
| βοΈ | includes/messaging/ingest.php |
R3. Passes the configured allow-list |
| π |
api/self-service/create_ticket.php Β· reply_ticket.php Β· api/tickets/check_mailbox_email.php
|
R3. Same |
| π₯οΈ | system/security/index.php |
R3 UI, and minIpAttempts min="0"
|
| ποΈ | database/freeitsm.sql |
S5. Admin seed sets must_change_password
|
| ποΈ | api/system/db_verify.php |
S5 catch-up migration Β· S7 rename collision Β· R3 seed |
| βοΈ | includes/session_security.php |
Strict-overwrite and X-Forwarded-Proto comma-list |
| π | api/myaccount/verify_login_otp.php |
unset() ordering vs the audit log |
| βοΈ |
config.php Β· docker/config.php Β· docker-compose.yml
|
TRUST_PROXY_HTTPS documented |
| π§ͺ | tests/security-findings/run.php |
Round-two section: round-trip and behavioural assertions |
| π |
lang/en/system.php Β· lang/en/tickets.php
|
R3 setting strings; corrected SLA help text |
| π |
docs/*-cron-setup.md (Γ4) Β· tickets/help-sla.php
|
R1. Stop telling readers to SELECT the token |
Inverting the secret rule in F3 was correct, but it widened what is ciphertext on disk. Every site still doing a bare SELECT setting_value carried on passing that ciphertext to its consumer. Two features broke (R1, R2) and neither threw.
The first-round test asserted "every secret in the database is ciphertext" β and passed throughout, because a read path that never decrypts is indistinguishable from a correct one when you only look at storage. Assert the whole round-trip.
F9 removed the fail-open catch from seven analystCanAccess*() functions. It did not touch tenancyTablesReady(), which every one of them depends on β so the fail-open simply moved up one frame (S3).
// before
if ($tenantSent && $tenantId !== null && !analystCanAccessTenant($conn, $analystId, $tenantId)) {Gating on $tenantSent meant an omitted tenant_id skipped the check entirely. The create branch then called resolveTenantForNewUser(), which maps the email domain against tenant_domains. Net effect: an analyst scoped to A posts
{"email":"victim@companyB.com","password":"chosen"}and receives a portal account owned by B with a known password, then authenticates as it. includes/knowledge/portal_reader.php scopes by the signed-in user's tenant, so B's restricted articles follow.
Pre-existing rather than introduced by F9 β that commit added the analystCanAccessUser() subject check and left this gate as found.
// after
$destinationTenantId = $tenantSent
? $tenantId
: ($id ? null : resolveTenantForNewUser($conn, $email));
if (isMultiTenant($conn)
&& $destinationTenantId !== null
&& !analystCanAccessTenant($conn, (int)$_SESSION['analyst_id'], $destinationTenantId)) {
β οΈ TheisMultiTenant()short-circuit is load-bearing. On a single-tenant install an ordinary analyst has noanalyst_tenant_accessrows, sogetAccessibleTenantIds()returns[]andanalystCanAccessTenant()denies everything β the suggested fix applied alone would have broken requester creation on every single-company install. Every other caller of that function is an inherently multi-tenant action (move_to_company.php,set_active_tenant.php,move_ticket_to_company.php,resolve_triage.php), which is why none of them needed the guard. It matches whatanalystCanAccessTicket()andanalystCanAccessUser()already do on line 1.
Editing with no tenant_id means "leave the company alone", so there is no destination to authorise; creating with none means "work it out", so there is.
tenancyTablesReady() ended in a bare catch (Exception $e) { $ready = false; }. That value propagates: $ready = false β tenantCount() returns 1 β isMultiTenant() false β every guard returns true on its first line, cached in a static for the request.
So a lock-wait timeout, dropped connection or permissions error on one SELECT 1 FROM tenants disabled all tenant isolation β the precise outcome F9 existed to prevent.
Now only dbErrorIsMissingTable() degrades to "single company". Anything else reports ready and lets the real query fail into tenancyDegradeAllowed(), which denies; deliberately uncached so a transient error cannot pin the answer. tenantCount() reports 2 on an uncountable table for the same reason, and tenancyColumnExists() answers true, since callers read false as "not migrated β allow" (analystCanAccessArticle() returns true outright on it).
Erlend filed this as a follow-up; we pulled it forward to block the merge. Shipping a commit titled "stop the tenant guards failing open" with the switch above them still failing open leaves the finding looking fixed when it is not.
One correction to the report: it says every guard short-circuits to
return true.analystCanAccessTenant()actually degrades to[TENANCY_FALLBACK_TENANT_ID]β narrow, not open. The substance holds foranalystCanAccessTicket()/analystCanAccessUser(), which is where it matters.
docker-compose.yml:31 mounts database/freeitsm.sql at /docker-entrypoint-initdb.d/01-schema.sql. That seed omitted must_change_password, which defaults to 0. Because the row then exists, db_verify.php's COUNT(*) === 0 test is false and the seed that does set the flag never runs. admin / freeitsm stayed valid indefinitely on the flagship path, contradicting README.md.
Both seeds now set it. A catch-up migration handles existing installs and is deliberately narrow β it acts only when the published password still verifies:
if ($defAdmin
&& (int)$defAdmin['must_change_password'] === 0
&& password_verify('freeitsm', (string)$defAdmin['password_hash'])) {Testing the password rather than the username means an admin who changed theirs is never nagged, and an unrelated account named admin is untouched. Wrapped so dbErrorIsUnknownColumn() is tolerated on a part-migrated install.
Confirmed against a live install that turned out to still be on
admin/freeitsm.
@rename($oldAbs, $newAbs) had no destination check. Two attachments on one email collide once both are rewritten: report.htm + report.html β report.bin, as do index.html + index.php. POSIX rename() overwrites silently, and both rows were then updated to the survivor's path. Now it walks a -1, -2, β¦ suffix to a free name; the display filename is a separate column, so the suffix is never surfaced.
The sweep's denylist gains phar, pht, hta, mhtml, svgz, xml, xsl, xslt, cer. It stays a denylist β unlike the ingest side, which is an allow-list β because it inspects names already on disk rather than deciding what may arrive. .xml/.xsl matter: a legacy .xml served as text/xml can carry a self-referencing xml-stylesheet processing instruction and execute script same-origin.
β οΈ Writing that PI into a//comment closes PHP mode. The?>inside it cost a parse error.
api/auth/oidc_callback.php set $_SESSION['analyst_id'] without consulting must_change_password, so SSO was the only path around enforcePasswordChangeGate(). It now sets $_SESSION['password_expired'] from the flag.
Only the flag, never the expiry policy β an SSO account may have no local password to expire, and auth/login.php draws the same distinction for LDAP via $skipPasswordExpiry. The reasoning: the flag marks an account whose local password is the published default, and that password keeps working at auth/login.php regardless of how the analyst arrived.
oidcLoadAnalyst() uses SELECT *, so the column is already present.
isEncryptedSettingKey() matches /_(password|secret|token|api_key)$/i, so all four *_cron_token values are encrypted in place. All four workers decrypt correctly. The break was confined to the human-facing side:
| Site | Problem |
|---|---|
system/webhooks/index.php |
Raw SELECT, no encryption.php include; ?token=ENC%3A⦠in the copy-paste URL, worker 403s |
docs/sla-cron-setup.md Β· docs/webhook-cron-setup.md Β· tickets/help-sla.php
|
Told the reader to SELECT setting_value
|
docs/sla-cron-setup.md |
Said to rotate by UPDATE β works, but silently returns the column to cleartext until the next Verification |
docs/workflow-scheduled-cron-setup.md Β· docs/integration-poll-cron-setup.md
|
Named the column without a query β vague rather than wrong |
New in includes/encryption.php:
function settingsGetDecrypted(PDO $conn, array $keys): array
function settingGetDecrypted(PDO $conn, string $key, string $default = ''): stringAnything reading a setting in order to use it goes through these. decryptValue() passes plaintext through, so they are safe pre-migration, on uncovered keys, and on re-runs. A value that will not decrypt logs and returns '' rather than the ciphertext β the failure mode being fixed.
scripts/cron_token.php is CLI-only, since the point of the encryption is that reading the row is not enough.
A sweep for other raw readers of encrypted settings found none. The three
save_ai_settings.phpwriters all callencryptValue(), andtest_ai_key.php/test_ai_connection.php/includes/rfp_ai.phpall decrypt. R1 and R2 were the only two.
Three-sided:
-
Read β
send_share_email.phpbuilt$settingsfrom a rawLIKE 'knowledge_email_%'query and passed it tosendSmtpEmail(), authenticating with the literalENC:β¦. Now decrypts per row onisEncryptedSettingKey()rather than a hardcoded key name, so a future secret under the prefix is covered. -
Write β
save_email_settings.phpstored it raw while wrappingai_api_keyandopenai_api_keyinencryptValue()two lines below. The value therefore oscillated (encrypted by Verify, plaintext by Save) rather than failing consistently, which is why it survived review. -
Display β
get_email_settings.phpreturned the column verbatim: plaintext before the rule, ciphertext after. NowmaskSecret(decryptValue(...)), withisMaskedNoChangeValue()on the save side so a form posted back untouched does not store****abcdas the password.
UPLOAD_TYPES_ATTACHMENT was UPLOAD_TYPES_DOCUMENT + UPLOAD_TYPES_IMAGE. No caller passes a custom list, so it governed the messaging media path too. Nine of the nineteen extensions messagingExtForMime() can emit were rejected:
| accepted (10) |
doc docx gif jpg pdf png txt webp xls xlsx
|
| rejected (9) |
3gp aac amr heic m4a mp3 mp4 ogg vcf
|
So a WhatsApp voice note (audio/ogg), video (video/mp4), iPhone photo (image/heic) and contact card (text/vcard) were quarantined by the module built to receive them, and six ATTACHMENT_SERVE_TYPES entries were unreachable.
Added UPLOAD_TYPES_AUDIO, UPLOAD_TYPES_VIDEO, UPLOAD_TYPES_MAIL, plus heic/heif, and application/CDFV2 on doc/xls/ppt β modern libmagic reports that for unclassifiable OLE2 files, so genuine Office documents were failing gate 2. CDFV2 is listed only against extensions already on the allow-list, never accepted globally, since it is equally the shape of .msi and .msg.
Which types an install accepts is now attachment_allowed_extensions, read by attachmentAllowedTypes():
$types = array_intersect_key(UPLOAD_TYPES_ATTACHMENT, array_flip($wanted));
return $cached = ($types ?: UPLOAD_TYPES_ATTACHMENT);
β οΈ It can only narrow. The value is intersected with the catalogue, so an unknown extension has no mime list to be validated against and is discarded βphpin the box is inert. Empty means the whole catalogue, so installs inherit future safe types rather than freezing at their install date; an emptied box returns the default rather than refusing everything, which would be indistinguishable from the product being broken.
Verified against attachment_allowed_extensions = 'pdf, png, php, phtml, exe' β effective list pdf, png.
| Where | Problem |
|---|---|
sessionCookieParamsAreHardened() |
Required samesite === 'Lax' exactly, so an admin-configured Strict failed the check and line 122 then overwrote their Strict cookie with Lax. Now "at least as strong as" β Lax or Strict pass, and Secure on when only off is required is not a failure |
requestIsHttps() |
=== 'https' against X-Forwarded-Proto, which chained proxies append to (https, http). Now takes the first comma-separated token |
TRUST_PROXY_HTTPS |
Appeared only in a code comment β not config.php, docker/config.php, README.md or docker-compose.yml. The commonest production topology (this image behind nginx/Traefik/Caddy) would therefore ship cookies without Secure indefinitely. Documented in all three, still off by default since the header is client-settable |
verify_login_otp.php |
Read $_SESSION['mfa_pending_username'] after the unset() that removed it, so every abandoned-MFA row in login_attempts logged unknown
|
php tests/security-findings/run.php http://localhost/freeitsm-app/ β 131 pass, 0 fail.
The round-one suite was largely strpos() over source text, which is why it certified R4 while the call did nothing. The new section asserts behaviour:
- every cron token and
csat_token_secretis ciphertext at rest and plaintext throughsettingsGetDecrypted()β the missing half; -
uploadStoreBytes()fed structurally valid Ogg/Opus, MP4, 3GP, HEIC, vCard, iCalendar and RFC822 bytes, asserting on the returnedstored_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 effective attachment list is a subset of the catalogue and reaches nothing executable;
-
save_user.phpauthorises the resolved destination and still short-circuits on single-tenant; - the tenancy helpers forgive only a missing table;
- the SQL seed sets the flag, and an admin still on the published password has been flagged.
The existing "rotates the session id" check now also asserts the file keeps the session open, so R4's exact shape cannot pass again.
β οΈ A malformed fixture failed first and looked like a product bug. A hand-built.oggthat was not a valid Ogg stream detected asapplication/octet-streamand was quarantined. Real Ogg/Opus and Ogg/Vorbis pages both detect asaudio/ogg. Build structurally valid fixtures, or you debug your own test.
R4 was proved with both controls. Against the unfixed code the response carried no Set-Cookie at all and the old session file survived on disk; against the fix the id rotates and session_regenerate_id(true) deletes the old file.
-
S2 β four sibling endpoints, the next piece of work.
api/tickets/delete_user.php(no tenancy check at all; its refusal text is also a cross-tenant oracle),api/v1/resources/users.php(apiKeyCanAccessTenantRow()exists and is simply not called),api/integrations/escalate_ticket.php(preview=1returns before the service, so the "every check lives in the service" invariant holds for writes but not previews),api/messaging/test_channel.phpandslack_diagnose.php. None are regressions and the branch does not touch them β folding an unrelated sweep into a branch under review makes it harder to review. -
S6 β MFA counter. Session-scoped, and a successful password step resets
failed_login_countandlocked_until, whilerecordIpAttempt()only fires for an already-locked account or an unknown username. An attacker holding a valid password loops for unlimited guesses at ~25% more requests. Needs a per-account or per-IP counter in the database β a design change, not a correction. -
S4 β CSRF.
request_guard.phprefusestext/plainonly.application/x-www-form-urlencodedis CORS-safelisted too, so relabelling the same body is a no-preflight request and the endpoint'sjson_decode(file_get_contents('php://input'))parses it. Unique coverage is the JS-free form variant. The write-up now says this rather than claiming a second line of defence, and two related claims are corrected: the front end does not sendapplication/jsonat all fetch sites, andauth/login.phpis a tokenless form POST. The token layer remains the actual fix. -
S9 remainder β
ticket_merge.phphand-writing.html; unauthenticated PHP version/extension disclosure insetup/; SVG accepted bysave_branding.php; IIS<handlers><clear/>returning 500.19 rather than a clean deny;get_attachment.phplackingrealpath()containment. - From round one β
uploadStoreFile()still skips the content check withoutfinfowhere its sibling fails closed;lms/contentandsystem/uploads/brandinghave no deny-all.
Most of this round tightened things, and five changes can now refuse something that previously worked. If a regression turns up, start here rather than in the diff β the symptom rarely names the cause.
| Symptom a user reports | Likely cause | Confirm it |
|---|---|---|
| "I can't create a customer contact" / You do not have access to that company | S1. The analyst genuinely lacks the destination company, or the email domain resolves to one they can't reach | Multi-company installs only. Check analyst_tenant_access for that analyst, and tenant_domains for the email domain |
| Records intermittently 404 / not found that used to load | S3. The tenancy check is now denying on a database error instead of allowing |
grep 'tenancy:' the PHP error log β see 7.2 |
| Admin is forced to change password unexpectedly |
S5. The account was still on the published freeitsm password |
Intended. SELECT must_change_password FROM analysts WHERE username='admin'
|
| SSO users hit the change-password screen |
S9. Their account carries must_change_password
|
Intended. Same query for their account |
| An attachment type stopped being accepted |
R3, only if someone has set attachment_allowed_extensions
|
SELECT setting_value FROM system_settings WHERE setting_key='attachment_allowed_extensions' β empty means everything safe, which is the default |
β οΈ S3 is the one to watch. It deliberately converted a fail-open into a fail-closed. On a healthy install it changes nothing, but on one with a genuinely odd schema state it will now deny where it used to wave things through. That is the correct direction, but it means a database problem now looks like a permissions problem.
Every new failure path logs. None of them is silent, which was a deliberate response to R4 having failed silently.
tenancy: tenants table unreadable (β¦) β S3. Something is wrong with the DB, not with permissions
tenancy: could not count tenants (β¦) β S3, same
tenancy: could not inspect <table>.<col> β S3, same
tenancy: denying access after an unexpected database error β the F9 path, unchanged
settings: could not decrypt <key> β R1. Encryption key mismatch β see 7.3
knowledge email: could not decrypt <key> β R2, same cause
session_security: cannot rotate the session id, output already started at β¦
If you see any tenancy: line, treat it as a database fault. The access denial is a symptom, not the problem.
settingsGetDecrypted() returns '' when a value will not decrypt, rather than passing the ciphertext through. That is the right call β handing ENC:β¦ to an SMTP server is what R2 was β but it changes the shape of the failure:
-
Before: a broken secret surfaced as a visibly wrong value (
ENC:β¦in a URL). - Now: it surfaces as an empty value, plus a log line.
So "the cron token is blank" and "the SMTP password is empty" both mean the encryption key does not match the data, not the setting is unset. Usual cause: the key file at ENCRYPTION_KEY_PATH was replaced or lost. php scripts/cron_token.php will show (not seededβ¦) for the same reason.
If one of these has to go in a hurry, each is independently revertable β they were committed one per finding for exactly this reason.
git log --oneline main..security/findings-2026-08
git revert <sha>
Two that need care rather than a plain revert:
-
S5 β reverting the code does not clear a
must_change_passwordalready set.UPDATE analysts SET must_change_password = 0 WHERE id = ?if you need the account back immediately. -
R3 β reverting narrows the allow-list again, which will start quarantining media that has been arriving fine. Prefer setting
attachment_allowed_extensionsto the narrower list you want and leaving the code alone.
S3 should not be reverted. If it is causing denials, the database fault it is reporting is the thing to fix.
php tests/security-findings/run.php http://localhost/freeitsm-app/
131 checks. It exercises behaviour rather than source text, so a genuine regression in any of this round should fail it rather than pass quietly β which is the whole point of the rewrite.
- Security hardening 2026-08 β the same items, in plain English
- Security Β· Database Verification β Developer Guide
- Admin Access Control Β· Roles & Permissions
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)