Skip to content

Latest commit

 

History

History
153 lines (109 loc) · 7.33 KB

File metadata and controls

153 lines (109 loc) · 7.33 KB

CVE-2026-41902 — FreeScout invitation hash never expires

Permanent unauthenticated account takeover whenever an invite link leaks. Six-month-old, year-old, indefinitely-old invite URLs all still work.

CVE CVE-2026-41902 (NVD)
GHSA GHSA-hqff-cwx7-3jpm
Severity Critical — CVSS 9.1
Vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
CWE CWE-613: Insufficient Session Expiration
Affected FreeScout < 1.8.217
Fixed 1.8.217
Authentication None (knowledge of the invite hash is sufficient)
Reporter @whatisproblem

Summary

When an admin invites a new user, FreeScout stores a 60-character invite_hash on the users row and emails the recipient a link of the form:

https://example.com/user-setup/<60-char-hash>

That URL lets the recipient set a password and activate the account — without authentication. The hash is the only secret. There is no expiry, no invite_sent_at column, no rate limit, and no per-attempt revocation. The hash is consumed only when the recipient (or an attacker) actually completes the form.

In practice: any leak of the URL — months or years after issuance — yields an interactive-less account takeover. If the original invitee was an admin, this is a full helpdesk compromise (customer PII, conversation history, SMTP credentials, ability to mint additional admin accounts).

Root cause

app/Http/Controllers/OpenController.phpuserSetupSave() (≈ lines 47–115):

public function userSetupSave($hash, Request $request)
{
    $user = User::where('invite_hash', $hash)->first();

    if (!$user) {
        abort(404);
    }

    // ... validate password, etc. ...

    $user->password    = bcrypt($request->password);
    $user->status      = User::STATUS_ACTIVE;
    $user->invite_hash = '';     // consumed here, but only on success
    $user->save();

    Auth::login($user);
    return redirect()->route('dashboard');
}

What is missing compared to a normal token-grant flow:

  1. No invite_sent_at (or invite_expires_at) column anywhere in the migration history.
  2. No timestamp comparison in the controller.
  3. No state machine (STATUS_INVITED is checked nowhere here).
  4. No rate-limiting middleware on /user-setup/{hash}.
  5. No invalidation on subsequent invite re-issue (admin "resend invite" simply re-uses the same row).

The hash is 60 chars (sha-1-style), so brute-force is not the threat. Leakage is.

Realistic leakage vectors

A hash that is "just an opaque token in a URL" is treated by every system that handles a URL as ordinary, non-secret text:

Vector Where the hash ends up
Forwarded invite email ("Hey IT, can you set this up for me?") The colleague's mailbox, possibly forwarded again
HTTP Referer header Any link clicked from /user-setup/... (docs, CDN assets, analytics pixels)
Server access logs nginx/apache access.log, Laravel storage/logs/laravel.log if any middleware logs the URL
Backups / log shipping S3, ELK, Loki, Sentry breadcrumbs
Shared inboxes (it@, support@) Retained in IMAP search indices long after the original invitee leaves
Pending invites that never got completed The hash sits there indefinitely, waiting

Any one of these is enough.

Reproduction

Setup (Docker)

git clone https://github.com/freescout-help-desk/freescout
cd freescout
git checkout 1.8.216           # last vulnerable release
# launch via docker-compose using the project's reference compose file
docker compose up -d

Step 1 — capture an invite hash (admin context)

Sign in as the existing admin. Navigate to Manage → Users → Create User, fill in an email, save. The user row is created with a fresh invite_hash. Inspect either the outgoing mail (mailcatcher) or the DB:

SELECT id, email, invite_hash, created_at FROM users WHERE status = 0;

Step 2 — wait, simulate leakage, do nothing for "a year"

In a real attack, this is the leak window. For demo purposes, just copy the hash. The bug is that time does not matter.

Step 3 — take over from a clean (unauthenticated) browser

GET /user-setup/<hash>          → setup form is served
POST /user-setup/<hash>
    password=Attacker1!&password_confirmation=Attacker1!

The session cookie returned belongs to the invited user. Log in: full access to whatever the original invitee was about to be granted (which, in the typical "admin invites teammates" flow, is admin-equivalent for the team scope).

Demonstrative scope only. No customer data is exfiltrated in this PoC; ATO is shown by whoami-equivalent (the dashboard now renders as the victim user).

Fix

Patched in 1.8.217. The advisory recommends — and the upstream fix implements — the standard token-grant hardening:

  1. Add an invite_sent_at (timestamp) column on users.
  2. Compare invite_sent_at against a TTL (default 7 days, configurable) in userSetupSave() and userSetup().
  3. Add a scheduled job to clear expired hashes proactively.
  4. Add a "Resend invite" UI action that issues a new hash and updates invite_sent_at, invalidating the prior link.
  5. Rate-limit /user-setup/{hash} per-IP to make hash-spraying noisy if anyone tries.

If you cannot upgrade immediately, a stop-gap is to manually clear invite_hash for any user row whose created_at is older than your acceptable window:

UPDATE users
SET invite_hash = ''
WHERE status = 0          -- still in invited state
  AND created_at < NOW() - INTERVAL 7 DAY;

Timeline

  • 2026-04-17/18 — Discovered during automated audit of FreeScout (web-vuln-agent).
  • 2026-04-18 — Reported privately via GHSA Draft to the FreeScout maintainers.
  • 2026-04 — Patch developed and released as 1.8.217.
  • 2026-07-17 (planned) — End of 90-day embargo / public write-up.

Lessons

  • Anything that looks like an "auth ticket" needs an expiry. Password reset tokens, magic-link tokens, invite tokens, OAuth state — all the same problem class. Invite tokens drift into "set-and-forget" territory because they are issued rarely and consumed once, which makes them easy to forget about.
  • A token's lifetime is bounded by its leakiest channel, not by the design intent. Email is leaky. Referer is leaky. Logs are leaky. Build in TTL not because the protocol needs it, but because the transport does.
  • invite_sent_at is so trivially cheap to add that its absence is itself a signal — likely the feature shipped before anyone modeled the threat of "what if the URL leaks?"

References