Skip to content

Mailbox Sign In Was Not Checked

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

Security: mailbox sign-in was not checked

Fixed in 3.15.1 Β· Found while fixing a private security report Β· Present in every release up to and including 3.15.0 Β· Modules: Tickets (mailboxes)


Timeline

Date What happened
24 October 2026 Found while working through a private security report (see Four weaknesses around signing in). The mailbox sign-in code itself was much older - it had been there since mailboxes could first be signed in to.
24 October 2026 Fixed and released in 3.15.1, the same day.
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. It was 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 fix. If you are on 3.15.1 or later, there is nothing to do.

The short version

A Microsoft 365 or Gmail mailbox is connected to FreeITSM by signing in to it: Tickets β†’ Settings β†’ Mailboxes β†’ Authenticate sends you to Microsoft or Google, and they send you back to FreeITSM with a one-time code that FreeITSM exchanges for the mailbox's access tokens.

The page they send you back to did not check that the sign-in had been started from FreeITSM, or that anybody was signed in to FreeITSM at all. So it would accept a code from a sign-in that somebody else had started, for an account of their choosing, and save that account's tokens against your mailbox.

What that could do. The service desk mailbox would then be connected to the other person's account. FreeITSM would import their inbox as tickets, and send its replies to your customers through their account, where they could read them. A mismatch warning would appear on the mailbox, because the account that signed in would not match the mailbox's address β€” but only if somebody looked.

Who could do it: somebody able to complete a sign-in with the same app registration your mailbox uses. That is easiest when the registration accepts accounts from any organisation, and hardest when it is limited to yours. It needed no FreeITSM account.

Upgrading is the fix. If you want to be sure nothing happened, open Tickets β†’ Settings β†’ Mailboxes: a mailbox whose signed-in account does not match its address shows a warning. Signing it in again replaces its tokens.

What was wrong

Two things, either of which would have been enough to stop it.

The state was never checked. OAuth has a value made for exactly this, state: the app picks something unguessable when it starts a sign-in, the provider hands it back unchanged, and the app refuses a callback whose state it did not issue. FreeITSM's mailbox sign-in made one up in the browser β€”

const state = 'mailbox_' + id + '_' + Math.random().toString(36).substring(2, 18);

β€” and the callback read the mailbox id out of it and ignored the rest:

if (preg_match('/^mailbox_(\d+)_/', $state, $matches)) {
    $mailboxId = (int)$matches[1];
}

So any state of the right shape was accepted, for any mailbox.

The callbacks needed no sign-in. auth/oauth_callback.php and auth/google_oauth_callback.php never looked at the session.

The single sign-on callback, written later, does all of this properly β€” it stores the state in the session when the sign-in starts and refuses anything else:

if ($state === '' || empty($_SESSION['oidc_state']) || !hash_equals($_SESSION['oidc_state'], $state)) {
    ssoBail('Security check failed (state mismatch). Please try signing in again.');
}

The mailbox callbacks predate it and were never brought up to the same standard. They were found because the report's first finding β€” endpoints that change data on a GET β€” sent a sweep through every file that writes, and a callback is a GET that writes.

The fix

The same pattern as single sign-on.

  • The server issues the state. New api/tickets/mailbox_oauth_start.php (a POST, needing the mailbox permission) makes it from 16 random bytes and remembers it in the session β€” which mailbox, which analyst, and an expiry fifteen minutes away. The settings page asks for it before sending you to Microsoft or Google.
  • The callback checks it. includes/mailbox_oauth_state.php accepts a state only if this session was issued it, for the analyst signed in now, and in date β€” and only once: it is used up whether or not the sign-in then succeeds. The mailbox id comes from what was issued, never from the URL.
  • Anything else stops with "Security check failed" before any code is exchanged.

πŸ“ Files changed

File Change
🟦 api/tickets/mailbox_oauth_start.php new β€” issues the state
🟩 includes/mailbox_oauth_state.php new β€” checks and uses it up
🟨 auth/oauth_callback.php, auth/google_oauth_callback.php require it
🟧 tickets/settings/index.php asks the server for the state

Tested

tests/security-report-2026-10.php, section 8:

  • The rule: an issued, in-date state gives back its mailbox; the second use is refused; an expired one, one issued to another analyst, and one never issued are all refused.
  • Through the real web server: both callbacks refuse a state nobody issued, before any exchange, and the mailbox's stored tokens are unchanged. A state issued by the start endpoint is refused in somebody else's session. πŸ”‘ Positive control: in its own session the same state passes the check and goes on to Microsoft β€” with a deliberately fake code, which Microsoft rejects, so the real mailbox is never touched β€” and the tokens are still unchanged afterwards.

Not tested: a complete real sign-in end to end, which needs a mailbox to re-authenticate. Sign one in again after upgrading to confirm it on your own install.

For developers

  • Every OAuth callback checks a server-issued state, stored in the session, compared with hash_equals, used once. api/auth/oidc_callback.php and includes/mailbox_oauth_state.php are the two examples.
  • Never take an id from the state. Take it from what you stored against it.

Related: 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