Skip to content

Security

Ed Mozley edited this page Aug 12, 2026 · 11 revisions

Security

FreeITSM ships with several layers of defence covering authentication, sensitive-data storage, and brute-force resistance. This page is the security reference for evaluators and administrators.

Authentication

Analyst login

  • Username + password stored as bcrypt hashes in analysts.password_hash
  • Hashing uses PHP's password_hash() with PASSWORD_DEFAULT; authentication uses password_verify() to re-hash the input and compare, so passwords are one-way β€” they cannot be reversed from the stored hash
  • Per-hash salt: every call to password_hash() generates a unique random salt, so the same password produces a different hash each time
  • Cost factor: bcrypt runs configurable work rounds (currently 2^12 = 4,096 iterations), keeping brute-force attacks slow
  • Password expiry policy: a configurable maximum password age (30–365 days), with a forced password change on next login once expired
  • Account lockout: a configurable maximum number of failed login attempts before a temporary account lock, with a configurable lockout duration
  • Minimum password length: 8 characters (enforced on change and reset)
  • Default admin: admin / freeitsm. Since August 2026 FreeITSM forces you to change it β€” the account is seeded with must_change_password = 1, on both the db_verify.php runtime path and the SQL script that Docker mounts as an init script, so the published password cannot survive first sign-in. Database Verification also detects an existing admin account still using the published password and flags it, so installations created before that change get caught too

Multi-Factor Authentication (TOTP)

  • Pure-PHP implementation of RFC 6238 (TOTP) and RFC 4226 (HOTP)
  • No external dependencies β€” uses PHP's hash_hmac() and random_bytes()
  • 6-digit codes, 30-second time step, Β±1 window tolerance (90s)
  • Compatible with any standard authenticator: Google Authenticator, Microsoft Authenticator, Authy, 1Password, Bitwarden
  • Setup: account menu β†’ MFA β†’ scan QR (or paste secret) β†’ verify a code β†’ enabled
  • Secrets encrypted at rest with AES-256-GCM in analysts.totp_secret
  • Disabling MFA requires the account password β€” preventing unauthorised deactivation from an unattended session
  • Trusted devices: users can opt in to skip the OTP prompt on a trusted browser for a configurable number of days β€” cookie-based, with SHA-256-hashed tokens stored server-side

MFA is optional and per-analyst β€” analysts with MFA disabled log in with just username and password as normal. When an analyst has enabled MFA, the login flow adds a second verification step:

  1. Analyst enters username and password on login.php
  2. Password verified β†’ MFA pending state stored in the session (mfa_pending_analyst_id etc.) β€” analyst_id is NOT set yet
  3. Login page renders the OTP form (shield icon, 6-digit input, auto-submit)
  4. Analyst enters the code from their authenticator app β†’ JS calls api/myaccount/verify_login_otp.php
  5. Server decrypts the stored TOTP secret and verifies the code (Β±1 time window)
  6. On success: $_SESSION['analyst_id'] is set, the pending state is cleared, and the analyst is redirected to index.php

The "Cancel and return to login" link clears the pending state so a different analyst (who may not have MFA) can log in on the same browser.

Password reset by email

  • "Forgot password" sends a single-use token via email (configurable mail provider)
  • Token expires after a short window
  • The expiry window is one hour
  • Token comparison uses hash_equals() (timing-safe)

Single sign-on (SSO / OIDC)

  • Optional OpenID Connect SSO alongside local login β€” works with Keycloak, Microsoft Entra ID, Okta, Google Workspace, Authentik, etc.
  • Authorization Code + PKCE (S256), with state and nonce validation; ID-token signature verified against the provider's JWKS (vendored firebase/php-jwt)
  • Client secrets encrypted at rest; local login retained as a break-glass fallback so a down IdP can't lock anyone out
  • Full details: Single Sign-On (SSO / OIDC)

LDAP / Active Directory sign-in

  • Optional directory sign-in alongside OIDC β€” passwords are checked against your existing directory (Active Directory, OpenLDAP, FreeIPA, 389) by bind, people can be auto-created on first login, and access is gated by directory group
  • Guards against the RFC 4513 unauthenticated-bind trap: an empty (or NUL-containing) password is rejected before any network call
  • The read-only service account's password is encrypted at rest and never returned to the browser
  • Full details: LDAP & Active Directory

IP-based brute-force protection

  • Repeated failed login attempts from the same IP are throttled
  • Both username and IP-level lockouts to defend against credential stuffing
  • Escalating bans for IPs attempting logins against non-existent or locked accounts, with configurable thresholds and 24-hour bans
  • Login attempts (success + failure) logged with IP and user agent in the Reporting module

Authorisation

Authorisation is three layers, each answering a different question, each enforced server-side and failing closed. Hiding a button is never the control; it is at most a courtesy on top of one.

Layer Question Gate Default
1 β€” Module access Can they enter this module? requireModuleAccess() / requireModuleAccessJson() Absence = everything (an analyst with no restriction gets all modules)
2 β€” Capabilities (Roles) Can they administer this part of it? requireCapability(Cap::X) / requireCapabilityJson(Cap::X) Deny by default (no role, no settings)
3 β€” Administrator Can they run the System module? requireAdminJson() / the page gate Deny by default (analysts.is_admin)

is_admin is a superset: an administrator implicitly holds every capability, which is what makes Layer 2's deny-by-default safe to switch on β€” the tightening can never lock the instance owner out.

Layer 1 β€” Module access control

  • Per-analyst and per-team module grants, managed at System β†’ Analysts / Teams; unioned at one choke-point (getAnalystAllowedModules()).
  • Enforced on the launcher cards, the waffle menu, and on the pages and APIs themselves β€” a restricted analyst cannot simply type the URL.
  • Checks are authoritative (re-read from the database per request), so a just-revoked analyst is stopped even on a stale session.
  • See Module Access Control.

Layer 2 β€” Capabilities / Roles

  • Permission goes down to the individual settings tab, so someone can maintain the ticket statuses without reaching the mailbox OAuth credentials two tabs along.
  • A settings tab you lack is not rendered into the page at all β€” absent, not hidden. There is no DOM to un-hide.
  • Every settings write endpoint carries its own guard. Operational reads are deliberately not guarded β€” they are what the everyday module needs to function. Except a read that returns credentials: ask what the response contains, not just what the endpoint does.
  • Settings that share the generic save_system_settings.php endpoint are authorised per setting key (includes/settings_keys.php), because one guard cannot cover five callers with five audiences. A key no module claims cannot be written through it.
  • See Roles & Permissions and the Developer Guide.

Layer 3 β€” Administrator (System)

  • analysts.is_admin gates the whole System module β€” page and API (includes/admin_api_guard.php). Before this, every analyst was effectively an administrator and could self-escalate.
  • The last administrator is protected from removal.
  • See Admin Access Control.

Auditing it β€” the guard nobody wrote

Type-safe permissions catch a misspelled check. Nothing catches a check somebody simply forgot to write β€” no language feature saves you from a line that isn't there, and that omission has repeatedly turned out to be the real hole.

System β†’ Debug tools β†’ D005 (Endpoint permission coverage) reads every endpoint under api/ and reports what actually guards each one β€” a capability, administrator-only, module access, an API key, a webhook signature, a URL token, "logged in and nothing more", or nothing at all β€” ranked by how much damage the gap allows. It also flags capabilities offered on System β†’ Roles that no endpoint enforces (a tick-box granting nothing), and capabilities passed as a bare string instead of a Cap:: constant.

Run it after adding endpoints and before a release. Endpoints that are public by design (login, self-service, the REST API, inbound webhooks) and those acting only on the caller's own account (your own password, your own MFA) are declared as exclusions with reasons, so the findings stay short enough to act on.

Multi-tenancy and team filtering

  • Company (tenant) access is scoped per analyst and per team, unioned the same way, and applied at the query level.
  • The Tickets module filters department visibility by team membership (get_my_departments.php vs get_departments.php).
  • The analyst's team memberships are stored in the session at login, and API endpoints filter by team-accessible departments.
  • An analyst with no team memberships sees everything β€” the unrestricted (admin) case.
  • See Multi-Tenancy.

Encryption at Rest

AES-256-GCM authenticated encryption for sensitive database values.

  • Key file: stored outside web root at C:\wamp64\encryption_keys\sdtickets.key
  • Key: a 256-bit random key
  • Nonce: a fresh 96-bit random IV per encryption, so the same value encrypted twice produces different ciphertext
  • Auth tag: 128-bit β€” detects any tampering with encrypted data
  • Format: ENC: + base64(IV + auth tag + ciphertext) β€” auth tag prevents tampering
  • Migration-safe: values without ENC: prefix pass through unchanged for gradual rollout
  • Key generation: one-click from System β†’ Encryption (or php -r "echo bin2hex(random_bytes(32));")
  • No regenerate button β€” preventing accidental key destruction is more important than convenience

Encrypted columns

system_settings:

  • vcenter_server, vcenter_user, vcenter_password
  • knowledge_ai_api_key, knowledge_openai_api_key
  • intune_tenant_id, intune_client_id, intune_client_secret
  • (Per-feature AI keys β€” <ns>_api_key for knowledge_ai, cmdb_ai, workflow_ai, forms_ai, tickets_reply_cleanup, plus RFP Builder β€” each encrypted and masked separately; see AI Providers)

target_mailboxes:

  • azure_tenant_id, azure_client_id, azure_client_secret
  • oauth_redirect_uri, imap_server, target_mailbox
  • imap_username, imap_password, smtp_server (basic IMAP/SMTP mailboxes)

analysts:

  • totp_secret

Masked API responses

A subset of true secrets listed in MASKED_SETTING_KEYS are returned as ****<last4> rather than plaintext by api/settings/get_system_settings.php. The corresponding save endpoints treat blank or asterisk-prefixed submissions as "leave unchanged" so the user can re-save the settings form without re-typing the secret each time.

The masked set covers vcenter_password, knowledge_ai_api_key, knowledge_openai_api_key and intune_client_secret, plus every per-feature AI key (see AI Providers) β€” i.e. the values that are secrets, as opposed to encrypted-but-displayable configuration such as server names. The "leave unchanged" convention is applied by api/settings/save_system_settings.php.

Session Management

  • Standard PHP sessions (session_start())
  • Session key: $_SESSION['analyst_id'] for analyst auth, $_SESSION['ss_user_id'] for self-service portal users (separate auth domains)
  • Password-expiry guard in includes/waffle-menu.php β€” if $_SESSION['password_expired'] is set, every page redirects to force_password_change.php until resolved

Injection Defences

  • SQL injection: PDO prepared statements throughout β€” parameters are never concatenated into SQL
  • XSS: server-side output encoding with htmlspecialchars(); client-side rendering escapes untrusted values via the DOM textContent β†’ innerHTML pattern

Audit Trails

  • Every ticket change is written to the ticket's audit trail
  • Login attempts (success and failure) are logged with IP and user agent β€” see IP-based brute-force protection above

Email Security

  • OAuth 2.0 for Microsoft 365 and Google Workspace mailboxes β€” no plaintext mailbox passwords
  • Mailbox whitelist: per-mailbox domain and email-address whitelisting (mailbox_email_whitelist table); non-whitelisted senders are rejected and logged. A mailbox with no whitelist entries accepts all senders β€” the whitelist only restricts once at least one entry exists
  • Mailbox activity log: every email imported or rejected during mailbox processing is recorded in mailbox_activity_log (action, sender, subject, reason, resulting ticket)
  • Rejection actions: configurable per-mailbox actions for rejected emails (delete, move to Deleted Items, mark as read)
  • Import actions: configurable post-import actions (delete, move to folder) with folder existence verified via Graph API before saving

File Uploads

  • Module-specific upload folders (e.g. tickets/attachments/, lms/content/, forms/uploads/) β€” each is gitignored
  • Generated filenames; original names stored in DB for display
  • Served via PHP endpoints that check session auth before serving the file

API Keys (External Access)

For the browser extension and external integrations (e.g. PowerShell asset inventory):

  • API keys generated at Software β†’ Settings β†’ API Keys
  • Rate limited: 60 requests per minute per key (configurable in System Settings)
  • Bearer-token style header
  • Revocable per-key with last-used tracking

Security disclosures and known open findings

FreeITSM was independently audited in August 2026 by Erlend Volden, who reported privately, re-reviewed the fixes, and found a further nine issues in that second pass. Both rounds are documented in full:

⚠️ Read the outstanding section on those pages. Everything reported in scope was fixed and merged, but several related problems were deliberately deferred as separate work and are still live in the current code β€” most importantly four API endpoints that do not correctly check which customer company the caller is allowed to touch. They are documented rather than quietly carried.

To report a vulnerability, see SECURITY.md in the repository.

Going Live Checklist

When moving from evaluation to production:

  1. Change the default admin password (FreeITSM now forces this at first sign-in)
  2. Delete the /setup folder β€” it contains diagnostic info useful only during install
  3. Generate a unique encryption key if you used a sample one
  4. Back up the encryption key file β€” losing it makes encrypted columns unrecoverable
  5. Configure OAuth for any mailboxes β€” avoid IMAP/password where possible
  6. Enable MFA on the admin account
  7. Restrict module access per analyst at System β†’ Modules
  8. Configure the password reset email so analysts can recover access without admin intervention
  9. Review whitelist entries for each mailbox to limit who can create tickets via email

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally