Add File Manager sub-app (Pro feature) (v2.7.0)

New browse/edit interface for the WordPress install's own files, built
using the installed "WP File Manager" plugin (elFinder-based) as a
functional reference rather than embedding it. Own REST controller
(FileManagerData.php) + Vue page, same pattern as every other sub-app.

Security is the whole point here (highest-risk surface in the plugin —
arbitrary filesystem read/write):
- Root is ABSPATH, manage_options-gated throughout.
- Every client path is resolved via realpath() and checked for root
  containment against the *resolved* path, not string-matched against
  the raw input — catches symlink tricks and encoded traversal that a
  naive "../" check would miss.
- Uploads/new files/renames reject a blocklist of server-executable
  extensions (.php, .cgi, .sh, etc.) — the single most important
  control, since uploading a webshell is the classic exploit path for
  this class of feature.
- Deliberately not built: zip/unzip (zip-slip vulnerability class),
  chmod/permissions editing, cross-directory move/copy.

Gated as a paid feature (riskier than Database Explorer, which is
already paid) — a judgment call flagged to the user, not something they
specified.

Also fixed a real bug found while wiring this up: SubApps.php's
page-level lock doesn't stop someone with manage_options from calling
the REST endpoints directly, bypassing the license check entirely.
File Manager's REST permission callbacks now also check
License::is_feature_available(). The same gap exists in Database
Explorer, Role Editor, and Activity Log's REST controllers and is
flagged as a follow-up task, not fixed here (out of scope for what was
asked, affects three unrelated features).

Verified live via direct REST calls: path traversal blocked (404),
blocked extensions rejected (400), root deletion blocked (400), and a
full create/write/read/rename/delete round trip succeeded cleanly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
nixpanel
2026-07-05 06:59:24 -05:00
co-authored by Claude Sonnet 5
parent 204c4365e2
commit 15d6060ae3
12 changed files with 1228 additions and 4 deletions
+2
View File
@@ -15,6 +15,7 @@ use AuroraAdmin\Rest\PostsData;
use AuroraAdmin\Rest\PagesData;
use AuroraAdmin\Rest\MediaData;
use AuroraAdmin\Rest\UsersData;
use AuroraAdmin\FileManager\FileManagerData;
use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps;
@@ -55,6 +56,7 @@ class AuroraAdmin
new PagesData();
new MediaData();
new UsersData();
new FileManagerData();
new ScheduledDayFilter();
new WhiteLabel();
new SubApps();
+466
View File
@@ -0,0 +1,466 @@
<?php
namespace AuroraAdmin\FileManager;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's File Manager — browse/edit the WordPress
* install's own files, in the spirit of the "WP File Manager" plugin
* already installed on this site (same root — ABSPATH — and same
* manage_options-only gate), but built fresh rather than embedding
* elFinder, to match every other Aurora page's own REST-controller +
* Vue-page pattern.
*
* manage_options for every single endpoint here, no exceptions — this is
* the highest-risk surface in the whole plugin (arbitrary read/write
* access to the site's own PHP files). Every path a client sends is
* resolved through resolve_path()/safe_child_path(), which realpath()s it
* and verifies it's still inside ABSPATH afterward — string-matching
* "../" alone isn't enough (symlinks, encoded traversal, etc.), so
* containment is always checked against the resolved real path, never the
* raw input.
*
* Deliberately NOT implemented (scope decisions, not oversights):
* - Zip/unzip — extraction has its own well-known class of vulnerability
* (zip-slip: a crafted archive's entries escape the extraction dir).
* - chmod/permissions editing — changing permissions on a live WP install
* is one of the easiest ways to accidentally lock yourself out or open
* a real hole; not worth it for a v1.
* - Move/copy across directories — rename (same-directory) is supported;
* full move/copy trees is more surface area for a first pass.
*/
class FileManagerData
{
// Never let these be uploaded, renamed-to, or created — anything
// server-executable is an instant RCE if it lands somewhere web-served.
const BLOCKED_EXTENSIONS = [
"php", "php3", "php4", "php5", "php7", "phtml", "pht", "phar", "phps",
"cgi", "pl", "py", "rb", "sh", "bash", "asp", "aspx", "jsp", "jspx",
"exe", "dll", "so", "com", "bat", "cmd", "htaccess",
];
// Binary-ish extensions the text editor refuses to open — avoids
// dumping garbled bytes into the editor and "saving" it back mangled.
const BINARY_EXTENSIONS = [
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "svg",
"pdf", "zip", "gz", "tar", "rar", "7z",
"mp3", "mp4", "mov", "avi", "wav", "ogg", "webm",
"woff", "woff2", "ttf", "eot", "otf",
"exe", "dll", "so",
];
const MAX_EDIT_SIZE = 2 * 1024 * 1024; // 2MB — editing bigger files in a plain textarea isn't useful anyway.
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
// Checked here, not just at the page-render layer (SubApps.php) — a
// locked page alone doesn't stop someone with manage_options from
// calling these endpoints directly, only the UI does.
$permission = function () {
return current_user_can("manage_options") && License::is_feature_available("file-manager");
};
register_rest_route("aurora-admin/v1", "/file-manager/list", [
"methods" => "GET",
"callback" => [self::class, "list_dir"],
"permission_callback" => $permission,
"args" => ["path" => ["type" => "string", "required" => false]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/folder", [
"methods" => "POST",
"callback" => [self::class, "create_folder"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/file", [
"methods" => "POST",
"callback" => [self::class, "create_file"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/upload", [
"methods" => "POST",
"callback" => [self::class, "upload_file"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/download", [
"methods" => "GET",
"callback" => [self::class, "download_file"],
"permission_callback" => function () {
// Direct navigation link, not fetch() — no X-WP-Nonce header
// possible, so also accept the nonce as a query param (WP's
// cookie-auth check already looks at $_REQUEST, which covers
// this) alongside the same capability+license check as everywhere
// else here.
return current_user_can("manage_options") && License::is_feature_available("file-manager");
},
"args" => ["path" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/rename", [
"methods" => "POST",
"callback" => [self::class, "rename_item"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/delete", [
"methods" => "POST",
"callback" => [self::class, "delete_item"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/read", [
"methods" => "GET",
"callback" => [self::class, "read_file"],
"permission_callback" => $permission,
"args" => ["path" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/write", [
"methods" => "POST",
"callback" => [self::class, "write_file"],
"permission_callback" => $permission,
]);
}
private static function root()
{
return rtrim(str_replace("\\", "/", realpath(ABSPATH)), "/");
}
/**
* Resolves a client-supplied relative path to a real, root-contained
* absolute path — or false if it doesn't exist or escapes the root.
* Containment is checked against realpath()'s output, not the raw
* string, so symlinks/traversal tricks can't slip past a naive
* substring check.
*/
private static function resolve_path($relative)
{
$relative = str_replace("\\", "/", (string) $relative);
$relative = ltrim($relative, "/");
if (strpos($relative, "\0") !== false) {
return false;
}
$root = self::root();
$candidate = $relative === "" ? $root : $root . "/" . $relative;
$real = realpath($candidate);
if ($real === false) {
return false;
}
$real = str_replace("\\", "/", $real);
if ($real !== $root && strpos($real, $root . "/") !== 0) {
return false;
}
return $real;
}
private static function sanitize_name($name)
{
$name = wp_basename((string) $name);
$name = trim($name);
if ($name === "" || $name === "." || $name === "..") {
return "";
}
if (preg_match('/[\x00-\x1F]/', $name)) {
return "";
}
return $name;
}
private static function extension($name)
{
return strtolower(pathinfo($name, PATHINFO_EXTENSION));
}
public static function list_dir($request)
{
$relative = (string) $request->get_param("path");
$real = self::resolve_path($relative);
if ($real === false || !is_dir($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("Folder not found.", "aurora-admin"), ["status" => 404]);
}
$root = self::root();
$entries = @scandir($real);
if ($entries === false) {
return new \WP_Error("aurora_fm_read_failed", __("Could not read folder.", "aurora-admin"), ["status" => 500]);
}
$items = [];
foreach ($entries as $entry) {
if ($entry === "." || $entry === "..") {
continue;
}
$full = $real . "/" . $entry;
$is_dir = is_dir($full);
$items[] = [
"name" => $entry,
"path" => ltrim(substr($full, strlen($root)), "/"),
"type" => $is_dir ? "dir" : "file",
"size" => $is_dir ? null : @filesize($full),
"sizeLabel" => $is_dir ? "" : size_format((int) @filesize($full)),
"modified" => gmdate("M j, Y g:i a", @filemtime($full)),
"extension" => $is_dir ? "" : self::extension($entry),
"isEditable" => !$is_dir && self::is_editable($entry, @filesize($full)),
"isWritable" => is_writable($full),
];
}
usort($items, function ($a, $b) {
if ($a["type"] !== $b["type"]) {
return $a["type"] === "dir" ? -1 : 1;
}
return strcasecmp($a["name"], $b["name"]);
});
$current = ltrim(substr($real, strlen($root)), "/");
return new \WP_REST_Response([
"path" => $current,
"breadcrumb" => self::breadcrumb($current),
"items" => $items,
], 200);
}
private static function breadcrumb($path)
{
if ($path === "") {
return [];
}
$parts = explode("/", $path);
$crumbs = [];
$accum = "";
foreach ($parts as $part) {
$accum = $accum === "" ? $part : $accum . "/" . $part;
$crumbs[] = ["label" => $part, "path" => $accum];
}
return $crumbs;
}
private static function is_editable($name, $size)
{
if ($size !== false && $size > self::MAX_EDIT_SIZE) {
return false;
}
$ext = self::extension($name);
if ($ext !== "" && in_array($ext, self::BINARY_EXTENSIONS, true)) {
return false;
}
return true;
}
public static function create_folder($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
$name = self::sanitize_name($request->get_param("name"));
if ($parent_real === false || !is_dir($parent_real) || $name === "") {
return new \WP_Error("aurora_fm_invalid", __("Invalid folder name or location.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (!@mkdir($target, 0755)) {
return new \WP_Error("aurora_fm_mkdir_failed", __("Could not create the folder.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Folder created.", "aurora-admin")], 200);
}
public static function create_file($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
$name = self::sanitize_name($request->get_param("name"));
if ($parent_real === false || !is_dir($parent_real) || $name === "") {
return new \WP_Error("aurora_fm_invalid", __("Invalid file name or location.", "aurora-admin"), ["status" => 400]);
}
$ext = self::extension($name);
if ($ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true)) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (@file_put_contents($target, "") === false) {
return new \WP_Error("aurora_fm_create_failed", __("Could not create the file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("File created.", "aurora-admin")], 200);
}
public static function upload_file($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
if ($parent_real === false || !is_dir($parent_real)) {
return new \WP_Error("aurora_fm_invalid", __("Invalid upload location.", "aurora-admin"), ["status" => 400]);
}
$files = $request->get_file_params();
if (empty($files["file"]) || !isset($files["file"]["tmp_name"])) {
return new \WP_Error("aurora_fm_no_file", __("No file was uploaded.", "aurora-admin"), ["status" => 400]);
}
$file = $files["file"];
if (!empty($file["error"])) {
return new \WP_Error("aurora_fm_upload_error", __("Upload failed.", "aurora-admin"), ["status" => 400]);
}
if ($file["size"] > self::MAX_UPLOAD_SIZE) {
return new \WP_Error("aurora_fm_too_large", __("File is larger than the 50MB limit.", "aurora-admin"), ["status" => 400]);
}
$name = self::sanitize_name($file["name"]);
$ext = self::extension($name);
if ($name === "" || ($ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true))) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (!is_uploaded_file($file["tmp_name"]) || !@move_uploaded_file($file["tmp_name"], $target)) {
return new \WP_Error("aurora_fm_upload_failed", __("Could not save the uploaded file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("File uploaded.", "aurora-admin")], 200);
}
public static function download_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
nocache_headers();
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"" . basename($real) . "\"");
header("Content-Length: " . filesize($real));
readfile($real);
exit();
}
public static function rename_item($request)
{
$real = self::resolve_path($request->get_param("path"));
$new_name = self::sanitize_name($request->get_param("newName"));
if ($real === false || $new_name === "" || $real === self::root()) {
return new \WP_Error("aurora_fm_invalid", __("Invalid rename request.", "aurora-admin"), ["status" => 400]);
}
$ext = self::extension($new_name);
if (!is_dir($real) && $ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true)) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$parent = dirname($real);
$target = $parent . "/" . $new_name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (!@rename($real, $target)) {
return new \WP_Error("aurora_fm_rename_failed", __("Could not rename.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Renamed.", "aurora-admin")], 200);
}
public static function delete_item($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || $real === self::root()) {
return new \WP_Error("aurora_fm_invalid", __("Invalid delete request.", "aurora-admin"), ["status" => 400]);
}
$ok = is_dir($real) ? self::delete_recursive($real) : @unlink($real);
if (!$ok) {
return new \WP_Error("aurora_fm_delete_failed", __("Could not delete.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Deleted.", "aurora-admin")], 200);
}
private static function delete_recursive($dir)
{
$entries = @scandir($dir);
if ($entries === false) {
return false;
}
foreach ($entries as $entry) {
if ($entry === "." || $entry === "..") {
continue;
}
$full = $dir . "/" . $entry;
if (is_dir($full) && !is_link($full)) {
if (!self::delete_recursive($full)) {
return false;
}
} elseif (!@unlink($full)) {
return false;
}
}
return @rmdir($dir);
}
public static function read_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
$size = filesize($real);
if (!self::is_editable(basename($real), $size)) {
return new \WP_Error("aurora_fm_not_editable", __("This file can't be opened in the editor (binary or too large).", "aurora-admin"), ["status" => 400]);
}
$content = @file_get_contents($real);
if ($content === false) {
return new \WP_Error("aurora_fm_read_failed", __("Could not read the file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response([
"content" => $content,
"size" => $size,
"isWritable" => is_writable($real),
], 200);
}
public static function write_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
if (!self::is_editable(basename($real), 0)) {
return new \WP_Error("aurora_fm_not_editable", __("This file can't be edited.", "aurora-admin"), ["status" => 400]);
}
$content = (string) $request->get_param("content");
if (@file_put_contents($real, $content) === false) {
return new \WP_Error("aurora_fm_write_failed", __("Could not save the file — check that it's writable.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Saved.", "aurora-admin")], 200);
}
}
+1
View File
@@ -26,6 +26,7 @@ class License
"role-editor" => "Role Editor",
"activity-log" => "Activity Log",
"database-explorer" => "Database Explorer",
"file-manager" => "File Manager",
"analytics" => "Analytics",
"white-label" => "White Label",
"remote-site-switcher" => "Remote Site Switcher",
+1
View File
@@ -44,6 +44,7 @@ class SubApps
"aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor", "feature" => "role-editor"],
"aurora-admin-activity-log" => ["title" => "Activity Log", "entry" => "activity-log", "feature" => "activity-log"],
"aurora-admin-database" => ["title" => "Database Explorer", "entry" => "database-explorer", "feature" => "database-explorer"],
"aurora-admin-file-manager" => ["title" => "File Manager", "entry" => "file-manager", "feature" => "file-manager"],
"aurora-admin-plugins" => ["title" => "Installed Plugins", "entry" => "plugins", "feature" => null],
"aurora-admin-comments" => ["title" => "Comments", "entry" => "comments", "feature" => null],
"aurora-admin-posts" => ["title" => "Posts", "entry" => "posts", "feature" => null],
+1
View File
@@ -241,6 +241,7 @@ class Shell
["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups", "feature" => "role-editor"],
["id" => "aurora-admin-activity-log", "title" => __("Activity Log", "aurora-admin"), "icon" => "dashicons-clock", "feature" => "activity-log"],
["id" => "aurora-admin-database", "title" => __("Database Explorer", "aurora-admin"), "icon" => "dashicons-database", "feature" => "database-explorer"],
["id" => "aurora-admin-file-manager", "title" => __("File Manager", "aurora-admin"), "icon" => "dashicons-portfolio", "feature" => "file-manager"],
];
$nav = [];