Filament 5.10 Security Fixes: The Livewire Scope Gap Your Upgrade Doesn't Close
> Originally published at hafiz.dev
---
On 8 October, Filament published four security advisories. The fixes shipped two days earlier, in Filament 5.10.0 and 4.15.0. All four are rated medium, and for most apps the answer is simple. Upgrade.
But one of them is different. The advisory about records staying reachable after they leave a user's tenant has its root cause in Livewire, not Filament. Filament 5.10 closes it for resource pages. It says plainly that relation managers, widgets and your own components are not covered. And when I ran composer audit on an affected install, it said everything was fine.
I installed Filament 5.9.0 on a clean Laravel 13.35 project, reproduced the scope problem with a plain Livewire component, and tested two fixes against it. All output below is real.
The four advisories at a glance
| Advisory | What it is | Affected | Fixed in |
|---|---|---|---|
| GHSA-6p72-cx74-63f5 | Records stay reachable after a scope or tenant change | 4.x, 5.x | 4.15.0, 5.10.0 |
| GHSA-jpwg-8rw9-v3jj | Email MFA bypass in multi-panel apps with colliding user IDs | 4.x, 5.x | 4.15.0, 5.10.0 |
| GHSA-q2ph-j76w-c7qx | FileUpload kept the client's file extension, so an image could be stored as .html | 3.x, 4.x, 5.x | 3.3.56, 4.15.0, 5.10.0 |
| GHSA-9g92-9qj5-cqjc | Pasted RichEditor custom blocks could run JavaScript | 4.x, 5.x | 4.15.0, 5.10.0 |
The current patch releases are 5.10.1 and 4.15.1, both from 7 October. If you're still on v3, only the FileUpload issue applies, and 3.3.56 fixes it.
This is the second round of Filament security fixes in two months. I covered the August round of MFA fixes when 4.12 and 5.7 shipped.
Why composer audit may say you're fine
Here's a Filament 5.9.0 install, which all four advisories affect:
composer show filament/filament | grep versions
composer audit
versions : * v5.9.0
No security vulnerability advisories found.
composer audit reads Packagist's advisory database. When I checked it on 10 October, the newest Filament entry was from 5 October. The four advisories from 8 October weren't there yet. The previous Filament advisory took 12 days to show up after GitHub published it.
So check the version yourself:
composer show filament/filament | grep versions
composer update "filament/*" -W
Why a record outlives its tenant
The advisory describes the symptom. A user opens a record. The record then moves to another tenant, or out of a scope the user is limited to. The page is still open, and the next Livewire request still loads the record. Depending on the page's actions, the user can still view or change it.
It needs an open page and existing access, so this isn't an attacker reaching into arbitrary records. But it is exactly the case multi-tenant apps worry about. Someone moves a client to another team, and the person who just lost access can keep editing from a tab they never closed.
The root cause is in Livewire
A Livewire component can't send an Eloquent model to the browser. When you declare public Project $project, Livewire stores the model's class and key in the page snapshot. On every later request, it loads the model again. This is the line that does it, from Livewire 4.4.7's ModelSynth:
$model = (new $class)->newQueryForRestoration($key)->useWritePdo()->firstOrFail();
And this is newQueryForRestoration() in Laravel 13.35:
public function newQueryForRestoration($ids)
{
return $this->newQueryWithoutScopes()->whereKey($ids);
}
newQueryWithoutScopes(). Your tenant scope runs on the first request, when the page mounts. It doesn't run when the model is restored on every request after that.
Reproducing it without Filament
To reproduce it, I added a Project model with a team scope:
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $query) {
$query->where('team_id', auth()->user()?->current_team_id);
});
}
And a plain Livewire component with a public model property:
class ShowProject extends Component
{
public Project $project;
public function rename(string $name): void
{
$this->project->update(['name' => $name]);
}
}
The test mounts the component as a user on team 1. It then moves the project to team 2 and calls rename() from the still-open component:
$component = Livewire::test(ShowProject::class, ['project' => $project])
->assertSee('Roadmap');
DB::table('projects')->where('id', $project->id)->update(['team_id' => 2]);
dump(Project::find($project->id));
$component->call('rename', 'Renamed after the move');
dump(DB::table('projects')->where('id', $project->id)->first(['team_id', 'name']));
null
{#3176
+"team_id": 2
+"name": "Renamed after the move"
}
The scoped query says the project doesn't exist for this user. The component renamed it anyway.
What Filament 5.10 fixes, and what it doesn't
The fix (PR #20616) adds a Livewire component hook. On every request it loads the record again through the resource's own query, scopes included, and returns a 404 if the record is no longer there. This is the hook's skip() method in 5.10.1:
public function skip(): bool
{
return ! (($this->component instanceof ResourcePage) || ($this->component instanceof EditTenantProfile));
}
Resource pages and the tenant profile page. Nothing else. The advisory says the same: the fix "does not automatically revalidate models passed into separate components, such as relation managers or widgets. Applications must enforce any required scope restrictions when those components access the models."
Here's where that leaves each kind of component after the upgrade:
> View the interactive component on hafiz.dev
So after you upgrade, three places still need your attention.
Relation managers need an owner-record check
A relation manager holds its parent in public Model $ownerRecord, so the same restoration happens. Filament 5.10 does add one useful thing here (PR #20587). canViewForRecord() now runs before the relation manager mounts, and again before every later Livewire update. If it returns false, the request gets a 403.
The default implementation only checks viewAny on the related model class. It never asks whether the owner record still belongs to the current tenant. So add that yourself:
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
{
return $ownerRecord->team_id === Filament::getTenant()?->getKey()
&& parent::canViewForRecord($ownerRecord, $pageClass);
}
Adjust the comparison to however your tenant relationship is named. The point is to check the record you were given, not to trust a query that already ran without your scope.
Widgets have no record in their static check
A widget's canView() is static and receives no record, so it can't do this check. If a widget declares public ?Model $record, verify it in hydrate(). The next section shows that pattern.
Two fixes for your own Livewire components
I ran both against the same scenario. Each one stopped the rename, and the name stayed "Roadmap".
Option 1: keep the key, resolve through the scope on every request. The model never becomes a public property, so Livewire never restores it without scopes:
#[Locked]
public int $projectId;
public function mount(Project $project): void
{
$this->projectId = $project->id;
}
#[Computed]
public function project(): Project
{
return Project::findOrFail($this->projectId);
}
After the move, the next request returned 404. findOrFail() runs through the global scope, so the project simply isn't found.
Option 2: keep the model property, check ownership in hydrate(). Livewire calls hydrate() on every request after the first, before any action runs:
public Project $project;
public function hydrate(): void
{
abort_unless($this->project->team_id === auth()->user()->current_team_id, 403);
}
After the move, the next request returned 403. The restored model carries the fresh team_id from the database, so the check sees the move straight away.
I prefer option 1 for new code. It can't be forgotten in one action, because the scope is the only way to get the model. Option 2 is the smaller change for an existing component.
The same lesson applies to any Livewire public property. What the browser holds between requests is a reference, and the server rebuilds it on its own terms. Authorization belongs in a policy or an explicit check that runs on every request, not in a scope that only ran once.
The multi-panel MFA bypass
This one only affects apps with more than one panel, where the panels use different auth guards and different user models, and email-based MFA is on.
Filament stored the email code and its send rate limit under keys built only from the user's primary key. Two panels share one browser session. So an admin with ID 1 and a customer with ID 1 shared the same MFA state. The advisory explains how an attacker who knows the victim's password and controls a colliding account could use that shared state to pass the victim's email challenge. App-based MFA and single-panel apps aren't affected.
The fix scopes all of this state by guard, user model class and identifier. Filament 5.10 also exposes the helper it uses, which is handy if you've built a custom MFA provider:
use Filament\Facades\Filament;
$principal = Filament::getUserScopedAuthIdentifier($user);
The same mistake is easy to make in your own code. If your app has two guards (say web for customers and admin for staff), any key built from $user->id alone can collide. That covers session keys, cache keys and rate limiter keys. Include the guard and the model class:
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
$guard = Auth::getDefaultDriver();
RateLimiter::attempt(
'send-code:'.$guard.':'.$user::class.':'.$user->getAuthIdentifier(),
maxAttempts: 3,
callback: fn () => $this->sendCode($user),
);
One related detail from the MFA docs. Filament keeps challenge and replay-protection state in your default cache store, and uses an atomic lock when the store supports one. Without lock support, concurrent requests may accept the same code twice. So if you run MFA on more than one server, every server needs the same cache backend, and it should support locks (database and Redis both do).
If you're building a multi-tenant Filament panel with a separate customer panel, this is the combination the advisory describes.
The two XSS fixes
Both are closed by upgrading.
FileUpload kept the client's extension. Filament generated a random file name, but took the extension from the name the browser sent. Validation checks the file's detected MIME type. So a file that passed as an image could be stored as .html. If uploads go to a public disk served from your app's own domain, opening that URL could run JavaScript. The fix switches to Laravel's hashName(), which takes the extension from the detected MIME type instead. This affects v3 as well, which makes it the one fix to backport.
Two things to know. preserveFilenames() still uses the client's file name on purpose, so only use it for trusted uploaders. And files uploaded before you upgrade keep their names. It's worth a quick look for active extensions on your public disk:
find storage/app/public -type f \( -name '*.html' -o -name '*.htm' -o -name '*.svg' -o -name '*.xml' \)
RichEditor trusted pasted block previews. Custom blocks carry a preview in a data-preview attribute. When someone pasted HTML containing a custom block, the editor rendered that preview without sanitizing it. Copying from a malicious page into the editor was enough. The fix regenerates previews on the server instead of trusting the pasted ones.
The upgrade checklist
1. Check the installed version with composer show filament/filament. Don't rely on composer audit until these advisories reach Packagist.
2. Upgrade to 5.10.1 or 4.15.1. For v3, upgrade to at least 3.3.56.
3. Search your relation managers for canViewForRecord(). If records are tenant-scoped, add an ownership check on $ownerRecord.
4. Search for public ?Model $record and other public model properties in widgets and custom Livewire components. Either resolve them through a scoped query, or check ownership in hydrate().
5. If you run several panels with email MFA, confirm your cache store supports atomic locks. Then search your own code for session, cache and rate limiter keys built from a user ID alone.
6. Look for .html, .svg and similar files on the public disk that were uploaded through FileUpload.
If your panel picked up custom widgets and pages over time, step 4 is the one to schedule properly.
FAQ
Which Filament versions are affected by the October 2026 advisories?
All four advisories affect Filament 4.x before 4.15.0 and 5.x before 5.10.0. The FileUpload extension issue also affects 3.x before 3.3.56. The current patch releases are 5.10.1 and 4.15.1.
Why doesn't composer audit report the new Filament advisories?
composer audit uses Packagist's advisory database, which receives GitHub advisories with a delay. On 10 October, the four advisories published on 8 October weren't in it yet. Check your installed version directly until they appear.
Does upgrading to Filament 5.10 fix the tenant scope issue completely?
For resource pages and the tenant profile page, yes. For relation managers, widgets and custom Livewire components, no. The advisory says applications must enforce scope restrictions there themselves.
Do Livewire components apply global scopes when restoring a model property?
No. Livewire restores public model properties with newQueryForRestoration(), which uses newQueryWithoutScopes(). Global scopes run when the page mounts, but not on later requests. Resolve the model through a scoped query, or check ownership in hydrate().
Who is affected by the Filament multi-panel MFA bypass?
Apps with two or more panels that use different auth guards and user models, share a browser session, and have email-based MFA enabled. App-based MFA and single-panel apps aren't affected.
Where the time goes
Steps 1 and 2 take five minutes. Steps 3 and 4 are where the time goes, and they're also the ones no update will do for you.
1 reaction · 1 comment
Mentions
NEW in Filament v5.11: JavaScript in Fields/Components/Widgets Get the full source of the example: filamentexamples.com Dan Harrin's original tweet: x.com Full Filament v5.11 release on GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [ar] جديد في Filament v5.11: استخدام JavaScript في الحقول/المكونات/الأدوات للحصول على الكود المصدري الكامل للمثال: filamentexamples.com التغريدة الأصلية لدان هارين: x.com إصدار Filament v5.11 الكامل على GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [bn] Filament v5.11-এ নতুন যা থাকছে: ফিল্ড/কম্পোনেন্ট/উইজেট-এ জাভাস্ক্রিপ্ট (JavaScript) ব্যবহার উদাহরণটির সম্পূর্ণ সোর্স এখানে দেখুন: filamentexamples.com ড্যান হ্যারিনের মূল টুইট: x.com গিটহাবে ফিলামেন্ট v5.11-এর সম্পূর্ণ রিলিজ: github.com/filamentphp/filament/releases/tag/v5.1… [de-DE] NEU in Filament v5.11: JavaScript in Feldern/Komponenten/Widgets Den vollständigen Quellcode des Beispiels finden Sie hier: filamentexamples.com Dan Harrins ursprünglicher Tweet: x.com Die vollständige Version 5.11 von Filament finden Sie auf GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [es-US] Novedades en Filament v5.11: JavaScript en campos/componentes/widgets Obtén el código fuente completo del ejemplo: filamentexamples.com Tweet original de Dan Harrin: x.com Versión completa de Filament v5.11 en GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [fr-FR] NOUVEAUTÉ dans Filament v5.11 : JavaScript dans les champs/composants/widgets Retrouvez le code source complet de l'exemple : filamentexamples.com Tweet original de Dan Harrin : x.com Version complète de Filament v5.11 disponible sur GitHub : github.com/filamentphp/filament/releases/tag/v5.1… [hi] Filament v5.11 में नया: Fields/Components/Widgets में JavaScript उदाहरण का पूरा स्रोत यहां देखें: filamentexamples.com डैन हैरिन का मूल ट्वीट: x.com GitHub पर Filament v5.11 का पूरा संस्करण यहां देखें: github.com/filamentphp/filament/releases/tag/v5.1… [id] BARU di Filament v5.11: JavaScript dalam Field/Komponen/Widget Dapatkan kode sumber lengkap contohnya: filamentexamples.com Tweet asli Dan Harrin: x.com Rilis lengkap Filament v5.11 di GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [it] NOVITÀ in Filament v5.11: JavaScript in Campi/Componenti/Widget Scarica il codice sorgente completo dell'esempio: filamentexamples.com Tweet originale di Dan Harrin: x.com Versione completa di Filament v5.11 su GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [iw] חדש ב-Filament v5.11: שימוש ב-JavaScript בשדות/רכיבים/ווידג'טים קבלו את המקור המלא של הדוגמה: filamentexamples.com הציוץ המקורי של דן הרין: x.com גרסה מלאה של Filament v5.11 ב-GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [ja] Filament v5.11の新機能:フィールド/コンポーネント/ウィジェットでJavaScriptが利用可能に サンプルコードの完全なソースコードはこちら:filamentexamples.com Dan Harrin氏の元のツイート:x.com Filament v5.11の完全版リリースはGitHubで公開中:github.com/filamentphp/filament/releases/tag/v5.1… [ko] Filament v5.11 신기능: 필드/컴포넌트/위젯에서 JavaScript 사용하기 예제의 전체 소스 코드는 다음 링크에서 확인할 수 있습니다: filamentexamples.com 댄 해린의 원본 트윗: x.com Filament v5.11 정식 릴리스는 GitHub에서 확인할 수 있습니다: github.com/filamentphp/filament/releases/tag/v5.1… [ml] Filament v5.11-ലെ പുതിയ ഫീച്ചർ: ഫീൽഡുകൾ/ഘടകങ്ങൾ/വിഡ്ജറ്റുകളിൽ JavaScript ഉപയോഗിക്കാം ഉദാഹരണത്തിന്റെ പൂർണ്ണ ഉറവിടം നേടുക: filamentexamples.com ഡാൻ ഹാരിന്റെ യഥാർത്ഥ ട്വീറ്റ്: x.com GitHub-ൽ പൂർണ്ണ ഫിലമെന്റ് v5.11 റിലീസ്: github.com/filamentphp/filament/releases/tag/v5.1… [nl-NL] NIEUW in Filament v5.11: JavaScript in Velden/Componenten/Widgets Bekijk de volledige broncode van het voorbeeld: filamentexamples.com Oorspronkelijke tweet van Dan Harrin: x.com Volledige release van Filament v5.11 op GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [pa] Filament v5.11 ਵਿੱਚ ਨਵਾਂ: Fields/Components/Widgets ਵਿੱਚ JavaScript ਉਦਾਹਰਣ ਦਾ ਪੂਰਾ ਸਰੋਤ ਪ੍ਰਾਪਤ ਕਰੋ: filamentexamples.com ਡੈਨ ਹੈਰਿਨ ਦਾ ਅਸਲ ਟਵੀਟ: x.com GitHub 'ਤੇ ਪੂਰਾ ਫਿਲਾਮੈਂਟ v5.11 ਰਿਲੀਜ਼: github.com/filamentphp/filament/releases/tag/v5.1… [pl] NOWOŚĆ w Filament v5.11: JavaScript w polach/komponentach/widgetach Pełny kod źródłowy przykładu: filamentexamples.com Oryginalny tweet Dana Harrina: x.com Pełna wersja Filament v5.11 na GitHubie: github.com/filamentphp/filament/releases/tag/v5.1… [pt-BR] NOVIDADE no Filament v5.11: JavaScript em Campos/Componentes/Widgets Acesse o código-fonte completo do exemplo: filamentexamples.com Tweet original de Dan Harrin: x.com Lançamento completo do Filament v5.11 no GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [ru] НОВИНКА в Filament v5.11: JavaScript в полях, компонентах и виджетах Полный исходный код примера: filamentexamples.com Оригинальный твит Дэна Харрина: x.com Полная версия Filament v5.11 на GitHub: github.com/filamentphp/filament/releases/tag/v5.1… [ta] Filament v5.11-ல் புதிய அம்சம்: ஃபீல்டுகள்/கம்போனென்ட்கள்/விட்ஜெட்களில் ஜாவாஸ்கிரிப்ட் (JavaScript) எடுத்துக்காட்டின் முழு மூலக் குறியீட்டைப் பெறுக: filamentexamples.com டான் ஹாரினின் அசல் ட்வீட்: x.com கிட்ஹப்பில் ஃபிலமென்ட் v5.11-இன் முழுமையான வெளியீடு: github.com/filamentphp/filament/releases/tag/v5.1… [te] Filament v5.11లో కొత్తవి: ఫీల్డ్లు/కాంపోనెంట్లు/విడ్జెట్లలో జావాస్క్రిప్ట్ (JavaScript) ఉదాహరణ యొక్క పూర్తి సోర్స్ను పొందండి: filamentexamples.com డాన్ హారిన్ యొక్క అసలు ట్వీట్: x.com గిట్హబ్లో పూర్తి ఫిలమెంట్ v5.11 విడుదల: github.com/filamentphp/filament/releases/tag/v5.1… [uk] НОВИНКА у Filament v5.11: JavaScript у полях/компонентах/віджетах Отримайте повний вихідний код прикладу: filamentexamples.com Оригінальний твіт Дена Харріна: x.com Повний реліз Filament v5.11 на GitHub: github.com/filamentphp/filament/releases/tag/v5.1…
@ashleymcnamara hey, would you mind DMing me? I'm not verified, so I can't message you first. I maintain Filament (filamentphp/filament) and would appreciate a private chat about an ongoing security concern affecting several open-source projects on GitHub.
Filament Up to Date Tip #3: Customize the Table Filters Reset Button --- title: "Filament Up to Date Tip #3: Customize the Table Filters Reset Button" published: true description: "Change the icon, label, and other options of the reset filters action with filtersResetAction()." tags: filament, laravel, table, filters series: Filament Up to Date cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com Use a ratio of 100:42 for best results. published_at: 2026-10-18 20:43 +0000 --- You can change the icon, label, and any other action option on the filters reset control. Every method available for customizing action trigger buttons works here. use Filament\Actions\Action; use Filament\Support\Icons\Heroicon; $table ->filtersResetAction( fn (Action $action) => $action ->icon(Heroicon::XMark) ->label('Clear filters'), ); Why it matters The default label may not match your app's wording, or your users' language. Now you can change it in one place without overriding views. --- !Filament Up To Date Tip #3 Pull request Thanks @devhoussam1998 for contributing this in PR #20476! 🙌 --- 📚 All tips in this series: devdeia.com
Filament Up to Date Tip #2: ToggleButtons Get size() and fullWidth() --- title: "Filament Up to Date Tip #2: ToggleButtons Get size() and fullWidth()" published: true description: "Two new methods let you control the size of ToggleButtons and stretch them across the field width." tags: filament, laravel, form, ToggleButtons series: Filament Up to Date cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com Use a ratio of 100:42 for best results. published_at: 2026-10-18 20:43 +0000 --- Second tip in the Filament Up to Date series: ToggleButtons got two new methods. size() Sets the size to small, medium, or large. You can also pass a closure. The default is medium. use Filament\Forms\Components\ToggleButtons; use Filament\Support\Enums\Size; ToggleButtons::make('status') ->options(OrderStatus::class) ->size(Size::Large); fullWidth() Stretches the toggle buttons across the full width of the field. It works with stacked, grouped, and inline layouts. ToggleButtons::make('status') ->options(OrderStatus::class) ->fullWidth(); When to use them - size() fits dense forms (small) or touch-friendly screens (large). - fullWidth() suits a status selector that should line up with the other fields in your form. --- !Filament Up To Date Tip #2 Pull requests Thanks @getupkid (PR #20517) and @caseydwyer (PR #20520) for contributing this! 🙌 --- 📚 All tips in this series: devdeia.com
Filament Up to Date Tip #1: Show the Full Navigation Hierarchy in Breadcrumbs --- title: "Filament Up to Date Tip #1: Show the Full Navigation Hierarchy in Breadcrumbs" published: true description: "A new opt-in panel setting adds cluster, group, and parent to your Filament breadcrumbs." tags: filament, laravel, navigation, breadcrumb cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com series: Filament Up to Date published_at: 2026-10-18 20:43 +0000 --- Welcome to Filament Up to Date, a series of short tips about new things landing in Filament directly from the latest pull requests. One feature per post, with a code snippet. The tip Filament panels now have an opt-in setting that shows the full navigation hierarchy in breadcrumbs: cluster, group, and parent. $panel ->breadcrumbs(hasNavigationHierarchy: true); Why it matters If your panel uses clusters, navigation groups, or parent items, the default breadcrumbs don't show where a page sits in that structure. With this flag on, users see the whole path, which helps in larger admin panels. It's opt-in, so existing panels won't change until you enable it. --- !Filament UTD Tip #1 Pull request Thanks @ahmed-rashad-alnaggar for contributing this in PR #20153! 🙌 --- 📚 All tips in this series: devdeia.com
@filamentphp @danjharrin Filament Up to Date #9 ✨ 🐕 Nested resource breadcrumbs find custom relation pages PR #20479 by OccTherapist (on GitHub) filament#20479 More tips: devdeia.com #FilamentUTD #PHP t.co
Filament 5.10 Security Fixes: The Livewire Scope Gap Your Upgrade Doesn't Close > Originally published at hafiz.dev --- On 8 October, Filament published four security advisories. The fixes shipped two days earlier, in Filament 5.10.0 and 4.15.0. All four are rated medium, and for most apps the answer is simple. Upgrade. But one of them is different. The advisory about records staying reachable after they leave a user's tenant has its root cause in Livewire, not Filament. Filament 5.10 closes it for resource pages. It says plainly that relation managers, widgets and your own components are not covered. And when I ran composer audit on an affected install, it said everything was fine. I installed Filament 5.9.0 on a clean Laravel 13.35 project, reproduced the scope problem with a plain Livewire component, and tested two fixes against it. All output below is real. The four advisories at a glance | Advisory | What it is | Affected | Fixed in | |---|---|---|---| | GHSA-6p72-cx74-63f5 | Records stay reachable after a scope or tenant change | 4.x, 5.x | 4.15.0, 5.10.0 | | GHSA-jpwg-8rw9-v3jj | Email MFA bypass in multi-panel apps with colliding user IDs | 4.x, 5.x | 4.15.0, 5.10.0 | | GHSA-q2ph-j76w-c7qx | FileUpload kept the client's file extension, so an image could be stored as .html | 3.x, 4.x, 5.x | 3.3.56, 4.15.0, 5.10.0 | | GHSA-9g92-9qj5-cqjc | Pasted RichEditor custom blocks could run JavaScript | 4.x, 5.x | 4.15.0, 5.10.0 | The current patch releases are 5.10.1 and 4.15.1, both from 7 October. If you're still on v3, only the FileUpload issue applies, and 3.3.56 fixes it. This is the second round of Filament security fixes in two months. I covered the August round of MFA fixes when 4.12 and 5.7 shipped. Why composer audit may say you're fine Here's a Filament 5.9.0 install, which all four advisories affect: composer show filament/filament | grep versions composer audit versions : * v5.9.0 No security vulnerability advisories found. composer audit reads Packagist's advisory database. When I checked it on 10 October, the newest Filament entry was from 5 October. The four advisories from 8 October weren't there yet. The previous Filament advisory took 12 days to show up after GitHub published it. So check the version yourself: composer show filament/filament | grep versions composer update "filament/*" -W Why a record outlives its tenant The advisory describes the symptom. A user opens a record. The record then moves to another tenant, or out of a scope the user is limited to. The page is still open, and the next Livewire request still loads the record. Depending on the page's actions, the user can still view or change it. It needs an open page and existing access, so this isn't an attacker reaching into arbitrary records. But it is exactly the case multi-tenant apps worry about. Someone moves a client to another team, and the person who just lost access can keep editing from a tab they never closed. The root cause is in Livewire A Livewire component can't send an Eloquent model to the browser. When you declare public Project $project, Livewire stores the model's class and key in the page snapshot. On every later request, it loads the model again. This is the line that does it, from Livewire 4.4.7's ModelSynth: $model = (new $class)->newQueryForRestoration($key)->useWritePdo()->firstOrFail(); And this is newQueryForRestoration() in Laravel 13.35: public function newQueryForRestoration($ids) { return $this->newQueryWithoutScopes()->whereKey($ids); } newQueryWithoutScopes(). Your tenant scope runs on the first request, when the page mounts. It doesn't run when the model is restored on every request after that. Reproducing it without Filament To reproduce it, I added a Project model with a team scope: protected static function booted(): void { static::addGlobalScope('team', function (Builder $query) { $query->where('team_id', auth()->user()?->current_team_id); }); } And a plain Livewire component with a public model property: class ShowProject extends Component { public Project $project; public function rename(string $name): void { $this->project->update(['name' => $name]); } } The test mounts the component as a user on team 1. It then moves the project to team 2 and calls rename() from the still-open component: $component = Livewire::test(ShowProject::class, ['project' => $project]) ->assertSee('Roadmap'); DB::table('projects')->where('id', $project->id)->update(['team_id' => 2]); dump(Project::find($project->id)); $component->call('rename', 'Renamed after the move'); dump(DB::table('projects')->where('id', $project->id)->first(['team_id', 'name'])); null {#3176 +"team_id": 2 +"name": "Renamed after the move" } The scoped query says the project doesn't exist for this user. The component renamed it anyway. What Filament 5.10 fixes, and what it doesn't The fix (PR #20616) adds a Livewire component hook. On every request it loads the record again through the resource's own query, scopes included, and returns a 404 if the record is no longer there. This is the hook's skip() method in 5.10.1: public function skip(): bool { return ! (($this->component instanceof ResourcePage) || ($this->component instanceof EditTenantProfile)); } Resource pages and the tenant profile page. Nothing else. The advisory says the same: the fix "does not automatically revalidate models passed into separate components, such as relation managers or widgets. Applications must enforce any required scope restrictions when those components access the models." Here's where that leaves each kind of component after the upgrade: > View the interactive component on hafiz.dev So after you upgrade, three places still need your attention. Relation managers need an owner-record check A relation manager holds its parent in public Model $ownerRecord, so the same restoration happens. Filament 5.10 does add one useful thing here (PR #20587). canViewForRecord() now runs before the relation manager mounts, and again before every later Livewire update. If it returns false, the request gets a 403. The default implementation only checks viewAny on the related model class. It never asks whether the owner record still belongs to the current tenant. So add that yourself: use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Model; public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool { return $ownerRecord->team_id === Filament::getTenant()?->getKey() && parent::canViewForRecord($ownerRecord, $pageClass); } Adjust the comparison to however your tenant relationship is named. The point is to check the record you were given, not to trust a query that already ran without your scope. Widgets have no record in their static check A widget's canView() is static and receives no record, so it can't do this check. If a widget declares public ?Model $record, verify it in hydrate(). The next section shows that pattern. Two fixes for your own Livewire components I ran both against the same scenario. Each one stopped the rename, and the name stayed "Roadmap". Option 1: keep the key, resolve through the scope on every request. The model never becomes a public property, so Livewire never restores it without scopes: #[Locked] public int $projectId; public function mount(Project $project): void { $this->projectId = $project->id; } #[Computed] public function project(): Project { return Project::findOrFail($this->projectId); } After the move, the next request returned 404. findOrFail() runs through the global scope, so the project simply isn't found. Option 2: keep the model property, check ownership in hydrate(). Livewire calls hydrate() on every request after the first, before any action runs: public Project $project; public function hydrate(): void { abort_unless($this->project->team_id === auth()->user()->current_team_id, 403); } After the move, the next request returned 403. The restored model carries the fresh team_id from the database, so the check sees the move straight away. I prefer option 1 for new code. It can't be forgotten in one action, because the scope is the only way to get the model. Option 2 is the smaller change for an existing component. The same lesson applies to any Livewire public property. What the browser holds between requests is a reference, and the server rebuilds it on its own terms. Authorization belongs in a policy or an explicit check that runs on every request, not in a scope that only ran once. The multi-panel MFA bypass This one only affects apps with more than one panel, where the panels use different auth guards and different user models, and email-based MFA is on. Filament stored the email code and its send rate limit under keys built only from the user's primary key. Two panels share one browser session. So an admin with ID 1 and a customer with ID 1 shared the same MFA state. The advisory explains how an attacker who knows the victim's password and controls a colliding account could use that shared state to pass the victim's email challenge. App-based MFA and single-panel apps aren't affected. The fix scopes all of this state by guard, user model class and identifier. Filament 5.10 also exposes the helper it uses, which is handy if you've built a custom MFA provider: use Filament\Facades\Filament; $principal = Filament::getUserScopedAuthIdentifier($user); The same mistake is easy to make in your own code. If your app has two guards (say web for customers and admin for staff), any key built from $user->id alone can collide. That covers session keys, cache keys and rate limiter keys. Include the guard and the model class: use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; $guard = Auth::getDefaultDriver(); RateLimiter::attempt( 'send-code:'.$guard.':'.$user::class.':'.$user->getAuthIdentifier(), maxAttempts: 3, callback: fn () => $this->sendCode($user), ); One related detail from the MFA docs. Filament keeps challenge and replay-protection state in your default cache store, and uses an atomic lock when the store supports one. Without lock support, concurrent requests may accept the same code twice. So if you run MFA on more than one server, every server needs the same cache backend, and it should support locks (database and Redis both do). If you're building a multi-tenant Filament panel with a separate customer panel, this is the combination the advisory describes. The two XSS fixes Both are closed by upgrading. FileUpload kept the client's extension. Filament generated a random file name, but took the extension from the name the browser sent. Validation checks the file's detected MIME type. So a file that passed as an image could be stored as .html. If uploads go to a public disk served from your app's own domain, opening that URL could run JavaScript. The fix switches to Laravel's hashName(), which takes the extension from the detected MIME type instead. This affects v3 as well, which makes it the one fix to backport. Two things to know. preserveFilenames() still uses the client's file name on purpose, so only use it for trusted uploaders. And files uploaded before you upgrade keep their names. It's worth a quick look for active extensions on your public disk: find storage/app/public -type f \( -name '*.html' -o -name '*.htm' -o -name '*.svg' -o -name '*.xml' \) RichEditor trusted pasted block previews. Custom blocks carry a preview in a data-preview attribute. When someone pasted HTML containing a custom block, the editor rendered that preview without sanitizing it. Copying from a malicious page into the editor was enough. The fix regenerates previews on the server instead of trusting the pasted ones. The upgrade checklist 1. Check the installed version with composer show filament/filament. Don't rely on composer audit until these advisories reach Packagist. 2. Upgrade to 5.10.1 or 4.15.1. For v3, upgrade to at least 3.3.56. 3. Search your relation managers for canViewForRecord(). If records are tenant-scoped, add an ownership check on $ownerRecord. 4. Search for public ?Model $record and other public model properties in widgets and custom Livewire components. Either resolve them through a scoped query, or check ownership in hydrate(). 5. If you run several panels with email MFA, confirm your cache store supports atomic locks. Then search your own code for session, cache and rate limiter keys built from a user ID alone. 6. Look for .html, .svg and similar files on the public disk that were uploaded through FileUpload. If your panel picked up custom widgets and pages over time, step 4 is the one to schedule properly. FAQ Which Filament versions are affected by the October 2026 advisories? All four advisories affect Filament 4.x before 4.15.0 and 5.x before 5.10.0. The FileUpload extension issue also affects 3.x before 3.3.56. The current patch releases are 5.10.1 and 4.15.1. Why doesn't composer audit report the new Filament advisories? composer audit uses Packagist's advisory database, which receives GitHub advisories with a delay. On 10 October, the four advisories published on 8 October weren't in it yet. Check your installed version directly until they appear. Does upgrading to Filament 5.10 fix the tenant scope issue completely? For resource pages and the tenant profile page, yes. For relation managers, widgets and custom Livewire components, no. The advisory says applications must enforce scope restrictions there themselves. Do Livewire components apply global scopes when restoring a model property? No. Livewire restores public model properties with newQueryForRestoration(), which uses newQueryWithoutScopes(). Global scopes run when the page mounts, but not on later requests. Resolve the model through a scoped query, or check ownership in hydrate(). Who is affected by the Filament multi-panel MFA bypass? Apps with two or more panels that use different auth guards and user models, share a browser session, and have email-based MFA enabled. App-based MFA and single-panel apps aren't affected. Where the time goes Steps 1 and 2 take five minutes. Steps 3 and 4 are where the time goes, and they're also the ones no update will do for you.
@filamentphp Filament Up to Date #5 ✨🦉 Loading skeletons for tables (opt-in) PR #20632 by @danjharrin filament#20632 More tips: devdeia.com Video: youtube.com #FilamentUTD #PHP t.co
Filament Up to Date #4 ✨ Nested actions can read and fill parent form data safely PR #20502 by webard (on Github) filament#20502 More tips: devdeia.com #FilamentUTD #PHP
Filament Up to Date #3 ✨ Custom table "reset filters" action PR #20476 by devhoussam1998 (on GitHub) filament#20476 More tips: devdeia.com #FilamentUTD #PHP