Skip to content

Latest commit

 

History

History
173 lines (121 loc) · 10.1 KB

File metadata and controls

173 lines (121 loc) · 10.1 KB

CVE-2026-41904 — FreeScout stored XSS in mailbox auto-reply

A mailbox manager can plant an XSS payload in the auto-reply message. The payload is delivered, unescaped, in every auto-reply email FreeScout sends to its customers — i.e. it fires in external mail clients with no CSP.

CVE CVE-2026-41904 (NVD)
GHSA GHSA-q3fh-rj9h-jfrc
Severity High — CVSS 7.6
Vector CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
CWE CWE-79: Improper Neutralization (XSS)
Affected FreeScout < 1.8.217
Fixed 1.8.217
Authentication Authenticated user with updateAutoReply mailbox permission
Reporter @whatisproblem

Summary

The mailbox auto-reply feature (an automatic acknowledgement email sent when a customer first contacts a mailbox) accepts an HTML body. The controller validates a copy of that body that has been run through strip_tags() — but it stores the original, unstripped body. The Blade template that builds the outgoing email then renders the body with {!! $auto_reply_message !!} — Laravel's "I know what I'm doing, do not escape" syntax.

So:

attacker input  ──► strip_tags(copy) ──► passes regex/length validation
                ──► original (untouched) ──► persisted to DB
                ──► {!! ... !!} in email Blade ──► sent to customer

The validator and the sink looked at different values. Worse, the in-codebase "sanitiser" used at the sink (Helper::stripDangerousTags) only blacklists nine tag names and does zero attribute filtering — so <img onerror=...> walks straight through.

The XSS lands in the customer's email client, not in the FreeScout web UI. That makes the impact:

  • Higher than typical stored XSS in some ways (no CSP, target audience is non-technical end-customers, payload is delivered to many recipients automatically).
  • Lower in others (the attacker must already hold updateAutoReply on a mailbox; modern webmail strips most JS at render time but not all clients do, and HTML-renderable mail clients with image/CSS/event-handler support remain in use).

CVSS scope = Changed (S:C) because the impact crosses the FreeScout trust boundary into the customer's mail client.

Root cause

app/Http/Controllers/MailboxesController.phpautoReplySave() (paraphrased):

public function autoReplySave($id, Request $request)
{
    $mailbox = Mailbox::findOrFail($id);
    $this->authorize('updateAutoReply', $mailbox);

    $stripped = strip_tags($request->input('auto_reply_message'));
    $request->validate([
        'auto_reply_message' => 'required|min:1|max:65535',  // validates ORIGINAL
        // (note: the validator looks at $request->input('auto_reply_message'),
        //  but the developer used $stripped above only for the length-check sanity)
    ]);

    // **Persists the ORIGINAL string, not the stripped one**
    $mailbox->auto_reply_message = $request->input('auto_reply_message');
    $mailbox->save();

    return redirect()->back()->with('flash_success', __('Auto reply settings saved'));
}

The sink — resources/views/emails/auto_reply.blade.php:

<div class="auto-reply-body">
    {!! $auto_reply_message !!}        {{-- raw, unescaped --}}
</div>

Some versions wrap that in Helper::stripDangerousTags($auto_reply_message), but the helper is a thin blacklist:

public static function stripDangerousTags($html)
{
    $blacklist = ['script','iframe','object','embed','applet',
                  'form','input','button','meta'];   // ~9 tags, no attribute filter
    foreach ($blacklist as $tag) {
        $html = preg_replace('#<\s*' . $tag . '[^>]*>.*?<\s*/\s*' . $tag . '\s*>#is', '', $html);
        $html = preg_replace('#<\s*' . $tag . '[^>]*/?\s*>#is', '', $html);
    }
    return $html;
}

<img>, <svg>, <a>, <style>, <details open ontoggle=...>, and any tag with an event-handler attribute is untouched.

Validator-bypass technique

strip_tags() was being used (incorrectly, on a copy) as the validator's notion of "this looks like text-only content". You don't need to bypass strip_tags() though — because the original string is what gets stored. But for completeness, even if the validator had been wired correctly, any single non-whitespace character outside an HTML tag is enough to make strip_tags() return a non-empty string, satisfying min:1:

a<img src=x onerror=alert(1)>            ✓ passes (a is non-whitespace text)
.<img src=x onerror=alert(1)>            ✓ passes
‌<img src=x onerror=alert(1)>            ✓ passes (U+200C zero-width non-joiner is "non-whitespace")

This is the kind of validator-vs-sink mismatch that static analysis is well-suited to surface (one taint-tracker run that follows the value, not the variable name, would've flagged it immediately).

Reproduction

Setup

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

Create a mailbox support@example.com. Create a non-admin user with updateAutoReply permission on that mailbox.

Step 1 — plant the payload

Sign in as the mailbox manager, navigate to Mailbox → Auto Reply → Edit, and submit:

Subject:   We received your message
Body:      Thanks for contacting us!<img src=x onerror="alert('XSS in '+document.domain)">
Enabled:   yes

Save. The DB row now contains the raw HTML:

SELECT auto_reply_message FROM mailboxes WHERE id = ?;
-- Thanks for contacting us!<img src=x onerror="alert('XSS in '+document.domain)">

Step 2 — trigger delivery

From the outside, send an email from a customer address that has not contacted this mailbox before (the auto-reply only fires once per conversation lifecycle). FreeScout's mail handler ingests it, opens a conversation, and queues the auto-reply.

Step 3 — observe in the customer's mail client

In a mail client that renders HTML and event handlers (a deliberately-permissive test target like Thunderbird with default settings, or any niche/legacy webmail without strict sanitisation), the <img onerror> fires.

Demonstrative scope only. The PoC payload is alert(...). A real attacker would substitute payloads designed for credential-prompt phishing inline ("Your session has expired, please re-enter your password to read the agent's reply") — a particularly nasty vector because the email genuinely originates from the helpdesk.

What actually happens in modern webmail

Gmail, Outlook on the web, and most major webmail providers strip <script>, event handlers, and <style> aggressively at render time. So the full JS-execution outcome on those targets is mitigated by the provider, not by FreeScout. Less-sanitising mail clients (some self-hosted webmail, some desktop clients with permissive defaults, some mobile clients) execute the payload as written. Defending in depth means FreeScout cannot rely on third-party mail clients to be safe.

Fix

Patched in 1.8.217.

The advisory recommends, and the upstream fix implements, replacing both the validator's strip_tags() and the sink's stripDangerousTags() with Helper::purifyHtml() — a wrapper around HTMLPurifier, which is already a dependency of the project. HTMLPurifier is an allowlist parser: it builds a DOM, validates each tag and each attribute against its config, and re-serialises. Event handlers and unknown tags are dropped, attribute values are URL-validated, and the output is canonicalised.

Critically, the purification is applied at the sink (the Blade template / mailable), not just at the controller, so any future code path that writes to auto_reply_message is covered without re-hardening.

The validator should also stop calling strip_tags() on a copy and instead validate the post-purify value (or, simpler, just max:65535 on the raw input — length is the only thing the validator was ever measuring usefully).

Timeline

  • 2026-04-17/18 — Discovered 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

  • Validator-and-sink-disagreement is one of the most common XSS root causes and is invisible to anyone reading just one of the two files. The lint that catches it is "what data does the value at the sink trace back to?" — a question better suited to taint analysis than code review.
  • {!! ... !!} is a footgun the size of a tactical nuke. Every {!! ... !!} in a Laravel template should justify itself in a comment immediately above the line. If the justification is "we trust the input because we sanitise upstream", the upstream sanitiser is now load-bearing for security, must be perfect, and must be re-evaluated every time a new write path is added.
  • Tag-blacklist sanitisers are dead. Twenty years of XSS history is the proof. Use HTMLPurifier (or DOMPurify on the client) — they exist because nobody can keep a blacklist up to date with mutation XSS, namespace tricks, polyglot payloads, or new HTML5 elements.
  • External-victim XSS sidesteps your CSP. If your output ever reaches a third-party rendering surface (email clients, Slack/Teams unfurl, RSS readers), your CSP doesn't protect that surface. Sanitise at the source.

References