Skip to content

Sign In Weaknesses October 2026

Ed Mozley edited this page Nov 12, 2026 · 1 revision

Security: four weaknesses around signing in

Fixed in 3.15.1 Β· Reported privately by a security researcher, who asked not to be named Β· Present in every release up to and including 3.15.0 Β· Modules: sign-in (analysts and the self-service portal)


Timeline

Date What happened
24 October 2026 Reported privately by a security researcher. All four weaknesses were in long-standing sign-in code, present in every release up to 3.15.0.
24 October 2026 Fixed and released in 3.15.1, the same day the report arrived.
12 November 2026 This write-up published. It was held back so installs had time to upgrade before the details were public.

This is not a 5.x problem. All four were fixed nearly three weeks before 5.0.0 came out, and every release from 3.15.1 onwards - 4.x and 5.x included - has the fixes. If you are on 3.15.1 or later, there is nothing to do.

The short version

What Who could use it
1 All summer, the account lockout lifted the moment it was set anyone, against a known username
2 Another website could sign you in as somebody else anyone with an analyst account of their own
3 The forced change of the default password accepted the default anyone who knows the published default
4 Errors before sign-in showed server details anyone, when something went wrong

The first is the serious one: on an install using the shipped settings, the lockout after five wrong passwords did nothing between the end of March and the end of October, so a password could be guessed without limit.

Upgrading is the fix. If your administrator account still uses the published default password, change it β€” the forced-change screen now insists.

1. All summer, the account lockout lifted the moment it was set

What was wrong. After five wrong passwords FreeITSM locks the account for thirty minutes, writing the end of the lock in UTC β€” the database connection always speaks UTC:

"UPDATE analysts SET failed_login_count = ?, locked_until = DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? MINUTE) WHERE id = ?"

and reading it back like this:

$lockedUntil = new DateTime($analyst['locked_until']);
$now = new DateTime('now', new DateTimeZone('UTC'));
if ($now < $lockedUntil) { /* still locked */ }

new DateTime() with no time zone reads the string in PHP's time zone β€” and the shipped config.php sets Europe/London. In winter London is UTC and nothing goes wrong. In British Summer Time it is an hour ahead, so "the lock ends at 14:30 UTC" was read as "14:30 London", which is 13:30 UTC: an hour earlier. A thirty-minute lock had expired half an hour before it was set. The next attempt found it expired, reset the counter to zero, and checked the password.

The address-based ban does not catch this either: it counts attempts at usernames that do not exist and attempts at locked accounts β€” and this account was never locked.

Who was affected: every install whose PHP time zone is ahead of UTC β€” the shipped default from late March to late October; all year east of Greenwich. The Docker image runs PHP in UTC unless TZ is set, so it was only affected if somebody set one.

The same reading made analyst password-reset links expire the moment they were sent, every summer β€” not a security problem, but the reason a reset "never works".

The fix. Every place the sign-in code reads a time back from the database says it is UTC:

$lockedUntil = new DateTime($analyst['locked_until'], new DateTimeZone('UTC'));

β€” the lockout, the password expiry check (in two places), the address-ban countdown and the reset link. The line carries a TRAP: comment, because this is the kind of mistake that comes back: the bug is invisible for five months a year.

2. Another website could sign you in as somebody else

What was wrong. FreeITSM refuses a form posted from another website (CSRF protection) β€” but only when the request carries a session cookie, on the reasoning that with no cookie there is nothing to forge:

if (!csrfHasSessionCookie()) return;                 // no cookie, nothing to forge

That is true of everything except signing in. A page on another site could post an attacker's own username and password to the login page; the reply sets a fresh session cookie, and the visitor is now working inside the attacker's account β€” writing notes, uploading files, typing a password into a form β€” which the attacker reads at leisure. And because the cookie is SameSite=Lax, a browser never sends it on a cross-site post at all, so even a signed-in visitor arrived "with no cookie".

The fix. The four places a password or one-time code is submitted β€” the analyst login, the portal login, and both one-time-code steps β€” get the origin check whether or not there is a cookie:

if (in_array(csrfRequestPath(), CSRF_LOGIN_PATHS, true) && csrfSameOrigin() === false) csrfRefuse('origin');

A real first visit to the login page says Sec-Fetch-Site: same-origin and passes. A request with no browser headers at all is not a browser, so it passes as before.

3. The forced change of the default password accepted the default

What was wrong. A new install's administrator is admin with the password freeitsm, published in the README, and must change it on first sign-in. The change-password endpoint checked one thing β€” at least eight characters β€” and freeitsm is eight characters. So the forced change could be satisfied by typing freeitsm three times.

Looking for the rule turned up seven more places that set a password, each with its own: eight characters in some, six in others, and none at all when an administrator created an analyst.

The fix. One rule, in includes/password_policy.php, called from all eight: at least eight characters; not one of a short list of passwords that are public for this product (freeitsm, and demo1234 from the demo data) or top of every guessing list; and not the current password. πŸ”‘ "Not the current password" is checked only after the current password has been proved β€” checked any earlier, "must be different" would tell someone holding the session whether a guess was right.

4. Errors before sign-in showed server details

What was wrong. The shipped config.php turned PHP's display_errors on, so any warning printed the server's file paths into the page β€” for whoever was looking, including before they had signed in. And four sign-in paths showed an exception's own text, which for a database failure can name tables, columns and the database user.

The fix. config.php now logs errors and shows them only on the command line, or where FREEITSM_DISPLAY_ERRORS is defined in db_config.php (outside the repository, so a developer's setting is never committed). The sign-in paths use dbSafeClientMessage(): a database error is logged and replaced with a plain sentence; an identity provider's own message, which an administrator setting up single sign-on needs, still shows.

πŸ“ Files changed

File Change
🟦 auth/login.php, api/auth/reset_password.php, api/myaccount/verify_login_otp.php database times read as UTC
🟩 includes/csrf.php CSRF_LOGIN_PATHS β€” the origin check, cookie or not
🟨 includes/password_policy.php (new) and the eight places that set a password one rule
🟧 config.php, includes/db_errors.php, the sign-in paths errors logged, not shown

Tested

tests/security-report-2026-10-part2.php, sections 14, 16, 18 and 22, against the real database and web server β€” on 24 October, still British Summer Time, so the lockout test ran on a day the bug was live:

  • An account locked for thirty minutes says it is locked, and stays locked.
  • A cross-site login post with no cookie at all is refused; the same post from the login page itself is answered (the positive control); the portal login is refused too.
  • freeitsm (in any case), demo1234, a short password and the current password are refused; an ordinary new password is accepted.
  • A database error becomes the plain message; an identity provider's message still shows; config.php no longer turns display_errors on.

For developers

  • TRAP: never new DateTime($valueFromTheDatabase) without a time zone. The connection is UTC; PHP's zone is whatever the server says. new DateTime($v, new DateTimeZone('UTC')), or compare in SQL against UTC_TIMESTAMP().
  • A new place that sets a password calls passwordPolicyError().
  • A new sign-in or code-entry endpoint goes on CSRF_LOGIN_PATHS.

Related: CSRF protection β€” Developer Guide Β· A link could change data Β· Bugs resolved

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally