45 KiB
| name | description |
|---|---|
| filament-security-audit | Audit a Filament v5 application for security issues and write a per-finding remediation plan. Use when asked to security-audit, security-review, harden, or pen-test a Filament panel, resource, page, or Livewire component. |
Filament Security Audit
Audit how the application uses Filament v5, not Filament's own source.
Your output is a specification document. The implementing agent will only see your plan, so every finding must name the exact component, namespace, method, and docs URL needed to fix it — no guessing.
How to Scan
Search-anchored, never file-by-file. Each catalogue check gives you a
search; run it. Then inspect only the code around each hit. If you open a file
no search pointed you to, stop. Run every search across all source roots (app/
and any namespaced roots; Blade under resources/views, plus mail /
notification / PDF view roots). For multi-panel apps, first note which
resources/pages belong to which — authorization expectations differ per panel.
Carve-out: panel provider classes, config/, .env, and composer.json
are always in scope — open them whenever a check needs to resolve a disk,
default, panel setting, or dependency version.
Check shape tags:
[Site]— search finds the vulnerable construct directly. Inspect each hit.[Seed]— search finds a seed set (policies, owner FKs, custom Livewire components); inspect related code only.[Conditional]— finding only when a precondition holds. Verify Flag if before reporting.
Highest-yield checks if time-boxed: [A1], [A2], [B1], [C1], [D1].
Run in parallel with subagents
Every check is an independent search, so this audit parallelises cleanly. If you can spawn subagents, partition by category (A–E); each returns structured findings (check ID, location, component, fix) — not prose — and the orchestrator merges and writes the plan. No subagents? Run sequentially.
Writing the Plan
A single Markdown document with the sections below. The reader will read every finding — there are no severity ratings; findings are grouped by check category (A–E) in §2 so similar issues sit next to each other.
- Flag only what's actually exploitable in this codebase. A C2 case whose
sink is sanitised by the framework is not a finding. The catalogue describes
what to look for; what fires depends on the conditions in the codebase. When
in doubt, lean toward
Pass— noise is the failure mode this skill exists to suppress. - Consolidate systemic findings. A check firing across many locations gets one entry with a list of affected locations, not one per occurrence.
Pass/N/Ais not a finding. Don't raise a "future hardening" follow-up for a check whose trigger you just certified absent. A one-line note in the Checks Performed row is the maximum; project-wide hardening recommendations go in §5.- Every real issue gets a
F-NNID in §2. No "asides" / "notes" / "things to watch" section — an implementing agent will skip it. If you can describe the bug, you can write a numbered entry for it. Fix:is one pasteable thing. Not a menu, not "and similar editors elsewhere", not "ask the team". If the right call genuinely depends on a team choice, pick the safer default and note the alternative in one line. Enumerate every affected location by file:line — "and others" is not actionable.
1. Summary
One paragraph + a per-category count (A / B / C / D / E). Count distinct findings (a consolidated systemic finding is one), so totals equal §2 entries. State which panels / directories you scanned.
2. Findings
Grouped by check category (A. Access Control / B. File Uploads & RCE / C. XSS & Injection / D. Query Scoping & Data Exposure / E. Dependencies). Inside a category, order by check ID then by location. Required shape:
### [F-01] Inline ToggleColumn on `is_admin` bypasses the update policy
Check: A4
Location: app/Filament/Resources/Users/Tables/UsersTable.php:42
Component: Filament\Tables\Columns\ToggleColumn
Docs: https://filamentphp.com/docs/5.x/tables/columns/toggle#authorization
Issue: The `is_admin` ToggleColumn is editable inline. Inline columns don't
run model policies — only `->disabled()`. Any user who can see the row can
toggle admin status via Livewire.
Fix: ->disabled(fn (User $record): bool => ! auth()->user()->can('update', $record))
Verify: Test a non-admin user cannot update the column (see Recommended Tests).
Stable ID (F-NN), the catalogue check ID, file:line, full namespace, docs
URL, issue, pasteable fix, verify.
3. Checks Performed
A table of every catalogue check with Finding / Pass / N/A. One-line
reason for each N/A.
4. Recommended Tests
Tests covering the confirmed findings, using Filament's helpers (https://filamentphp.com/docs/5.x/testing/overview).
5. Optional Hardening Tips
Project-wide configuration knobs that aren't a §2 finding today but reduce regression risk. Strict rules:
- Only project-wide configuration. No file:line. Anything pointing at a specific location is a §2 finding instead.
- Trigger condition required. Each tip names the verified condition that made it relevant (e.g. "5 non-Spatie private FileUpload fields exist").
- Omit §5 entirely when empty — don't stub it as "no hardening tips needed."
This is not an "asides" escape hatch — see the §2 rules. Real issues belong in §2.
When to ask vs proceed
- Proceed by default — this skill produces a plan, not edits.
- Ask only when intent is ambiguous and changes the verdict (e.g. a resource that may be intentionally open). State your assumption and continue.
Security Checks Catalogue
Every Filament v5 security notice, grouped by category (A–E). Each entry lists Search, Flag if, Fix, Docs, with Why / Safe when / Exceptions where useful. Reference: https://filamentphp.com/docs/5.x/advanced/security
A. Access Control
A1. Bulk delete/restore missing the *Any() policy guard — [Seed] [Conditional]
A missing policy is not a finding. This is only the narrow inconsistency where a per-record guard exists but the matching bulk guard does not.
- Search: candidate policies —
grep -rnE "function (delete|forceDelete|restore)\(" app/Policies— keeping only those whose body does real work (references$record,$user,Gate, or->can(, not a barereturn true;). Then confirm a matching bulk action exists —grep -rnE "DeleteBulkAction|ForceDeleteBulkAction|RestoreBulkAction" app/Filament(including the default group) — and whether the guard is defined:grep -rnE "function (deleteAny|forceDeleteAny|restoreAny)\(" app/Policies. - Flag if: the per-record method does real work and a matching bulk
action exists but the policy has no corresponding
deleteAny()/forceDeleteAny()/restoreAny(). - Fix: record-independent check (role/permission gate) → copy the same logic
into the
*Any()method. Record-dependent check (ownership) → add->authorizeIndividualRecords('delete')(resp.'forceDelete','restore') to the bulk action so Filament re-checks per record. - Why: bulk actions authorize the whole batch once against
*Any(), never per-record — a missing*Any()fails open. (With the panel's->strictAuthorization()setting enabled a missing*Any()throws instead — N/A there.) - Docs:
A2. Import bypasses the create() / update() policy — [Seed] [Conditional]
Anchored on inconsistency (like [A1]): a model with a meaningful create() /
update() policy while an importer writes records with no equivalent
authorization. A missing policy is not the trigger.
- Search:
grep -rn "ImportAction::make" appand open the referenced importer. For completeness,grep "extends Importer"catches imports run outsideImportAction(commands, queued re-runs). - Flag if: the model's policy has a real
create()/update()but the importer contains nocan(/Gate::/authorize(/abortcheck in any of its overridable hooks (resolveRecord(),beforeValidate,beforeFill,beforeSave,beforeCreate,beforeUpdate) — authorizing in any one is safe. - Fix: record-independent → copy the check into the hook; record-dependent
(update) →
abort_unless(auth()->user()->can('update', $this->record), 403); new records (create) →abort_unless(auth()->user()->can('create', static::getModel()), 403)inbeforeCreate()(the record is filled-but-unsaved there). - Why:
ImportActionresolves, fills, and saves each CSV row without consulting Laravel policies. - Docs: https://filamentphp.com/docs/5.x/actions/import#per-record-authorization
A3. Overridden can*() methods no longer invoked (v4+) — [Site] [Conditional]
- Search:
grep -rnE "function can(Create|Edit|View|ViewAny|Delete|DeleteAny|ForceDelete|ForceDeleteAny|Restore|RestoreAny|Reorder|Replicate|Attach|Detach|DetachAny|Associate|Dissociate|DissociateAny)\(" app/Filament. - Flag if: an override shows authorization intent in its body — references
auth(),$user,Gate,->can(, orabort— and that rule is not also enforced by a policy. (Skip static returns with no such signal.) - Fix: move the logic into the model policy, or override the matching
get*AuthorizationResponse()method (which must return anIlluminate\Auth\Access\Response—Response::allow()/Response::deny()— not a bool). - Why: in v4+
can*()still gates page access, navigation, and global search, but record/bulk actions and relation managers authorize viaget*AuthorizationResponse()directly — so the page looks gated while the action leaks. - Docs: https://filamentphp.com/docs/5.x/upgrade-guide#overriding-the-can-authorization-methods-on-a-resource-relationmanager-or-managerelatedrecords-class
A4. Inline editable columns bypass the update() policy — [Site] [Conditional]
Anchored on inconsistency: a model whose policy does real update() work while
an inline column saves to it without an equivalent guard.
- Search:
grep -rnE "(Select|Toggle|TextInput|Checkbox)Column::make" appacross all source roots. If tables build columns from custom subclasses, alsogrep -rnE "extends (Toggle|Select|TextInput|Checkbox)Column" app. - Flag if: the model policy has a meaningful
update()and the column has no->disabled()closure carrying an auth check. Static->disabled(true)and->rules(...)(validation, not authorization) do not count. - Safe when: no meaningful
update()policy (markN/A), or the column carries its own auth inside->updateStateUsing()/->beforeStateUpdated(). - Fix: record-independent →
->disabled(fn (): bool => ! auth()->user()->can('update_posts')); record-dependent →->disabled(fn ($record): bool => ! auth()->user()->can('update', $record)); or move the field to a policy-gated Edit page / modal action. - Why: these columns save via a Livewire request that checks only
->disabled()(and field validation) — never theupdate()policy — so any user who can see the row can write the value. - Docs:
A5. Livewire upload RPC on components without an upload field — [Seed] [Conditional]
- Search:
grep -rlE "InteractsWith(Schemas|Forms|Infolists|Actions|Table)" app, excluding classes that extend Filament'sResource/Page/RelationManager. Then confirm each hit composesInteractsWithSchemasdirectly, or viaInteractsWithForms(which itself composes it) — only those components expose the upload RPC. A class that only usesInteractsWithTable/InteractsWithActionsdoes not, so it is not a target. - Flag if: a custom component is reachable by untrusted users (check route
middleware, or whether it's rendered on a public Blade view) and lacks
RestrictsFileUploadsToSchemaComponents. Chief cases: unauthenticated pages, or components whose schema has no upload field. - Fix: add
Filament\Schemas\Concerns\RestrictsFileUploadsToSchemaComponentsto the component class (which must implementHasSchemas/HasForms); it 403s uploads whose target isn't an upload field in the component's schema. - Why:
InteractsWithSchemascomposes Livewire'sWithFileUploads, exposing_startUpload/_finishUploadeverywhere. Panel resources/pages re-authorize every request, so the trait isn't needed there. - Docs: https://filamentphp.com/docs/5.x/advanced/security#restricting-livewire-file-uploads-to-schema-components
A6. Custom Livewire: work runs before authorization — [Seed] [Conditional]
- Search: the vulnerable construct is a lifecycle hook that runs before the
auth check, so anchor on the hooks themselves —
grep -rnE "function (boot|mount|hydrate)" app(catchesboot(),boot{Trait}(), themount()body, and per-propertyhydrate{Prop}()) — in custom FilamentPageclasses and standalone Livewire components. (A resource page is only a target if it adds sensitive work above its ownauthorizeAccess()call.) - Flag if: a
boot()/boot{Trait}()body, a custom page'smount()body, or a barehydrate()hook performs sensitive side effects (DB writes, event dispatch, external calls) not preceded by an authorization check. (A per-propertyhydrate{Prop}()hook runs just after the auth check — flag it only as defence-in-depth.) - Fix: do the work after authorization has fired — in the
mount()body below an explicit$this->authorizeAccess()(resource pages) /abort_unless(static::canAccess(), 403)(custom pages) call, or in awire:clickaction method (always post-authorization). Avoid sensitive work inboot()or per-property hydrate hooks. - Why: Filament wires page authorization into Livewire trait hooks —
CanAuthorizeAccesson custom pages,mountCanAuthorizeResourceAccess()/hydrateCanAuthorizeResourceAccess()on resource pages — which fire after the component's ownboot()/mount()/ barehydrate()body. So side effects in those hooks run even when the request is ultimately aborted 403. Resource pages callauthorizeAccess()explicitly insidemount(), so only code above that line is exposed. - Docs: https://filamentphp.com/docs/5.x/advanced/security#authorization-and-the-livewire-request-lifecycle
B. File Uploads & RCE
B1. Path tampering on shared disks (FileUpload + RichEditor) — [Seed] [Conditional]
On a shared private disk, every unprotected writer of either type is an
exfiltration primitive — a user can tamper their own field to read content other
users uploaded through other fields on the same disk. Provider-backed fields
(Spatie FileUpload subclass, provider-backed RichEditor) are safe
writers, but their content remains a potential target: a tampered
non-provider field can read provider-backed content if its path is known.
-
Search:
-
Group all upload-storing fields by resolved disk —
grep -rnE "(FileUpload|SpatieMediaLibraryFileUpload)::make" appandgrep -rn "RichEditor::make" app, resolving each field's disk via->disk(...)or->fileAttachmentsDisk(...)→ panel default →config('filament.default_filesystem_disk')→FILESYSTEM_DISK. Drop:- Public / web-served disks (already addressable — no escalation).
- Disks single-user / single-tenant by infrastructure — Flysystem
rootbound per tenant at framework level: static inconfig/filesystems.php, dynamic viaStorage::set('uploads', ['root' => "/tenants/{$tenantId}"])in a service provider / middleware, or a tenancy package (Spatie multi-tenancy, Stancl/Tenancy). App-layer prefixing on a shared root does NOT count — tampering bypasses string prefixes.
-
Find disclosure targets per disk — any file on the disk root worth exfiltrating, regardless of which mechanism uploaded it:
- Sensitive content in FileUpload / SpatieMediaLibraryFileUpload fields
or RichEditor image attachments. Judgement; ask the user when unclear.
Reliable name signals (apply to the field's or editor's hosting model):
Medical*,Health*,Patient*,Tax*,Bank*,Invoice*,Statement*,Identity*,Passport*,Credential*,Token*,Secret*. Generic names likeDocument/Attachment/File/Uploadare not signals on their own — context determines sensitivity (checkcomposer.json/.envfor HIPAA / PCI / PII hints, and the field's actual domain use). - Enumerable filenames —
grep -rn "preserveFilenames\|getUploadedFileNameForStorageUsing"across both field types. Spatie / UUID-scoped randomness is the safe case; preserved or deterministic names are targets. - Non-Filament content on the disk root — generated PDFs, queued exports, log dumps written by other code paths.
- Sensitive content in FileUpload / SpatieMediaLibraryFileUpload fields
or RichEditor image attachments. Judgement; ask the user when unclear.
Reliable name signals (apply to the field's or editor's hosting model):
-
Find unprotected writers per disk — two sub-categories:
-
FileUpload writers: non-Spatie
FileUploadfields withoutpreventFilePathTampering(true)(per-field, or via globalFileUpload::configureUsing(...)default). -
RichEditor writers: editors satisfying all three of:
- Accepts attachments — default toolbar includes
attachFiles; a custom toolbar (->toolbarButtons([...])) that omits it disables attachment uploads, so the editor can't insert<img data-id>nodes. Skip. - No UUID-scoped provider — trace the editor to its hosting model and
check
registerRichContent(..., ...)->fileAttachmentProvider(...)in the model file (grep -rn "fileAttachmentProvider(" app/Modelsenumerates registered providers). Spatie'sMediaLibraryor any custom provider that re-validates ownership protects the editor. Action / page schemas with no model backing have nowhere to register a provider — always unprotected. - No tampering protection — no
->preventFileAttachmentPathTampering(true)on the field, and no globalRichEditor::configureUsing(...)default.
- Accepts attachments — default toolbar includes
-
-
Gradient or audience check (per disk, cheap heuristic) — either flags an unprotected writer:
- Gradient — any target's gating policy (
view/download/ equivalent) references$recordor$userin the method body (ownership scoping creates per-record asymmetry). Read the body; don't infer from the signature alone —view(User $user, Article $record)may never touch$record. - Audience — the rendered field output reaches viewers beyond the
writer's access scope: an avatar shown on a public profile page or in a
staff list, an editor body rendered into a public article or into a
notification email. Even with flat permissions, a publicly rendered
tampered preview or
<img>fetches the file without re-authenticating the viewer.
- Gradient — any target's gating policy (
-
Identify legitimate fill sources per flagged writer — for each writer surviving steps 3 and 4, find every place its value can be set to a path that did NOT come from a fresh upload or the record's original. These become mandatory
allowFilePathUsing:exclusions in the fix — applying the global default without them breaks those workflows. Sources to inspect:- Field defaults —
->default(...)setting a path (FileUpload) or HTML containing<img data-id="...">(RichEditor). - Form fill hooks —
mutateFormDataBeforeFill()(page),mutateRecordDataUsing()(modal action), explicitfillForm()/$this->form->fill(...)calls. - Action fills —
Action::make(...)->action(...)closures that call$set('<field>', ...)or otherwise write the field from a template / another record. Grep->set('<field>'in panels/pages hosting the writer. - Reactive / live updates —
->afterStateUpdated(...)or->live()callbacks writing to the field from elsewhere.
Encode each allowed pattern in an
allowFilePathUsing:closure (e.g.str_starts_with($file, 'templates/')for a template directory; a membership check on a specific Spatie media collection used by templates). - Field defaults —
-
-
Flag if: a disk has targets, unprotected writers (of either type), and a gradient or broader audience. One §2 entry per disk, listing every unprotected writer and the targets they could reach.
-
Safe when: no targets; no unprotected writers; no gradient AND no broader audience.
-
Fix (one §2 entry per disk):
- Add the relevant global default(s) —
FileUpload::configureUsing(fn (FileUpload $component) => $component->preventFilePathTampering())and/orRichEditor::configureUsing(fn (RichEditor $component) => $component->preventFileAttachmentPathTampering()). - For every fill source from step 5, add a per-field exclusion:
->preventFilePathTampering(allowFilePathUsing: fn (string $file): bool => str_starts_with($file, 'templates/'))(or its RichEditor equivalent). Enumerate each with file:line and the specific allowed pattern.
Step 2 is mandatory when step 5 found fill sources — applying the global default alone will break fill-from-template / copy-from-another-record / default-attachment workflows in production. Alternative when only a small subset of fields is affected: apply the per-field method (with the exclusion in the same call) instead of registering a global default.
- Add the relevant global default(s) —
-
§5 tip: if a disk has any non-provider writer and the corresponding global default is missing (
grep -rn "preventFilePathTampering\|preventFileAttachmentPathTampering" app/Providers), the missing default(s) belong in §5 — defends against a future field creating the gap, even when no §2 finding fires today. -
Why: two mechanisms with the same flaw — both ask the disk for whatever path the client supplied.
- FileUpload: Livewire state holds a client-controlled path. Preview /
download URL methods read state on every render — tampering redirects them
to any file under the disk root, persistence not required (so
storeFiles(false)is not an exemption: the state is still there and the URL methods still read it).->preventFilePathTampering()validates against the record's original value or a fresh upload; off by default. - RichEditor: image attachment
<img data-id="...">is client-controlled. The editor rewrites eachdata-idinto an<img src>URL at render time, signing whatever path it contains without checking ownership — tampering redirects the rendered image to any file under the disk root.->preventFileAttachmentPathTampering()validates thedata-id; off by default. UUID-scoped attachment providers (Spatie'sfileAttachmentProvider(MediaLibrary), or any custom provider that re-validates ownership) protect by rejecting non-owned paths.
- FileUpload: Livewire state holds a client-controlled path. Preview /
download URL methods read state on every render — tampering redirects them
to any file under the disk root, persistence not required (so
-
Docs:
B2. Upload field accepts any file type — [Site] [Conditional]
Without an explicit accepted-type allowlist, FileUpload accepts any file. Add
a per-field restriction to every upload — ->acceptedFileTypes([...]), or the
shortcuts ->image() / ->avatar() — so renamed .php uploads (whose body is
plain PHP text) are detected by content-sniffing and rejected.
-
Search:
grep -rnE "(FileUpload|SpatieMediaLibraryFileUpload)::make" app. For each field, check whether->acceptedFileTypes(,->image(, or->avatar(is set. -
Flag if: a field has no type restriction. An overly-broad list like
application/*is also a finding — themimetypesrule is an allowlist matched against the file's sniffed MIME, so broad wildcards admit dangerous types (application/x-php, etc.). -
Safe when: the field already restricts via
->image()/->avatar()/->acceptedFileTypes([...]). -
Fix (one §2 entry listing every unrestricted field): add
->acceptedFileTypes([...])per field with the appropriate type list, or use->image()/->avatar()when applicable. Enumerate every affected field with file:line. -
Why:
FileUploadaccepts any type by default.->acceptedFileTypes(...)activates Laravel'smimetypesrule, which content-sniffs the uploaded file via finfo — the client-supplied Content-Type header is ignored and the filename extension is never validated. A renamed.phpfile (raw PHP text) sniffs astext/x-phpand fails animage/*allowlist. A polyglot file (valid image magic bytes + embedded PHP) sniffs as an image and passes — it is stopped only when paired with [B3] (random storage filenames keep the.phpextension off disk). Real-world impact tracks the field's resolved disk (->disk(...)→ panel default →config('filament.default_filesystem_disk')→FILESYSTEM_DISK):- Web-served disk (
publicwithstorage:link, or anything Apache / Nginx executes from): unrestricted upload → renamed.phplands on disk with an executable extension → RCE. (B2 alone defeats the renamed-PHP attack; [B3] closes the polyglot path.) - Non-served disk (
s3/gcs/ private cloud storage — the production default for most Filament apps): no execution path; missing restriction is hygiene only, not an exploit. Still worth flagging — a future field added to a different disk inherits the unrestricted pattern.
- Web-served disk (
-
Docs: https://filamentphp.com/docs/5.x/forms/file-upload#file-type-validation
B3. User-controlled file names → remote code execution — [Site] [Conditional]
- Search:
grep -rn "preserveFilenames\|getUploadedFileNameForStorageUsing" app. - Flag if: used together with a
local/publicdisk — resolve the disk in order: the field's->disk(...), then the panel's->defaultFilesystemDisk(), thenconfig('filament.default_filesystem_disk')(defaults toFILESYSTEM_DISK→local).->storeFileNamesIn(is the safe pattern. - Safe when: the field targets a non-served disk (e.g.
->disk('s3')), or uses->storeFileNamesIn(→ markPass. Alocal/publicdisk not actually web-served has no execution path either; confirm HTTP reachability before flagging. Filename-collision concerns on a non-public disk belong under [B1], not here. - Fix: keep random storage names; store the original with
->storeFileNamesIn('column')instead of preserving it on disk. - Why: preserving the client filename keeps a
.phpextension on disk; [B2]'smimetypesrule content-sniffs the body but never checks the extension, so a polyglot upload (image magic bytes + embedded PHP) passes animage/*allowlist while landing assomething.php— executable code on a PHP-served disk → RCE. Both halves of the chain are needed: B2 alone stops the renamed-only.php; B3 alone keeps the extension dangerous; closing one closes the gap. - Docs: https://filamentphp.com/docs/5.x/forms/file-upload#security-implications-of-controlling-file-names
C. XSS & Injection
Every C-check has two halves — source (the interpolated value) and sink (the renderer). Verify both before flagging:
- Source must be user-controllable. Trace each interpolated variable one hop
to its assignment and name the origin in the §2 entry. An enum label
(
$preset->getLabel()), a hardcoded map, or__('...')is not user input →Pass. - Sink must render raw. Several Filament paths sanitise downstream
(
Notificationtitle/body viastr(...)->sanitizeHtml();->html()on columns/entries;RichContentRenderer::toHtml()). Others don't (actionmodalDescription,TextEntry::html()fed a pre-builtHtmlable, raw{!! !!}in Blade, mail/notification views). Sanitised sink →Pass.
C1. Unsanitized rich-editor / markdown output in Blade — [Seed] [Conditional]
- Search: two steps. First list the editor-backed attribute names —
grep -rnoE "(RichEditor|MarkdownEditor)::make\(\s*[\"'][^\"']+[\"']\)" app(both quote styles). Then grep for raw echoes —grep -rnE "\{!!" resources/viewsplus any mail / notification / PDF view roots (e.g.resources/views/mail,resources/views/notifications) — and match them against those names. (Aliased access —$body = $record->content; {!! $body !!}— slips this heuristic; spot-check.) - Flag if: a
{!! !!}echoes one of those editor-backed attributes raw (without->sanitizeHtml()). - Fix:
{!! str($record->content)->sanitizeHtml() !!}(Markdown:{!! str($record->content)->markdown()->sanitizeHtml() !!}). If the editor uses->json(), render withFilament\Forms\Components\RichEditor\RichContentRenderer::make($record->content)->toHtml()(it sanitizes) —sanitizeHtml()on raw JSON renders nothing. - Why: editor content is raw user HTML. Filament's own renderers auto-sanitise, so only your own raw echoes are at risk.
- Docs:
C2. Raw HTML bypasses the sanitizer (HtmlString / view()) — [Site] [Conditional]
- Search:
grep -rnE "new HtmlString\(|->toHtmlString\(\)" app resources/views, plusformatStateUsing(/->state(/getStateUsing(returning aview()orHtmlString, plusTextEntry|TextColumn::make(...)->html(where the state is built upstream from anHtmlStringinterpolation.->html()is safe only when the upstream state-builder isn't injecting unescaped data — if it is, the finding sits at the interpolation site, not the->html()call. - Flag if: user-controlled data is interpolated unescaped into the raw
HTML and the sink renders raw (see C-category intro). Static markup, or
output where every dynamic value is
e()'d, is N/A. - Sink classification:
Notification::title/bodysanitises downstream → no finding (residual risk is broken HTML from attributes likeO'Brienin anhref— surface as a §5 escape-interpolated-values tip if the pattern is widespread).- Action
modalDescription/Heading,TextEntry/TextColumn::html()fed a pre-builtHtmlable, raw{!! !!}in a Blade/mail/notification view — no downstream sanitiser → finding. - A custom view that calls
->sanitizeHtml()orRichContentRenderer::toHtml()on the value before echoing → Pass.
- Fix: prefer
->html()(when its state isn't already pre-built raw HTML); otherwisee()every dynamic value before wrappingHtmlString. Symfony'sHtmlSanitizerdefault permits inlinestyle— configure a stricter sanitizer for fully untrusted content. - Docs:
C3. Unsafe URL schemes in url() — [Site] [Conditional]
- Search:
grep -rnE "->(url|recordUrl)\(" appacross columns, entries, actions, notification actions, mention providers, andrecordUrl(). Narrow to the risky shape (closure or raw attribute, notroute(...)):grep -rnE "->(url|recordUrl)\(\s*fn|->(url|recordUrl)\([^)]*\\\$record->" app. - Flag if: any part of the URL derives from user-controlled data (e.g. a
closure returning a raw model attribute).
route(...)URLs are safe. Input-side validation (->url()/->email()) on the form field is not aPass— the stored value can still carryjavascript:/data:(via import, seeder, direct write) and is re-emitted unsanitised. - Fix: wrap in
Str::sanitizeUrl($value)(a Filament macro onIlluminate\Support\Str, alsostr($value)->sanitizeUrl(); returns the value only for schemeless orhttp/httpsURLs, elsenull); pass extra schemes viaallowedSchemes:. - Why: a
javascript:ordata:value renders into an<a href>and executes on click → XSS. - Docs: https://filamentphp.com/docs/5.x/advanced/security#validating-user-input
C4. Unescaped HTML in option labels (allowHtml / allowOptionsHtml) — [Site] [Conditional]
- Search:
grep -rnE "->(allowHtml|allowOptionsHtml)\(" app(the trailing(avoids matchingallowHtmlValidationMessages, which is [C6]; also coversCheckboxList/MorphToSelect, which share theallowHtmlflag). - Flag if: the option labels derive from user/DB data (a relationship title, a user-entered name). Static developer-authored labels are N/A.
- Fix: remove the flag if labels needn't be HTML; otherwise escape any
dynamic value with
e()before it reaches the label. - Docs:
C5. Unescaped extraAttributes() values — [Site] [Conditional]
- Search:
grep -rnE "extra[A-Za-z]*Attributes\(" app. - Flag if: attribute names/values are built from user-controlled data. Static arrays (class lists, Alpine/Livewire directives) are N/A.
- Fix: pass only trusted/validated data; if a value must be dynamic, escape
it with
e($value)before adding it to the attribute array, and never build attribute names from user input. - Why:
extra*Attributes()render values into HTML without escaping by design (to allow Alpine/Livewire directives), so user data can break out of the attribute. - Docs: https://filamentphp.com/docs/5.x/advanced/security#validating-user-input
C6. Unescaped validation messages (allowHtmlValidationMessages) — [Site] [Conditional]
- Search:
grep -rn "allowHtmlValidationMessages" app. - Flag if: a message interpolates user-controlled data, uses a Laravel
placeholder that echoes input (
:input,:value), or relies on an HTML / user-derived field label (:attribute). Developer-authored and translation-file messages with no such interpolation are N/A. - Fix: ensure no message interpolates unescaped user data, or remove the call.
- Docs: https://filamentphp.com/docs/5.x/forms/validation#allowing-html-in-validation-messages
C7. User input in client-side JS expressions — [Site] [Conditional]
- Search:
grep -rnE "hiddenJs\(|visibleJs\(|afterStateUpdatedJs\(|actionJs\(|alpineClickHandler\(|JsContent::make\(|->js\(\)" app. - Flag if: user input is spliced into the JS string via PHP
interpolation/concatenation. Runtime reads via
$state/$get()are safe. - Fix: use
$get()/$statefor dynamic values; never interpolate user input PHP-side. - Why: these strings are
eval()'d client-side, so PHP-side interpolation of user data → XSS. - Docs:
D. Query Scoping, Data Exposure & Multi-Tenancy
D1. List/widget query ignores an ownership rule enforced elsewhere — [Seed] [Conditional]
Anchored on inconsistency, not "scope everything": flag a query that returns records a per-user ownership rule — visible elsewhere — should have excluded. (Tenant scoping is [D3].) A scope that is registered but logically broken counts as a D1 finding too — applying a broken scope is the same failure mode as not applying one.
- Search: seed = models with an owner FK
(
grep -rnE "(user|author|owner|account|customer)_id|created_by" database/migrations app/Models) or a record-dependentview()policy (grep -rnE "function view\(" app/Policies— body references$record). For each, inspect query sites —grep -rnE "getEloquentQuery|modifyQueryUsing|getTableQuery|getStats|getData" app/Filament— and registered scopes (#[ScopedBy(...)],addGlobalScopeinbooted(), classes underapp/Models/Scopes,app-modules/*/src/Models/Scopes). - Flag if: (a) the query doesn't apply the ownership scope the policy/FK
implies, or (b) a query-customisation site (
getEloquentQuery()override,->modifyQueryUsing(...), a filterquery(...)callback) calls a top-level->where(...)->orWhere(...)not wrapped in->where(function ($q) { ... })— Filament appends search/filter constraints after your callback, so the top-levelorWhereescapes the surroundingANDgroup and leaks rows. (Registered global scopes do not need this wrap: Laravel auto-groupsor-containing scope constraints into a nestedwhere.) - Fix: missing-scope →
->modifyQueryUsing(...), overridegetEloquentQuery(), or add a global scope, and apply the same constraint in widgets. Unwrapped-OR → wrap the OR pair in->where(function ($q) { $q->where(...)->orWhere(...); })at the customisation site. - Filter
options()is not an access boundary — separate search:grep -rnE "SelectFilter::make|SelectConstraint::make" appand inspect each->options(...)callback. Flag if the option list is narrowed per user/role (auth()/$user/Gate/->can(/ role check inside the closure). The submitted value is not validated against the returned list before hittingwhereIn/where, so a tampered request reaches the "hidden" rows. Fix: keep the full option list and enforce access in the query (above), or wrap the filter in->visible(...)and gate the underlying query. - Docs: https://filamentphp.com/docs/5.x/advanced/security#scoping-queries
D2. Sensitive model attributes exposed to JavaScript — [Seed] [Conditional]
- Search:
grep -rnE "(token|secret|password|api[_]?key|two_factor|ssn|tax_id|bank|iban|card_number|private_key|salary)" app/Models database/migrations, plus any sensitive domain column and$appendsaccessors. Restrict to models edited via a Filament Edit/View page or modalEditAction/ViewAction. - Flag if: the attribute isn't in
$hidden, isn't excluded by a$visiblewhitelist, and isn't stripped inmutateFormDataBeforeFill(). - Fix: add the column to
$hidden(covers every path), orunset()it inmutateFormDataBeforeFill(array $data): array. For modalEditAction/ViewAction, the scrub is->mutateRecordDataUsing(...). - Why: Filament exposes all non-
$hiddenattributes to JavaScript via Livewire model binding on Edit/View pages. This is exposure, not mass-assignment — only attributes with a form field are editable. - Docs: https://filamentphp.com/docs/5.x/resources/overview#protecting-model-attributes
D3. Models/queries not auto-scoped to the tenant — [Seed] [Conditional]
-
Search: across all source roots (unscoped queries hide in jobs/commands/observers) —
grep -rn "withoutGlobalScopes(" app— empty-arg calls drop tenancy too.- Enumerate tenant-owned models by their FK
(
grep -rnE "team_id|organization_id|tenant_id|company_id" database/migrations app/Models) and anyBelongsToTenant-style trait. For each, confirm it's either exposed through a tenant-panel resource (auto-scoped) or explicitly scoped elsewhere. Alsogrep -rnE "saveQuietly\(|withoutEvents\(|unguarded\(" appfor muted creation events.
-
Flag if: a tenant-owned model has no resource and no explicit scope, is queried before tenant identification (early middleware/providers) or outside the panel, or
withoutGlobalScopes()is called with no arguments. -
Fix — three mechanisms depending on context:
- Add a resource for the model (simplest — resource queries are auto-scoped via the panel).
- Register a model-level global scope that filters by
Filament::getTenant(). Pair withtenantMiddleware([...], isPersistent: true)so the tenant is re-identified on Livewire AJAX requests (which bypass panel route middleware); non-panel HTTP routes need the panel's tenant middleware applied to them directly, and queue jobs needFilament::setTenant($tenant)at the job's entry — persistent middleware doesn't run on workers. - Use a
creatingmodel listener to populate the tenant FK on save (covers writes without needing a query scope).
To drop a single scope without losing tenancy, use
withoutGlobalScope(filament()->getTenancyScopeName()). Never bare-argwithoutGlobalScopes(). -
Why: automatic scoping applies only to models with a resource, only inside the panel, only after tenant identification.
-
Docs: https://filamentphp.com/docs/5.x/users/tenancy#tenancy-security
D4. Over-permissive tenant-access methods — [Site] [Conditional]
- Search:
grep -rnE "function (canAccessTenant|getTenants|canAccessPanel)\(" app(across all source roots — may live on a trait or a modular User model). - Flag if:
canAccessTenant()returnstrueunconditionally,getTenants()returns every tenant (Team::all()), orcanAccessPanel()returnstruefor everyone on a panel with open registration. Safe when each gates on real membership. A permissivegetTenants()alone (withcanAccessTenant()still gated) only discloses tenant names in the switcher — verify and note in the §2 Issue paragraph; access is re-checked at identification. - Fix: gate on membership —
canAccessTenant:return $this->teams()->whereKey($tenant)->exists();;getTenants:return $this->teams;;canAccessPanel: a role/flag/email-domain check. - Why: these methods are the front door to a tenant. The impact is latent while the panel has no tenant-scoped resources, but becomes a direct cross-tenant hole the moment one is added — flag it regardless.
- Docs: https://filamentphp.com/docs/5.x/users/tenancy#tenancy-security
D5. unique / exists validation ignores the tenancy scope — [Site] [Conditional]
- Search: in tenant-enabled panels (
$isScopedToTenant !== false),grep -rnE "->(unique|exists)\(" app/Filamenton resource form fields; alsogrep -rnE "['\"](unique|exists):" app/Filamentto catch the string-rule forms (->rules(['unique:...']),Rule::unique(...)), which bypass scopes identically. - Flag if: a tenant-scoped resource uses
unique()/exists()validation. - Fix: use
->scopedUnique()/->scopedExists()(no-arg defaults to the component's model and field name; passmodel:/column:only for a non-default table). - Why: Laravel's
unique/existsquery the DB directly without global scopes, so cross-tenant data influences validation. Unscopedexistsenables cross-tenant reference binding; unscopeduniquesurfaces as false collisions / "value taken elsewhere" disclosure. - Docs: https://filamentphp.com/docs/5.x/users/tenancy#unique-and-exists-validation
E. Dependencies
E1. Known vulnerabilities in Filament / Livewire / Filament plugins — [Site]
- Search:
composer audit --format=plainfrom the project root. Cross-check package names againstcomposer.jsonto identify which advisories affect Filament (filament/*), Livewire (livewire/livewire), or installed Filament plugins (any package whose name or description references Filament). Unrelated framework / library CVEs are out of scope for this audit. - Flag if:
composer auditreports an in-scope advisory with a fixed version available. One §2 entry per advisory. - Safe when: no in-scope advisories, or the installed version already meets the advisory's fixed range.
- Fix:
composer update <package> --with-all-dependenciesand re-runcomposer auditto confirm the advisory is gone. If the fixed version is a major bump, link the package's upgrade guide in the §2 entry rather than pasting the command alone. - Why: Filament and Livewire CVEs typically affect every panel using the vulnerable version — usage-level mitigations don't apply. Outdated-but- unaffected packages are not a finding (this is not a "stay current" check).
- Docs: https://getcomposer.org/doc/03-cli.md#audit