Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/file-manager-advanced/application/class_fma_admin_menus.php
+++ b/file-manager-advanced/application/class_fma_admin_menus.php
@@ -1345,48 +1345,14 @@
*/
public function fmaPer()
{
- $settings = $this->get();
- $user = wp_get_current_user();
- $allowed_fma_user_roles = isset($settings['fma_user_roles']) ? $settings['fma_user_roles'] : array('administrator');
-
- if (!in_array('administrator', $allowed_fma_user_roles)) {
- $fma_user_roles = array_merge(array('administrator'), $allowed_fma_user_roles);
- } else {
- $fma_user_roles = $allowed_fma_user_roles;
- }
-
- $checkUserRoleExistance = array_intersect($fma_user_roles, $user->roles);
-
- if (count($checkUserRoleExistance) > 0 && !in_array('administrator', $checkUserRoleExistance)) {
- $fmaPer = 'read';
- } else {
- $fmaPer = 'manage_options';
- }
- return $fmaPer;
+ return class_fma_permissions::get_fma_capability();
}
/**
* Fma - Network Permissions
*/
public function networkPer()
{
- $settings = $this->get();
- $user = wp_get_current_user();
- $allowed_fma_user_roles = isset($settings['fma_user_roles']) ? $settings['fma_user_roles'] : array();
-
- $fma_user_roles = $allowed_fma_user_roles;
-
- $checkUserRoleExistance = array_intersect($fma_user_roles, $user->roles);
-
- if (count($checkUserRoleExistance) > 0) {
- if (!in_array('administrator', $checkUserRoleExistance)) {
- $fmaPer = 'read';
- } else {
- $fmaPer = 'manage_options';
- }
- } else {
- $fmaPer = 'manage_network';
- }
- return $fmaPer;
+ return class_fma_permissions::get_network_capability();
}
/**
* Diaplying AFM
--- a/file-manager-advanced/application/class_fma_connector.php
+++ b/file-manager-advanced/application/class_fma_connector.php
@@ -11,18 +11,27 @@
//read:https://github.com/Studio-42/elFinder/wiki/Connector-configuration-options
public function fma_local_file_system() {
$settings = get_option('fmaoptions');
- $path = ABSPATH;
+ $is_admin = class_fma_permissions::has_unrestricted_filesystem_access();
- if ( isset( $settings['public_path'] ) && ! empty($settings['public_path'] ) ) {
+ // Administrators keep Public Root Path / ABSPATH (unchanged behaviour).
+ $path = ABSPATH;
+ $url = site_url();
+
+ if ( isset( $settings['public_path'] ) && ! empty( $settings['public_path'] ) ) {
$path = $settings['public_path'];
}
- $url = site_url();
-
if ( isset( $settings['public_url'] ) && ! empty( $settings['public_url'] ) ) {
$url = $settings['public_url'];
}
+ // Non-admins are sandboxed to uploads so ABSPATH / core / plugins stay unreachable
+ // (AFM-885 / WPScan). Admins are not affected.
+ if ( ! $is_admin ) {
+ $path = class_fma_permissions::get_restricted_root_path();
+ $url = class_fma_permissions::get_restricted_root_url();
+ }
+
if ( isset( $settings['hide_path'] ) && ($settings['hide_path'] == '1' ) ) {
$url = '';
}
@@ -116,12 +125,12 @@
'URL' => $url, // URL to files (REQUIRED)
'trashHash' => $trash_f, // elFinder's hash of trash folder
'winHashFix' => DIRECTORY_SEPARATOR !== '/', // to make hash same to Linux one on windows too
- 'uploadDeny' => current_user_can('manage_options') ? array('all') : array('text/x-php'), // All Mimetypes not allowed to upload
+ 'uploadDeny' => $is_admin ? array('all') : class_fma_permissions::get_restricted_upload_deny_mimes(),
'uploadAllow' => $allowUpload,// Mimetype `image` and `text/plain` allowed to upload
- 'uploadOrder' => current_user_can('manage_options') ? array('deny','allow') :array('allow', 'deny'), // allowed Mimetype `image` and `text/plain` only
+ 'uploadOrder' => $is_admin ? array('deny','allow') : array('allow', 'deny'),
'disabled' => array('help','preference'),
'accessControl' => 'access',
- 'acceptedName' => current_user_can('manage_options') ? '' : 'afm_plugin_file_validName',
+ 'acceptedName' => $is_admin ? '' : 'afm_plugin_file_validName',
'uploadMaxSize' => $max_upload_size,
'searchTimeout' => 300,
'attributes' => array(
@@ -153,10 +162,47 @@
$trash,
),
);
- $opts['bind']['upload'] = array( $this, 'on_upload_event' );
+
+ if ( ! $is_admin ) {
+ $opts['roots'][0]['attributes'] = array_merge(
+ $opts['roots'][0]['attributes'],
+ class_fma_permissions::get_restricted_file_attributes()
+ );
+ // Same filename policy on every read/write path (not only get/put).
+ $opts['bind']['put.pre'] = array( $this, 'on_put_command' );
+ $opts['bind']['get.pre'] = array( $this, 'on_get_command' );
+ $opts['bind']['file.pre'] = array( $this, 'on_get_command' );
+ $opts['bind']['zipdl.pre'] = array( $this, 'on_zipdl_command' );
+ $opts['bind']['archive.pre'] = array( $this, 'on_archive_command' );
+ $opts['bind']['rm.pre'] = array( $this, 'on_rm_command' );
+ $opts['bind']['rename.pre'] = array( $this, 'on_rename_command' );
+ }
+
+ // SVG sanitiser + post-write policy for every command that can create/change files
+ // (AFM-885 WPScan: extract bypassed upload/put-only binds; rename/content sniff follow-up).
+ $opts['bind']['upload'] = array( $this, 'on_files_written_event' );
+ $opts['bind']['extract'] = array( $this, 'on_files_written_event' );
+ $opts['bind']['duplicate'] = array( $this, 'on_files_written_event' );
+ $opts['bind']['paste'] = array( $this, 'on_files_written_event' );
+ $opts['bind']['put'] = array( $this, 'on_put_event' );
+ $opts['bind']['rename'] = array( $this, 'on_rename_event' );
$opts['bind']['search.pre'] = array( $this, 'on_search_command' );
$opts = apply_filters( 'fma__opts_override', $opts );
+ // So cloud drivers building onetime/temp URLs hit the WP ajax connector.
+ if ( ! defined( 'ELFINDER_CONNECTOR_URL' ) ) {
+ define(
+ 'ELFINDER_CONNECTOR_URL',
+ add_query_arg(
+ array(
+ 'action' => 'fma_load_fma_ui',
+ '_fmakey' => wp_create_nonce( 'fmaskey' ),
+ ),
+ admin_url( 'admin-ajax.php' )
+ )
+ );
+ }
+
// run elFinder
$fma_connector = fma_create_elfinder_connector(new elFinder($opts));
try {
@@ -167,29 +213,482 @@
}
}
- public function on_upload_event( $cmd, &$args, $files, $elfinder, $volume ) {
- if ( 'upload' === $cmd ) {
- if ( isset( $args['added'] ) && is_array( $args['added'] ) ) {
- foreach ( $args['added'] as $key => $uploaded_file ) {
- if ( isset( $uploaded_file['mime'] ) && ( 'image/svg' === $uploaded_file['mime'] || strpos( $uploaded_file['mime'], 'svg' ) ) ) {
- $this->sanitize_svg_file_content( $uploaded_file, $volume );
- }
+ /**
+ * Block restricted overwrite operations for non-administrator users.
+ */
+ public function on_put_command( $cmd, &$args, $elfinder, $volume ) {
+ if ( empty( $args['target'] ) || ! $volume ) {
+ return;
+ }
+
+ $file = $volume->file( $args['target'] );
+ if ( ! $file || empty( $file['name'] ) ) {
+ return;
+ }
+
+ if ( ! class_fma_permissions::is_restricted_write_filename_allowed( $file['name'] ) ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_UPLOAD_FILE_MIME ),
+ ),
+ );
+ }
+ }
+
+ /**
+ * Block restricted read / download operations (get + file cmds).
+ */
+ public function on_get_command( $cmd, &$args, $elfinder, $volume ) {
+ if ( empty( $args['target'] ) || ! $volume ) {
+ return;
+ }
+
+ $file = $volume->file( $args['target'] );
+ if ( ! $file || empty( $file['name'] ) ) {
+ return;
+ }
+
+ if ( ! class_fma_permissions::is_restricted_write_filename_allowed( $file['name'] ) ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_ACCESS_DENIED ),
+ ),
+ );
+ }
+ }
+
+ /**
+ * Block zip download of restricted filenames.
+ */
+ public function on_zipdl_command( $cmd, &$args, $elfinder, $volume ) {
+ if ( empty( $args['targets'] ) || ! is_array( $args['targets'] ) || ! $volume ) {
+ return;
+ }
+
+ foreach ( $args['targets'] as $target ) {
+ $file = $volume->file( $target );
+ if ( ! $file || empty( $file['name'] ) ) {
+ continue;
+ }
+ if ( ! empty( $file['mime'] ) && 'directory' === $file['mime'] ) {
+ // Directory zipdl is handled by archive.pre / volume locks.
+ continue;
+ }
+ if ( ! class_fma_permissions::is_restricted_write_filename_allowed( $file['name'] ) ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_ACCESS_DENIED ),
+ ),
+ );
+ }
+ }
+ }
+
+ /**
+ * Refuse archiving targets that are locked/hidden or have restricted names.
+ */
+ public function on_archive_command( $cmd, &$args, $elfinder, $volume ) {
+ if ( empty( $args['targets'] ) || ! is_array( $args['targets'] ) || ! $volume ) {
+ return;
+ }
+
+ foreach ( $args['targets'] as $target ) {
+ $file = $volume->file( $target );
+ if ( ! $file ) {
+ continue;
+ }
+ if ( ! empty( $file['locked'] ) || ! empty( $file['hidden'] ) ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_PERM_DENIED ),
+ ),
+ );
+ }
+ if ( ! empty( $file['name'] ) && ! class_fma_permissions::is_restricted_write_filename_allowed( $file['name'] ) ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_PERM_DENIED ),
+ ),
+ );
+ }
+ // Directories: block if any locked/hidden descendant exists (incl. .php).
+ if ( ! empty( $file['mime'] ) && 'directory' === $file['mime'] && method_exists( $volume, 'closest' ) ) {
+ $locked = $volume->closest( $target, 'locked', true );
+ if ( $locked ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_PERM_DENIED ),
+ ),
+ );
}
}
}
}
+ /**
+ * Refuse recursive directory delete when locked/hidden children exist.
+ */
+ public function on_rm_command( $cmd, &$args, $elfinder, $volume ) {
+ if ( empty( $args['targets'] ) || ! is_array( $args['targets'] ) || ! $volume ) {
+ return;
+ }
+
+ foreach ( $args['targets'] as $target ) {
+ $file = $volume->file( $target );
+ if ( ! $file ) {
+ continue;
+ }
+ if ( ! empty( $file['locked'] ) ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_LOCKED, $file['name'] ),
+ ),
+ );
+ }
+ if ( ! empty( $file['mime'] ) && 'directory' === $file['mime'] && method_exists( $volume, 'closest' ) ) {
+ $locked = $volume->closest( $target, 'locked', true );
+ if ( $locked ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_PERM_DENIED ),
+ ),
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Block renaming to a restricted filename for non-administrators.
+ */
+ public function on_rename_command( $cmd, &$args, $elfinder, $volume ) {
+ if ( empty( $args['name'] ) ) {
+ return;
+ }
+
+ if ( ! class_fma_permissions::is_restricted_write_filename_allowed( $args['name'] ) ) {
+ return array(
+ 'preventexec' => true,
+ 'results' => array(
+ 'error' => array( elFinder::ERROR_UPLOAD_FILE_MIME ),
+ ),
+ );
+ }
+ }
+
+ /**
+ * Sanitize SVG after put (mkfile+put path skips upload sanitiser).
+ * Content-based: SVG payload in a non-.svg name is still sanitised so a
+ * later rename to .svg cannot revive script tags (AFM-885 residual).
+ */
+ public function on_put_event( $cmd, &$result, $args, $elfinder, $volume = null ) {
+ if ( 'put' !== $cmd || empty( $args['target'] ) || ! $volume ) {
+ return;
+ }
+
+ $file = $volume->file( $args['target'] );
+ if ( ! $file || empty( $file['name'] ) ) {
+ return;
+ }
+
+ $content_hint = isset( $args['content'] ) ? $args['content'] : null;
+ if ( $this->should_sanitize_as_svg( $file, $volume, $content_hint ) ) {
+ $this->sanitize_svg_file_content( $file, $volume );
+ }
+ }
+
+ /**
+ * After rename: sanitise if the new name is .svg or contents look like SVG.
+ * Closes: put malicious SVG into .txt → rename to .svg.
+ */
+ public function on_rename_event( $cmd, &$result, $args, $elfinder, $volume = null ) {
+ if ( 'rename' !== $cmd || ! $volume ) {
+ return;
+ }
+
+ $candidates = array();
+ if ( ! empty( $result['added'] ) && is_array( $result['added'] ) ) {
+ $candidates = array_merge( $candidates, $result['added'] );
+ }
+ if ( ! empty( $result['changed'] ) && is_array( $result['changed'] ) ) {
+ $candidates = array_merge( $candidates, $result['changed'] );
+ }
+
+ foreach ( $candidates as $file ) {
+ if ( empty( $file['hash'] ) || empty( $file['name'] ) ) {
+ continue;
+ }
+ if ( ! empty( $file['mime'] ) && 'directory' === $file['mime'] ) {
+ continue;
+ }
+ if ( $this->should_sanitize_as_svg( $file, $volume ) ) {
+ $this->sanitize_svg_file_content( $file, $volume );
+ }
+ }
+ }
+
+ /**
+ * Post-write handler for upload / extract / duplicate / paste (AFM-885).
+ *
+ * elFinder passes ($cmd, &$result, $args, $elfinder, $dstVolume).
+ * Apply SVG sanitiser to every newly created file (including nested extract),
+ * and for non-admins remove entries that fail the filename policy.
+ */
+ public function on_files_written_event( $cmd, &$result, $args, $elfinder, $volume = null ) {
+ if ( empty( $result['added'] ) || ! is_array( $result['added'] ) || ! $volume ) {
+ return;
+ }
+
+ $is_admin = class_fma_permissions::has_unrestricted_filesystem_access();
+ $written = $this->collect_written_file_stats( $result['added'], $volume );
+
+ foreach ( $written as $file ) {
+ if ( empty( $file['name'] ) || empty( $file['hash'] ) ) {
+ continue;
+ }
+
+ if ( ! $is_admin && ! class_fma_permissions::is_restricted_write_filename_allowed( $file['name'] ) ) {
+ $this->remove_volume_file( $file, $volume );
+ continue;
+ }
+
+ if ( $this->should_sanitize_as_svg( $file, $volume ) ) {
+ $this->sanitize_svg_file_content( $file, $volume );
+ }
+ }
+
+ // Drop removed restricted files from the client-facing "added" list.
+ if ( ! $is_admin ) {
+ $result['added'] = array_values(
+ array_filter(
+ $result['added'],
+ function ( $file ) {
+ if ( empty( $file['name'] ) ) {
+ return true;
+ }
+ if ( ! empty( $file['mime'] ) && 'directory' === $file['mime'] ) {
+ return true;
+ }
+ return class_fma_permissions::is_restricted_write_filename_allowed( $file['name'] );
+ }
+ )
+ );
+ }
+ }
+
+ /**
+ * BC alias — older references may still call on_upload_event.
+ */
+ public function on_upload_event( $cmd, &$result, $args, $elfinder, $volume = null ) {
+ return $this->on_files_written_event( $cmd, $result, $args, $elfinder, $volume );
+ }
+
+ /**
+ * Whether a file stat represents an SVG by name/MIME.
+ *
+ * @param array $file elFinder file stat.
+ * @return bool
+ */
+ private function is_svg_file_stat( $file ) {
+ $lower = isset( $file['name'] ) ? strtolower( $file['name'] ) : '';
+ $mime = isset( $file['mime'] ) ? (string) $file['mime'] : '';
+
+ return ( substr( $lower, -4 ) === '.svg' )
+ || ( substr( $lower, -4 ) === '.svgz' )
+ || ( false !== strpos( $mime, 'svg' ) );
+ }
+
+ /**
+ * Decide if SVG sanitiser should run (name/MIME or content sniff).
+ *
+ * @param array $file elFinder file stat.
+ * @param object $volume Volume driver.
+ * @param string|null $content_hint Optional in-memory content (e.g. put body).
+ * @return bool
+ */
+ private function should_sanitize_as_svg( $file, $volume, $content_hint = null ) {
+ if ( $this->is_svg_file_stat( $file ) ) {
+ return true;
+ }
+
+ if ( null !== $content_hint && $this->file_content_looks_like_svg( $content_hint ) ) {
+ return true;
+ }
+
+ $sample = $this->read_volume_file_sample( $file, $volume );
+ return $this->file_content_looks_like_svg( $sample );
+ }
+
+ /**
+ * Content-based SVG detection (WPScan: type from contents, not only name).
+ *
+ * @param string $content File contents or sample.
+ * @return bool
+ */
+ private function file_content_looks_like_svg( $content ) {
+ if ( ! is_string( $content ) || '' === $content ) {
+ return false;
+ }
+
+ // Only sniff text-ish payloads; skip obvious binaries.
+ if ( false !== strpos( substr( $content, 0, 512 ), "