---
name: filament-security-audit
description:
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/A` is 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-NN` ID 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 bare `return 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**:
- https://filamentphp.com/docs/5.x/resources/deleting-records#authorization
- https://filamentphp.com/docs/5.x/actions/delete#improving-the-performance-of-delete-bulk-actions
### 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" app` and open the referenced
importer. For completeness, `grep "extends Importer"` catches imports run
outside `ImportAction` (commands, queued re-runs).
- **Flag if**: the model's policy has a real `create()` / `update()` **but** the
importer contains no `can(` / `Gate::` / `authorize(` / `abort` check 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)` in
`beforeCreate()` (the record is filled-but-unsaved there).
- **Why**: `ImportAction` resolves, 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(`, or `abort` — 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 an
`Illuminate\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 via
`get*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" app`
across all source roots. If tables build columns from custom subclasses, also
`grep -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 (mark `N/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 the `update()` policy — so any
user who can see the row can write the value.
- **Docs**:
- https://filamentphp.com/docs/5.x/tables/columns/toggle#authorization
- https://filamentphp.com/docs/5.x/tables/columns/text-input#authorization
- https://filamentphp.com/docs/5.x/tables/columns/select#authorization
- https://filamentphp.com/docs/5.x/tables/columns/checkbox#authorization
### 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's `Resource` / `Page` /
`RelationManager`. Then confirm each hit composes `InteractsWithSchemas`
directly, or via `InteractsWithForms` (which itself composes it) — only those
components expose the upload RPC. A class that only uses `InteractsWithTable`
/ `InteractsWithActions` does 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\RestrictsFileUploadsToSchemaComponents` to the
component class (which must implement `HasSchemas` / `HasForms`); it 403s
uploads whose target isn't an upload field in the component's schema.
- **Why**: `InteractsWithSchemas` composes Livewire's `WithFileUploads`,
exposing `_startUpload` / `_finishUpload` everywhere. 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` (catches `boot()`,
`boot{Trait}()`, the `mount()` body, and per-property `hydrate{Prop}()`) — in
custom Filament `Page` classes and standalone Livewire components. (A resource
page is only a target if it adds sensitive work _above_ its own
`authorizeAccess()` call.)
- **Flag if**: a `boot()` / `boot{Trait}()` body, a custom page's `mount()`
body, or a bare `hydrate()` hook performs sensitive side effects (DB writes,
event dispatch, external calls) not preceded by an authorization check. (A
per-property `hydrate{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 a
`wire:click` action method (always post-authorization). Avoid sensitive work
in `boot()` or per-property hydrate hooks.
- **Why**: Filament wires page authorization into Livewire _trait_ hooks —
`CanAuthorizeAccess` on custom pages, `mountCanAuthorizeResourceAccess()` /
`hydrateCanAuthorizeResourceAccess()` on resource pages — which fire _after_
the component's own `boot()` / `mount()` / bare `hydrate()` body. So side
effects in those hooks run even when the request is ultimately aborted 403.
Resource pages call `authorizeAccess()` explicitly inside `mount()`, 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**:
1. **Group all upload-storing fields by resolved disk** —
`grep -rnE "(FileUpload|SpatieMediaLibraryFileUpload)::make" app` and
`grep -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
`root` bound per tenant at framework level: static in
`config/filesystems.php`, dynamic via
`Storage::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.
2. **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 like `Document` / `Attachment` / `File` /
`Upload` are **not** signals on their own — context determines
sensitivity (check `composer.json` / `.env` for 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.
3. **Find unprotected writers per disk** — two sub-categories:
- **FileUpload writers**: non-Spatie `FileUpload` fields without
`preventFilePathTampering(true)` (per-field, or via global
`FileUpload::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 `
` 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/Models`
enumerates registered providers). Spatie's `MediaLibrary` or 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
global `RichEditor::configureUsing(...)` default.
4. **Gradient or audience check** (per disk, cheap heuristic) — either flags
an unprotected writer:
- **Gradient** — any target's gating policy (`view` / `download` /
equivalent) references `$record` or `$user` _in 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 `
` fetches the file without re-authenticating
the viewer.
5. **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 `
` (RichEditor).
- **Form fill hooks** — `mutateFormDataBeforeFill()` (page),
`mutateRecordDataUsing()` (modal action), explicit `fillForm()` /
`$this->form->fill(...)` calls.
- **Action fills** — `Action::make(...)->action(...)` closures that call
`$set('', ...)` or otherwise write the field from a template /
another record. Grep `->set(''` 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).
- **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):
1. Add the relevant global default(s) —
`FileUpload::configureUsing(fn (FileUpload $component) => $component->preventFilePathTampering())`
and/or
`RichEditor::configureUsing(fn (RichEditor $component) => $component->preventFileAttachmentPathTampering())`.
2. 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.
- **§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 `
` is client-controlled.
The editor rewrites each `data-id` into an `
` 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 the `data-id`; off by
default. UUID-scoped attachment providers (Spatie's
`fileAttachmentProvider(MediaLibrary)`, or any custom provider that
re-validates ownership) protect by rejecting non-owned paths.
- **Docs**:
- https://filamentphp.com/docs/5.x/forms/file-upload#authorizing-existing-file-paths
- https://filamentphp.com/docs/5.x/forms/rich-editor#securing-file-attachment-ids
### 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 — the `mimetypes` rule 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**: `FileUpload` accepts any type by default. `->acceptedFileTypes(...)`
activates Laravel's `mimetypes` rule, 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 `.php` file (raw PHP text)
sniffs as `text/x-php` and fails an `image/*` 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 `.php`
extension off disk). Real-world impact tracks the field's resolved disk
(`->disk(...)` → panel default → `config('filament.default_filesystem_disk')`
→ `FILESYSTEM_DISK`):
- **Web-served disk** (`public` with `storage:link`, or anything Apache /
Nginx executes from): unrestricted upload → renamed `.php` lands 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.
- **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`/`public` disk — resolve the disk in
order: the field's `->disk(...)`, then the panel's
`->defaultFilesystemDisk()`, then `config('filament.default_filesystem_disk')`
(defaults to `FILESYSTEM_DISK` → `local`). `->storeFileNamesIn(` is the safe
pattern.
- **Safe when**: the field targets a non-served disk (e.g. `->disk('s3')`), or
uses `->storeFileNamesIn(` → mark `Pass`. A `local`/`public` disk 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 `.php` extension on disk;
[B2]'s `mimetypes` rule content-sniffs the body but never checks the
extension, so a **polyglot** upload (image magic bytes + embedded PHP) passes
an `image/*` allowlist while landing as `something.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
(`Notification` title/body via `str(...)->sanitizeHtml()`; `->html()` on
columns/entries; `RichContentRenderer::toHtml()`). Others don't (action
`modalDescription`, `TextEntry::html()` fed a pre-built `Htmlable`, 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/views` plus 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 with
`Filament\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**:
- https://filamentphp.com/docs/5.x/forms/rich-editor#security
- https://filamentphp.com/docs/5.x/forms/markdown-editor#security
### C2. Raw HTML bypasses the sanitizer (`HtmlString` / `view()`) — `[Site]` `[Conditional]`
- **Search**:
`grep -rnE "new HtmlString\(|->toHtmlString\(\)" app resources/views`, plus
`formatStateUsing(` / `->state(` / `getStateUsing(` returning a `view()` or
`HtmlString`, plus `TextEntry|TextColumn::make(...)->html(` where the state is
built upstream from an `HtmlString` interpolation. `->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/body` sanitises downstream → **no finding** (residual
risk is broken HTML from attributes like `O'Brien` in an `href` — surface as
a §5 escape-interpolated-values tip if the pattern is widespread).
- Action `modalDescription/Heading`, `TextEntry/TextColumn::html()` fed a
pre-built `Htmlable`, raw `{!! !!}` in a Blade/mail/notification view — no
downstream sanitiser → **finding**.
- A custom view that calls `->sanitizeHtml()` or
`RichContentRenderer::toHtml()` on the value before echoing → **Pass**.
- **Fix**: prefer `->html()` (when its state isn't already pre-built raw HTML);
otherwise `e()` every dynamic value before wrapping `HtmlString`. Symfony's
`HtmlSanitizer` default permits inline `style` — configure a stricter
sanitizer for fully untrusted content.
- **Docs**:
- https://filamentphp.com/docs/5.x/tables/columns/text#rendering-raw-html-without-sanitization
- https://filamentphp.com/docs/5.x/infolists/text-entry#rendering-raw-html-without-sanitization
- https://filamentphp.com/docs/5.x/schemas/primes#text-component
### C3. Unsafe URL schemes in `url()` — `[Site]` `[Conditional]`
- **Search**: `grep -rnE "->(url|recordUrl)\(" app` across columns, entries,
actions, notification actions, mention providers, and `recordUrl()`. Narrow to
the risky shape (closure or raw attribute, not `route(...)`):
`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** a
`Pass` — the stored value can still carry `javascript:` / `data:` (via import,
seeder, direct write) and is re-emitted unsanitised.
- **Fix**: wrap in `Str::sanitizeUrl($value)` (a Filament macro on
`Illuminate\Support\Str`, also `str($value)->sanitizeUrl()`; returns the value
only for schemeless or `http`/`https` URLs, else `null`); pass extra schemes
via `allowedSchemes:`.
- **Why**: a `javascript:` or `data:` value renders into an `` 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 matching `allowHtmlValidationMessages`, which is [C6]; also covers
`CheckboxList` / `MorphToSelect`, which share the `allowHtml` flag).
- **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**:
- https://filamentphp.com/docs/5.x/forms/select#allowing-html-in-the-option-labels
- https://filamentphp.com/docs/5.x/tables/columns/select#allowing-html-in-the-option-labels
### 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()` / `$state` for 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**:
- https://filamentphp.com/docs/5.x/forms/overview#hiding-a-field-using-javascript
- https://filamentphp.com/docs/5.x/actions/overview#running-javascript-when-an-action-is-clicked
## 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-dependent `view()` 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(...)]`, `addGlobalScope` in `booted()`,
classes under `app/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 filter `query(...)` callback) calls a top-level
`->where(...)->orWhere(...)` not wrapped in `->where(function ($q) { ... })` —
Filament appends search/filter constraints _after_ your callback, so the
top-level `orWhere` escapes the surrounding `AND` group and leaks rows.
(Registered global scopes do **not** need this wrap: Laravel auto-groups
`or`-containing scope constraints into a nested `where`.)
- **Fix**: missing-scope → `->modifyQueryUsing(...)`, override
`getEloquentQuery()`, 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" app` and 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 hitting `whereIn` / `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 `$appends` accessors. Restrict to models
edited via a Filament Edit/View page or modal `EditAction`/`ViewAction`.
- **Flag if**: the attribute isn't in `$hidden`, isn't excluded by a `$visible`
whitelist, and isn't stripped in `mutateFormDataBeforeFill()`.
- **Fix**: add the column to `$hidden` (covers every path), or `unset()` it in
`mutateFormDataBeforeFill(array $data): array`. For modal
`EditAction`/`ViewAction`, the scrub is `->mutateRecordDataUsing(...)`.
- **Why**: Filament exposes all non-`$hidden` attributes 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) —
1. `grep -rn "withoutGlobalScopes(" app` — empty-arg calls drop tenancy too.
2. Enumerate tenant-owned models by their FK
(`grep -rnE "team_id|organization_id|tenant_id|company_id" database/migrations app/Models`)
and any `BelongsToTenant`-style trait. For each, confirm it's either
exposed through a tenant-panel resource (auto-scoped) or explicitly scoped
elsewhere. Also `grep -rnE "saveQuietly\(|withoutEvents\(|unguarded\(" app`
for 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:
1. **Add a resource** for the model (simplest — resource queries are
auto-scoped via the panel).
2. **Register a model-level global scope** that filters by
`Filament::getTenant()`. Pair with
`tenantMiddleware([...], 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 need
`Filament::setTenant($tenant)` at the job's entry — persistent middleware
doesn't run on workers.
3. **Use a `creating` model 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-arg
`withoutGlobalScopes()`.
- **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()` returns `true` unconditionally,
`getTenants()` returns every tenant (`Team::all()`), or `canAccessPanel()`
returns `true` for everyone on a panel with open registration. **Safe when**
each gates on real membership. A permissive `getTenants()` alone (with
`canAccessTenant()` 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/Filament` on resource form fields; also
`grep -rnE "['\"](unique|exists):" app/Filament` to 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; pass `model:` / `column:` only for a
non-default table).
- **Why**: Laravel's `unique`/`exists` query the DB directly without global
scopes, so cross-tenant data influences validation. Unscoped `exists` enables
cross-tenant reference binding; unscoped `unique` surfaces 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=plain` from the project root. Cross-check
package names against `composer.json` to 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 audit` reports 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 --with-all-dependencies` and re-run
`composer audit` to 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