laravelplus / content-security
Defense-in-depth content security for Laravel: malware scanning, MIME/magic-byte verification, archive/image/PDF inspection, HTML sanitization, URL/SSRF checks, quarantine, audit trail and a Vue 3 security console.
Requires
- php: ^8.5
- ext-fileinfo: *
- ext-json: *
- laravel/framework: ^13.0
- symfony/html-sanitizer: ^7.0|^8.0
Requires (Dev)
- inertiajs/inertia-laravel: ^3.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
Suggests
- ext-gd: Required to validate and re-encode images.
- ext-zip: Required to inspect ZIP archives (zip bombs, path traversal, embedded executables).
- inertiajs/inertia-laravel: Required for the /admin/content-security console.
This package is not auto-updated.
Last update: 2026-09-01 06:59:45 UTC
README
A defence-in-depth content security layer for Laravel: malware scanning, MIME
and magic-byte verification, archive/image/PDF inspection, HTML sanitization,
URL and SSRF checks, quarantine, a full audit trail, and a Vue 3 security
console at /admin/content-security.
use LaravelPlus\ContentSecurity\Rules\SafeFile; $request->validate([ 'attachment' => ['required', 'file', new SafeFile()], ]);
use LaravelPlus\ContentSecurity\Facades\ContentSecurity; $result = ContentSecurity::scanFile($file); if ($result->isClean()) { // accept }
What this is, and what it is not
Content Security is one layer. It does not replace, and must not be described as replacing:
- proper authorization
- Laravel's own validation
- parameterised SQL queries — an ORM or query bindings
- contextual output escaping
- a Content-Security-Policy header
- CSRF protection
- serving uploads from outside the web root
- OS and container hardening
- keeping malware signatures up to date
Two specific claims this package explicitly does not make:
It does not prevent SQL injection. Parameterised queries do. The
suspicious-content scanner recognises some payload shapes, which is useful
as an alerting signal — "someone just pasted a UNION SELECT into a job
description" — and useless as a control. A codebase that concatenates user
input into SQL is not made safe by installing this.
It does not prevent XSS on its own. Contextual output escaping does. What this package adds is a real HTML sanitizer (a parser, never a regex) for the cases where you must accept and re-render untrusted markup.
And no file is ever "100% safe". A clean scan means no configured check objected — not that the file is harmless.
Requirements
- PHP 8.5+
- Laravel 13
ext-fileinfo(required),ext-zip(archives),ext-gd(images)- ClamAV
clamdfor malware scanning — optional but strongly recommended inertiajs/inertia-laravelfor the admin console
Installation
composer require laravelplus/content-security php artisan content-security:install php artisan migrate
Then tell the package who may open the console — it denies everyone until you do:
// AppServiceProvider::boot() ContentSecurity::auth(fn (User $user): bool => $user->isAdmin());
Check the wiring:
php artisan content-security:status
ClamAV
# Debian / Ubuntu sudo apt install clamav clamav-daemon sudo systemctl enable --now clamav-freshclam clamav-daemon
CONTENT_SECURITY_CLAMAV_CONNECTION=unix CONTENT_SECURITY_CLAMAV_SOCKET=/var/run/clamav/clamd.ctl
The package talks to the resident clamd daemon rather than spawning
clamscan per upload — the binary reloads the entire signature database on
every invocation, which is roughly a second of CPU and several hundred MB of
RAM each time.
Verify from the console (Scanner health → Test scanner) or:
php artisan content-security:health
Fail closed
This is the single most important behaviour in the package.
When a scanner cannot answer — the daemon is down, the socket times out, the file is unreadable — the scan is reported as failed, and a failed scan is not clean:
$result->isClean(); // false $result->failed(); // true $result->isThreat(); // false — nothing was found, because nothing looked
failed() is deliberately not the opposite of isThreat(). "We looked and
found nothing" and "we could not look" are different facts, and a system that
conflates them accepts every upload the moment its scanner falls over.
Set fail_closed => false only if you have thought hard about it.
Scanning
ContentSecurity::scanFile($uploadedFile); // default policy ContentSecurity::scanFile($uploadedFile, 'images'); // named policy ContentSecurity::scanFile('/path/to/file.pdf'); ContentSecurity::scanDisk('s3', 'uploads/report.pdf'); ContentSecurity::scanFileOrFail($file); // throws PolicyViolationException ContentSecurity::scanText($comment); ContentSecurity::scanHtml($richText); ContentSecurity::sanitizeHtml($richText); // returns clean HTML ContentSecurity::scanUrl($website); ContentSecurity::queue($largeFile); // returns a ScanId
The result
$result->isClean(); $result->isThreat(); $result->isInfected(); $result->failed(); $result->status(); // ScanStatus enum $result->threats(); // list<Threat> $result->checks(); // per-check outcomes $result->duration(); // milliseconds $result->scanner(); $result->scanId(); $result->metadata(); $result->highestThreatLevel();
The file pipeline
Cheapest first, so an obviously bad upload never reaches the engine:
size → extension → MIME → magic bytes → archive → image → PDF → malware
Every step is switchable per policy, and a step that is switched off is recorded as skipped rather than omitted — a disabled malware scan must never look identical to a passing one.
Validation rules
$request->validate([ 'attachment' => ['nullable', 'file', new SafeFile()], 'avatar' => ['nullable', 'image', new SafeFile('images')], 'description' => ['required', new SafeHtml()], 'bio' => ['required', 'string', new SafeText()], 'website' => ['nullable', new SafeUrl()], ]);
User-facing messages are deliberately vague ("This file did not pass a security check"). Naming the signature or the check would turn the endpoint into a free malware sandbox that tells an attacker which attempt got closest. The full detail goes to the audit log and the console.
The developer-facing detail is available on the rule:
$rule = new SafeFile(); $request->validate(['attachment' => ['required', 'file', $rule]]); $rule->result()->scanId(); // store it against your own model
For rich text, consider sanitizing instead of rejecting:
$post->body = ContentSecurity::sanitizeHtml($request->string('body'));
Quarantine
upload → quarantine disk → scan → clean ──→ final storage
└ threat → quarantine / delete
Unscanned and rejected files never touch normal storage. Stored filenames are generated ULIDs — the uploader's filename is kept as metadata, where it can be read but never obeyed.
'storage' => [ 'quarantine_disk' => 'local', // MUST NOT be web-served 'quarantine_path' => 'content-security/quarantine', ],
Releasing a file rescans it first and proceeds only on a clean result. An override is possible, must be asked for explicitly, and is dispatched as an audited event.
Policies
config/content-security.php is the baseline. With admin.manage_policies
on, the database holds overrides on top of it, edited from the console and
recorded in the audit log. A policy nobody has touched has no row at all and
reads straight from config, so your deployment defaults stay the reviewed,
version-controlled thing they should be — and Reset always means
something.
Two things are never overridable at runtime, enforced in the repository rather than in the UI:
forbidden_extensions— the server-executable formats. A console that could addphpto an allowlist would be the vulnerability.- the
max_size_ceiling.
Set CONTENT_SECURITY_MANAGE_POLICIES=false for installations that want
their security policy reviewable in a diff and nowhere else.
Admin console
Mounted at /admin/content-security (configurable via
content-security.admin.prefix or CONTENT_SECURITY_ADMIN_PREFIX).
Overview · Scans · Threats · Quarantine · Policies · Scanner health
php artisan content-security:publish-pages npm run build
If your app maps Inertia pages to layouts, exclude the console — it ships its own full-page shell:
case name.startsWith('admin/content-security/'): return undefined;
Authorization is entirely yours. The package denies by default: no callback and no gate means nobody gets in, and unauthorised visitors get a 404 rather than a 403, because confirming that a security console exists at this URL is itself information they should not have.
Reports
A daily digest and a weekly roll-up:
CONTENT_SECURITY_REPORT_TO=security@example.com
Both are scheduled automatically once recipients are set. The daily mail is sent only when there is something to say — findings, scan failures, or an offline engine. The weekly one always goes out, so a quiet week is confirmed rather than assumed. A digest that says "nothing happened" every day for six months trains its readers to delete it unopened, and then the one that matters is deleted too.
php artisan content-security:report --period=weekly --preview
Events
Event::listen(ThreatDetected::class, SendSecurityAlert::class);
ScanStarted · ScanCompleted · ScanFailed · ThreatDetected ·
FileQuarantined · QuarantineReleased · QuarantineDeleted ·
PolicyChanged
ScanFailed deserves an alert of its own: a fail-closed application with a
broken scanner is rejecting every upload.
Extending
// A different malware engine ContentSecurity::extend('yara', fn ($app, $config) => new YaraScanner($config)); // Your own checks ContentSecurity::addFileCheck(WatermarkCheck::class, before: MalwareCheck::class); ContentSecurity::addTextCheck(ProfanityCheck::class); // Hooks into the middle of a scan ContentSecurity::beforeScan(fn ($context) => $context->withActor(auth()->id())); ContentSecurity::afterScan(fn ($result, $context) => $result); // Policies from your own store ContentSecurity::resolveFilePolicyUsing(fn (string $name) => $tenant->policy($name));
Every collaborator is bound to a contract — MalwareScanner, Sanitizer,
ArchiveInspector, ImageInspector, PdfInspector, UrlInspector,
ScanRepository, PolicyRepository — so any of them can be replaced from
your own service provider without forking the package.
See docs/extending.md.
Commands
php artisan content-security:status # config, engines, recent activity php artisan content-security:scan file.pdf # exit 0 clean, 1 otherwise php artisan content-security:health # exit non-zero if an engine is down php artisan content-security:report --period=daily php artisan content-security:cleanup-quarantine php artisan content-security:install php artisan content-security:publish-pages
Privacy
Scanned text is never stored by default — the audit row keeps a SHA-256 and a
length. Threat metadata never carries the matched payload, and log lines never
carry file contents, scanned text, or filesystem paths outside the quarantine
root. Opt in to short samples with persistence.store_text_samples.
Production notes
- Keep the quarantine disk off the web root.
content-security:statuswarns if it looks publicly served. - Configure your web server so uploaded files are never executed.
- Run
freshclam— an engine with year-old signatures is theatre. - Use
ContentSecurity::queue()for large files; scanning a 500 MB upload inside a request cycle will not end well. - Alert on
ScanFailed, not only onThreatDetected. - Leave
fail_closedon.
Testing
composer test # Pest composer analyse # PHPStan level 8 composer lint # Pint
The suite never requires ClamAV — use FakeMalwareScanner. Integration tests
against a real daemon live in the separate Integration suite.
Security
Please report vulnerabilities per SECURITY.md.
Contributing
See CONTRIBUTING.md.
Credits
License
MIT. See LICENSE.