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 |
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).
app/Http/Controllers/OpenController.php — userSetupSave() (≈ 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:
- No
invite_sent_at(orinvite_expires_at) column anywhere in the migration history. - No timestamp comparison in the controller.
- No state machine (
STATUS_INVITEDis checked nowhere here). - No rate-limiting middleware on
/user-setup/{hash}. - 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.
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.
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 -dSign 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;In a real attack, this is the leak window. For demo purposes, just copy the hash. The bug is that time does not matter.
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).
Patched in 1.8.217. The advisory recommends — and the upstream fix implements — the standard token-grant hardening:
- Add an
invite_sent_at(timestamp) column onusers. - Compare
invite_sent_atagainst a TTL (default 7 days, configurable) inuserSetupSave()anduserSetup(). - Add a scheduled job to clear expired hashes proactively.
- Add a "Resend invite" UI action that issues a new hash and updates
invite_sent_at, invalidating the prior link. - 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;- 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.
- 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_atis 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?"
- CVE Record: https://www.cve.org/CVERecord?id=CVE-2026-41902
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-41902
- GHSA: https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-hqff-cwx7-3jpm
- CWE-613: https://cwe.mitre.org/data/definitions/613.html
- Upstream project: https://github.com/freescout-help-desk/freescout