Published : August 16, 2026

CVE-2026-15009: Advanced File Manager <= 5.4.12 Reflected Cross-Site Scripting via postMessage 'soundFile' Parameter PoC, Patch Analysis & Rule

Severity Medium (CVSS 6.1)
CWE 79
Vulnerable Version 5.4.12
Patched Version 5.4.13
Disclosed August 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-15009: This vulnerability is a Stored Cross-Site Scripting (XSS) issue found in the Advanced File Manager plugin for WordPress, affecting versions up to and including 5.4.12. The flaw resides in the lack of sanitization of the ‘soundFile’ parameter, which is processed through the plugin’s postMessage API. This allows an unauthenticated attacker to inject arbitrary web scripts that execute when an authenticated administrator with the File Manager screen open visits a maliciously crafted page. The CVSS score for this vulnerability is 6.1, and it is classified under CWE-79.

Root Cause: The root cause is the plugin’s failure to sanitize and escape the ‘soundFile’ parameter received via the browser’s postMessage API. The plugin registers a message event listener that handles commands from parent windows. The soundFile parameter is used to construct a URL for an audio file, which is then dynamically appended to the DOM. Because this value is not validated or escaped, an attacker can inject a javascript: URI or other malicious payload into the soundFile parameter. Once the admin screen processes this message, the payload is inserted into the DOM and executed in the context of the admin’s session. The attacker can achieve this by controlling a domain that is a leading prefix of the target site’s backend URL, a condition that can be met with common domain/site configurations.

Exploitation: The attack vector leverages the postMessage API from an attacker-controlled page. To trigger the vulnerability, an attacker crafts a page that opens a popup or iframe pointing to the WordPress admin File Manager screen. The attacker’s page then sends a postMessage to the File Manager window containing a crafted ‘soundFile’ value. This value is designed to contain a malicious JavaScript payload, such as a javascript: URL. The victim must be an authenticated administrator who has the File Manager admin screen open in their browser. When the victim visits the attacker’s page, the message is dispatched, the plugin processes the ‘soundFile’ parameter, and the malicious script executes in the admin’s session, potentially allowing the attacker to perform actions like creating rogue admin users or injecting further malicious content.

Patch Analysis: The provided patch is a comprehensive security overhaul that addresses multiple vulnerabilities, including this XSS issue. The diff does not directly show a fix to the ‘soundFile’ parameter handling. Instead, it demonstrates a broad refactoring of permission checks and file system access controls. The update introduces a new class ‘class_fma_permissions’ to centralize capability checks, replacing inline logic. It also introduces new security bindings for elFinder operations, stricter file validation for non-admin users, and a new nonce check for the main connector request. While the patch fixes several related issues, the specific fix for this CVE likely involves sanitizing the input from postMessage. However, the provided diff does not include the specific code that handles the soundFile parameter, so the exact sanitization logic is not visible. The patch’s overall approach of tightening security and adding new input sanitization for file operations suggests a comprehensive mitigation strategy.

Impact: Successful exploitation of this XSS vulnerability allows an attacker to execute arbitrary JavaScript in the browser of an authenticated WordPress administrator. This can lead to complete site compromise, as the attacker can perform any action the administrator can, including creating new admin accounts, modifying plugins and themes, injecting backdoors, and exfiltrating sensitive data. The attacker could also manipulate or delete files, potentially disrupting the site’s operation. The attack requires specific conditions (the attacker’s domain must be a prefix-match of the target’s URL, and the admin must visit the malicious page), which lowers the attack’s practical impact but does not eliminate the risk.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- 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 ), "" ) ) {
+			return false;
+		}
+
+		$sample = ltrim( $content, "xEFxBBxBF tnrx0B" );
+		$sample = substr( $sample, 0, 8192 );
+
+		return (bool) preg_match( '/<s*svgb/i', $sample );
+	}
+
+	/**
+	 * Read a small sample of a volume file for content sniffing.
+	 *
+	 * @param array  $file   File stat.
+	 * @param object $volume Volume driver.
+	 * @return string
+	 */
+	private function read_volume_file_sample( $file, $volume ) {
+		if ( empty( $file['hash'] ) || ! method_exists( $volume, 'getPath' ) ) {
+			return '';
+		}
+
+		$path = $volume->getPath( $file['hash'] );
+		if ( ! $path || ! is_file( $path ) || ! is_readable( $path ) ) {
+			return '';
+		}
+
+		// Cap read size for large uploads.
+		$size = filesize( $path );
+		if ( false === $size || $size < 1 ) {
+			return '';
+		}
+
+		$fh = fopen( $path, 'rb' );
+		if ( ! $fh ) {
+			return '';
+		}
+		$sample = fread( $fh, 8192 );
+		fclose( $fh );
+
+		return is_string( $sample ) ? $sample : '';
+	}
+
+	/**
+	 * Flatten added stats so nested extract contents are included.
+	 *
+	 * @param array $added  elFinder added stats.
+	 * @param object $volume Volume driver.
+	 * @return array
+	 */
+	private function collect_written_file_stats( $added, $volume ) {
+		$files = array();
+
+		foreach ( $added as $file ) {
+			if ( empty( $file['hash'] ) ) {
+				continue;
+			}
+			if ( ! empty( $file['mime'] ) && 'directory' === $file['mime'] ) {
+				$files = array_merge( $files, $this->collect_files_under_hash( $file['hash'], $volume ) );
+				continue;
+			}
+			$files[] = $file;
+		}
+
+		return $files;
+	}
+
+	/**
+	 * Recursively list file stats under a directory hash.
+	 *
+	 * @param string $hash   Directory hash.
+	 * @param object $volume Volume driver.
+	 * @return array
+	 */
+	private function collect_files_under_hash( $hash, $volume ) {
+		$files = array();
+
+		if ( ! method_exists( $volume, 'scandir' ) ) {
+			return $files;
+		}
+
+		$items = $volume->scandir( $hash );
+		if ( ! is_array( $items ) ) {
+			return $files;
+		}
+
+		foreach ( $items as $item ) {
+			if ( empty( $item['hash'] ) ) {
+				continue;
+			}
+			if ( ! empty( $item['mime'] ) && 'directory' === $item['mime'] ) {
+				$files = array_merge( $files, $this->collect_files_under_hash( $item['hash'], $volume ) );
+				continue;
+			}
+			$files[] = $item;
+		}
+
+		return $files;
+	}
+
+	/**
+	 * Delete a volume file from disk (and best-effort via volume API).
+	 *
+	 * @param array  $file   File stat.
+	 * @param object $volume Volume driver.
+	 * @return void
+	 */
+	private function remove_volume_file( $file, $volume ) {
+		if ( empty( $file['hash'] ) ) {
+			return;
+		}
+
+		if ( method_exists( $volume, 'rm' ) ) {
+			try {
+				$volume->rm( $file['hash'] );
+				return;
+			} catch ( Exception $e ) {
+				// Fall through to unlink.
+			}
+		}
+
+		if ( method_exists( $volume, 'getPath' ) ) {
+			$path = $volume->getPath( $file['hash'] );
+			if ( $path && is_file( $path ) ) {
+				@unlink( $path );
+			}
+		}
+	}
+
 	private function sanitize_svg_file_content( $uploaded_file, $volume ) {
 		require_once FMAFILEPATH . 'application/svg-sanitizer/includes/autoload.php';
-		if ( isset( $uploaded_file['hash'] ) ) {
-			$file_path = $volume->getPath( $uploaded_file['hash'] );
-			if ( file_exists( $file_path ) ) {
-				$file_content  = file_get_contents( $file_path );
-				$svg_sanitizer = new enshrinedsvgSanitizeSanitizer();
-				$file_content  = $svg_sanitizer->sanitize( $file_content );
-				file_put_contents( $file_path, $file_content );
-			}
+		if ( empty( $uploaded_file['hash'] ) || ! method_exists( $volume, 'getPath' ) ) {
+			return;
 		}
+
+		$file_path = $volume->getPath( $uploaded_file['hash'] );
+		if ( ! $file_path || ! file_exists( $file_path ) ) {
+			return;
+		}
+
+		$file_content  = file_get_contents( $file_path );
+		$svg_sanitizer = new enshrinedsvgSanitizeSanitizer();
+		$sanitized     = $svg_sanitizer->sanitize( $file_content );
+
+		// Sanitiser returns false on hard failure — never leave the original XSS payload.
+		if ( false === $sanitized || null === $sanitized ) {
+			$sanitized = '';
+		}
+
+		file_put_contents( $file_path, $sanitized );
 	}

 	/**
@@ -250,6 +749,7 @@
 		   || strpos($lower_name, '.config') !== false
 		   || strpos($lower_name, '.css') !== false
 		   || strpos($lower_name, '.js') !== false
+		   || preg_match( '/.(html?|xhtml|shtml)$/', $lower_name )
 		  ) {
 			return false;
 		}
--- a/file-manager-advanced/application/class_fma_elfinder_connector.php
+++ b/file-manager-advanced/application/class_fma_elfinder_connector.php
@@ -17,6 +17,26 @@
 class class_fma_elfinder_connector extends elFinderConnector
 {
 	/**
+	 * WordPress slashes $_POST; elFinder's parent filter only stripslashes when
+	 * magic_quotes_gpc is on (removed in PHP 5.4+). Apply wp_unslash on scalar
+	 * values only — parent recurses arrays via $this->input_filter(), so unslashing
+	 * the full array again would double-strip put content (" becomes ").
+	 *
+	 * @param mixed $args Request argument or array of arguments.
+	 * @return mixed
+	 */
+	protected function input_filter($args)
+	{
+		if (is_array($args)) {
+			return parent::input_filter($args);
+		}
+
+		$args = parent::input_filter($args);
+
+		return function_exists('wp_unslash') ? wp_unslash($args) : $args;
+	}
+
+	/**
 	 * Host-safe passthrough fallback.
 	 *
 	 * @param resource $fp File pointer.
--- a/file-manager-advanced/application/class_fma_local_filesystem.php
+++ b/file-manager-advanced/application/class_fma_local_filesystem.php
@@ -327,4 +327,35 @@

         return $result;
     }
+
+    /**
+     * Skip hidden items when scanning children for paste/move pre-checks.
+     *
+     * elFinder's copy() loop already ignores hidden files, but paste() calls
+     * closest('read', false) first and walks every child — including hidden
+     * .htaccess (read=false when "Display .htaccess?" is off). That blocked
+     * entire folder copy with "Permission denied".
+     *
+     * Exception: when looking for locked=true (rm/archive safety), do NOT skip
+     * hidden items — restricted .php files are hidden+locked and must block
+     * recursive delete / archive (AFM-885).
+     *
+     * @param string $path Directory path.
+     * @param string $attr Attribute name.
+     * @param bool   $val  Attribute value to match.
+     * @return string|false
+     */
+    protected function childsByAttr($path, $attr, $val)
+    {
+        foreach ($this->scandirCE($path) as $p) {
+            $stat = $this->stat($p);
+            if ('locked' !== $attr && !empty($stat['hidden'])) {
+                continue;
+            }
+            if (($_p = $this->closestByAttr($p, $attr, $val)) != false) {
+                return $_p;
+            }
+        }
+        return false;
+    }
 }
--- a/file-manager-advanced/application/class_fma_main.php
+++ b/file-manager-advanced/application/class_fma_main.php
@@ -40,11 +40,10 @@
 		add_action('wp_ajax_fma_review_ajax', array($this, 'fma_review_ajax'));
 		add_action('wp_ajax_fma_save_php_file', array($this, 'fma_save_php_file'));
 		add_action('wp_ajax_fma_debug_php', array($this, 'fma_debug_php'));
+		add_action('admin_footer', array($this, 'maybe_inject_review_header_line'));
 		$this->settings = get_option('fmaoptions');

 		add_action('admin_init', array($this, 'admin_init'));
-		// Hook into WordPress to handle slashes in POST data for elFinder
-		add_action('init', array($this, 'handle_elfinder_post_data'));

 		// Initialize SMTP recommendation
 		$this->init_smtp_recommendation();
@@ -65,11 +64,21 @@
 	 */
 	public function fma_load_fma_ui()
 	{
+		// Clear any buffered output so file/download streams stay binary-clean.
+		while (ob_get_level()) {
+			ob_end_clean();
+		}
+
+		class_fma_permissions::verify_ajax_access();
+
+		$fmakey = isset($_REQUEST['_fmakey']) ? sanitize_text_field(wp_unslash($_REQUEST['_fmakey'])) : '';
+		if ('' === $fmakey || !wp_verify_nonce($fmakey, 'fmaskey')) {
+			wp_die(esc_html__('Security check failed', 'file-manager-advanced'), esc_html__('Forbidden', 'file-manager-advanced'), array('response' => 403));
+		}
+
 		include 'class_fma_connector.php';
 		$fma_connector = new class_fma_connector();
-		if (wp_verify_nonce($_REQUEST['_fmakey'], 'fmaskey')) {
-			$fma_connector->fma_local_file_system();
-		}
+		$fma_connector->fma_local_file_system();
 	}

 	/**
@@ -102,6 +111,7 @@
 				wp_enqueue_style('elfinder.styles', FMA_PLUGIN_URL . 'application/assets/css/custom_style_filemanager_advanced.css', array(), FMA_VERSION, 'all');

 				wp_enqueue_script('elfinder', $library_url . 'js/elfinder.min.js', array('jquery', 'jquery-ui-core', 'jquery-ui-selectable', 'jquery-ui-draggable', 'jquery-ui-droppable', 'jquery-ui-resizable', 'jquery-ui-dialog', 'jquery-ui-slider', 'jquery-ui-tabs'), FMA_VERSION, true);
+				wp_enqueue_script('fma-elfinder-security', FMA_PLUGIN_URL . 'application/assets/js/fma-elfinder-security.js', array('jquery', 'elfinder'), FMA_VERSION, true);
 				wp_enqueue_script('codemirror', $library_url . 'codemirror/lib/codemirror.js', array(), FMA_VERSION, true);
 				wp_enqueue_script('codemirror.htmlmixed', $library_url . 'codemirror/mode/htmlmixed/htmlmixed.js', array(), FMA_VERSION, true);
 				wp_enqueue_script('codemirror.xml', $library_url . 'codemirror/mode/xml/xml.js', array(), FMA_VERSION, true);
@@ -118,8 +128,8 @@
 					wp_enqueue_style('codemirror.theme', $library_url . 'codemirror/theme/' . $cm_theme . '.css', array(), FMA_VERSION, 'all');
 				}

-				wp_enqueue_script('fma-elfinder-commands', FMA_PLUGIN_URL . 'application/assets/js/fma-elfinder-commands.js', array('jquery', 'elfinder'), FMA_VERSION, true);
-				wp_enqueue_script('elfinder.script', FMA_PLUGIN_URL . 'application/assets/js/elfinder_script.js', array('jquery', 'fma-elfinder-commands'), FMA_VERSION, true);
+				wp_enqueue_script('fma-elfinder-commands', FMA_PLUGIN_URL . 'application/assets/js/fma-elfinder-commands.js', array('jquery', 'elfinder', 'fma-elfinder-security'), FMA_VERSION, true);
+				wp_enqueue_script('elfinder.script', FMA_PLUGIN_URL . 'application/assets/js/elfinder_script.js', array('jquery', 'fma-elfinder-security', 'fma-elfinder-commands'), FMA_VERSION, true);
 				wp_localize_script(
 					'elfinder.script',
 					'afm_object',
@@ -197,6 +207,112 @@
 	}

 	/**
+	 * Thank-you / Rate Us line used in AFM admin headers.
+	 *
+	 * @return string
+	 */
+	public static function review_header_line_html() {
+		$img = plugins_url( 'images/5stars.png', FMAFILEPATH . 'application/pages/main.php' );
+		return sprintf(
+			'<span id="thankyou" class="fma-header-review description">%s<a class="fma-header-review-rate" href="%s" target="_blank" rel="noopener noreferrer">%s <img src="%s" alt=""></a></span>',
+			wp_kses_post( __( 'Thank you for using <a href="https://wordpress.org/plugins/file-manager-advanced/">File Manager Advanced</a>. If happy then ', 'file-manager-advanced' ) ),
+			esc_url( 'https://wordpress.org/support/plugin/file-manager-advanced/reviews/?filter=5' ),
+			esc_html__( ' Rate Us', 'file-manager-advanced' ),
+			esc_url( $img )
+		);
+	}
+
+	/**
+	 * Show the Rate Us line in the header on AFM pages except the main File Manager screen.
+	 */
+	public function maybe_inject_review_header_line() {
+		if ( ! is_admin() ) {
+			return;
+		}
+
+		$page      = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
+		$post_type = isset( $_GET['post_type'] ) ? sanitize_text_field( wp_unslash( $_GET['post_type'] ) ) : '';
+
+		if ( 'file_manager_advanced_ui' === $page ) {
+			return;
+		}
+
+		$is_afm = false;
+		if ( '' !== $page && (
+			0 === strpos( $page, 'file_manager_advanced' )
+			|| 0 === strpos( $page, 'afmp-' )
+			|| 0 === strpos( $page, 'fma_shortcode' )
+			|| 'afm-integrations-pro' === $page
+		) ) {
+			$is_afm = true;
+		}
+		if ( 'fma_shortcode' === $post_type ) {
+			$is_afm = true;
+		}
+		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
+		if ( $screen && isset( $screen->post_type ) && 'fma_shortcode' === $screen->post_type ) {
+			$is_afm = true;
+		}
+
+		if ( ! $is_afm ) {
+			return;
+		}
+
+		$html = self::review_header_line_html();
+		?>
+		<style>
+			.wrap .fma-header-review {
+				float: right;
+				margin: 0;
+				padding: 6px 0 0;
+				line-height: 1.4;
+				white-space: nowrap;
+			}
+			.wrap .fma-header-review a {
+				text-decoration: none;
+			}
+			.wrap .fma-header-review .fma-header-review-rate {
+				display: inline-flex;
+				align-items: center;
+				gap: 4px;
+				vertical-align: middle;
+			}
+			.wrap .fma-header-review img {
+				width: 100px;
+				height: auto;
+				vertical-align: middle;
+			}
+		</style>
+		<script>
+		jQuery(function ($) {
+			if ($('.fma-header-review').length) {
+				return;
+			}
+			var $wrap = $('.wrap').first();
+			var $h = $wrap.children('h1').first();
+			if (!$h.length) {
+				$h = $wrap.children('h2').first();
+			}
+			if (!$h.length) {
+				return;
+			}
+			$h.addClass('wp-heading-inline');
+			var html = <?php echo wp_json_encode( $html ); ?>;
+			var $lastAction = $h.nextAll('.page-title-action').last();
+			if ($lastAction.length) {
+				$lastAction.after(html);
+			} else {
+				$h.after(html);
+			}
+			if (!$h.nextAll('hr.wp-header-end').length) {
+				$wrap.find('.fma-header-review').first().after('<hr class="wp-header-end">');
+			}
+		});
+		</script>
+		<?php
+	}
+
+	/**
 	 * Review Ajax
 	 */
 	public function fma_review_ajax()
@@ -245,6 +361,8 @@
 	 */
 	public function fma_debug_php()
 	{
+		class_fma_permissions::verify_ajax_access();
+
 		// Check nonce for security
 		if (!wp_verify_nonce($_POST['nonce'], 'fmaskey')) {
 			wp_die(__('Security check failed', 'file-manager-advanced'));
@@ -269,14 +387,20 @@
 	 */
 	public function fma_save_php_file()
 	{
+		if (!class_fma_permissions::user_has_file_manager_access()) {
+			wp_send_json_error(array('message' => __('You do not have permission to access the file manager.', 'file-manager-advanced')));
+			return;
+		}
+
 		// Check nonce for security
 		if (!wp_verify_nonce($_POST['nonce'], 'fmaskey')) {
 			wp_send_json_error(array('message' => __('Security check failed', 'file-manager-advanced')));
 			return;
 		}

-		// Get the PHP code and file info from POST data
-		$php_code = wp_unslash($_POST['php_code']);
+		// Get the PHP code and file info from POST data.
+		// Keep php_code slashed here; connector applies wp_unslash once on put content.
+		$php_code = isset($_POST['php_code']) ? $_POST['php_code'] : '';
 		$file_hash = sanitize_text_field($_POST['file_hash']);
 		$filename = sanitize_text_field(wp_unslash($_POST['filename']));

@@ -291,7 +415,6 @@
 			$_POST = array(
 				'cmd' => 'put',
 				'target' => $file_hash,
-				// Pass raw content: elFinder does not stripslashes on modern PHP; wp_slash() corrupts " sequences.
 				'content' => $php_code,
 				'action' => 'fma_load_fma_ui',
 				'_fmakey' => wp_create_nonce('fmaskey')
@@ -360,28 +483,6 @@
 		}
 	}

-	/**
-	 * Handle elFinder POST data to remove WordPress slashes
-	 */
-	public function handle_elfinder_post_data()
-	{
-		// Only process on admin AJAX requests for our file manager
-		if (!is_admin() || !defined('DOING_AJAX') || !DOING_AJAX) {
-			return;
-		}
-
-		// Check if this is our elFinder request
-		if (!isset($_POST['action']) || $_POST['action'] !== 'fma_load_fma_ui') {
-			return;
-		}
-
-		// WordPress slashes $_POST; elFinder only stripslashes when magic_quotes_gpc (removed in PHP 5.4+).
-		if (isset($_POST['cmd']) && 'put' === $_POST['cmd'] && isset($_POST['content']) && is_string($_POST['content'])) {
-			$_POST['content']    = wp_unslash($_POST['content']);
-			$_REQUEST['content'] = $_POST['content'];
-		}
-	}
-
 	/**
 	 * Initialize SMTP recommendation using universal system
 	 * @since 6.7.3
--- a/file-manager-advanced/application/class_fma_permissions.php
+++ b/file-manager-advanced/application/class_fma_permissions.php
@@ -0,0 +1,272 @@
+<?php
+/**
+ * File Manager Advanced permission helpers.
+ *
+ * @package File Manager Advanced
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+if ( class_exists( 'class_fma_permissions' ) ) {
+	return;
+}
+
+/**
+ * Centralized access control for the file manager.
+ */
+class class_fma_permissions {
+
+	/**
+	 * Whether the current user may use the file manager.
+	 *
+	 * @return bool
+	 */
+	public static function user_has_file_manager_access() {
+		if ( ! is_user_logged_in() ) {
+			return false;
+		}
+
+		$capability = self::get_required_capability();
+
+		if ( ! current_user_can( $capability ) ) {
+			return false;
+		}
+
+		// The `read` capability is granted to every logged-in user; require an allowed role too.
+		if ( 'read' === $capability && ! self::user_role_is_allowed() ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Capability required to render and use the file manager UI.
+	 *
+	 * @return string
+	 */
+	public static function get_required_capability() {
+		if ( is_multisite() && ! is_network_admin() ) {
+			return self::get_network_capability();
+		}
+
+		return self::get_fma_capability();
+	}
+
+	/**
+	 * Single-site capability logic (mirrors class_fma_admin_menus::fmaPer).
+	 *
+	 * @return string
+	 */
+	public static function get_fma_capability() {
+		$settings               = get_option( 'fmaoptions' );
+		$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, true ) ) {
+			$fma_user_roles = array_merge( array( 'administrator' ), $allowed_fma_user_roles );
+		} else {
+			$fma_user_roles = $allowed_fma_user_roles;
+		}
+
+		$check_user_role_existence = array_intersect( $fma_user_roles, $user->roles );
+
+		if ( count( $check_user_role_existence ) > 0 && ! in_array( 'administrator', $check_user_role_existence, true ) ) {
+			return 'read';
+		}
+
+		return 'manage_options';
+	}
+
+	/**
+	 * Multisite capability logic (mirrors class_fma_admin_menus::networkPer).
+	 *
+	 * @return string
+	 */
+	public static function get_network_capability() {
+		$settings               = get_option( 'fmaoptions' );
+		$user                   = wp_get_current_user();
+		$allowed_fma_user_roles = isset( $settings['fma_user_roles'] ) ? $settings['fma_user_roles'] : array();
+
+		$check_user_role_existence = array_intersect( $allowed_fma_user_roles, $user->roles );
+
+		if ( count( $check_user_role_existence ) > 0 ) {
+			if ( ! in_array( 'administrator', $check_user_role_existence, true ) ) {
+				return 'read';
+			}
+
+			return 'manage_options';
+		}
+
+		return 'manage_network';
+	}
+
+	/**
+	 * Whether the current user's role is explicitly allowed in plugin settings.
+	 *
+	 * @return bool
+	 */
+	public static function user_role_is_allowed() {
+		$settings = get_option( 'fmaoptions' );
+		$user     = wp_get_current_user();
+
+		if ( in_array( 'administrator', $user->roles, true ) ) {
+			return true;
+		}
+
+		$allowed_fma_user_roles = isset( $settings['fma_user_roles'] ) ? $settings['fma_user_roles'] : array( 'administrator' );
+
+		return ! empty( array_intersect( $allowed_fma_user_roles, $user->roles ) );
+	}
+
+	/**
+	 * Whether the current user has unrestricted filesystem access.
+	 *
+	 * @return bool
+	 */
+	public static function has_unrestricted_filesystem_access() {
+		return current_user_can( 'manage_options' );
+	}
+
+	/**
+	 * Root directory used to sandbox non-administrator users (uploads).
+	 * Keeps granted roles out of ABSPATH / wp-admin / wp-includes / plugins.
+	 *
+	 * @return string
+	 */
+	public static function get_restricted_root_path() {
+		$upload_dir = wp_upload_dir();
+
+		if ( ! empty( $upload_dir['basedir'] ) ) {
+			$path = wp_normalize_path( $upload_dir['basedir'] );
+		} else {
+			$path = wp_normalize_path( WP_CONTENT_DIR . '/uploads' );
+		}
+
+		if ( ! is_dir( $path ) ) {
+			wp_mkdir_p( $path );
+		}
+
+		return $path;
+	}
+
+	/**
+	 * Public URL for the uploads-based restricted root directory.
+	 *
+	 * @return string
+	 */
+	public static function get_restricted_root_url() {
+		$upload_dir = wp_upload_dir();
+
+		if ( ! empty( $upload_dir['baseurl'] ) ) {
+			return $upload_dir['baseurl'];
+		}
+
+		return content_url( 'uploads' );
+	}
+
+	/**
+	 * MIME types denied for non-administrator upload and overwrite operations.
+	 *
+	 * @return array
+	 */
+	public static function get_restricted_upload_deny_mimes() {
+		return array(
+			'text/x-php',
+			'application/x-httpd-php',
+			'application/x-php',
+			'text/javascript',
+			'application/javascript',
+			'application/x-javascript',
+			'text/css',
+			'application/x-executable',
+			'text/html',
+			'application/xhtml+xml',
+		);
+	}
+
+	/**
+	 * Volume attribute rules blocking sensitive files for non-administrators.
+	 *
+	 * @return array
+	 */
+	public static function get_restricted_file_attributes() {
+		return array(
+			array(
+				// Covers .php, .php.bak, .php~, etc.
+				'pattern' => '/.php(.|$)/i',
+				'read'    => false,
+				'write'   => false,
+				'hidden'  => true,
+				'locked'  => true,
+			),
+			array(
+				'pattern' => '/.phtml(.|$)/i',
+				'read'    => false,
+				'write'   => false,
+				'hidden'  => true,
+				'locked'  => true,
+			),
+			array(
+				'pattern' => '/.js(.|$)/i',
+				'read'    => false,
+				'write'   => false,
+				'hidden'  => true,
+				'locked'  => true,
+			),
+			array(
+				'pattern' => '/.css(.|$)/i',
+				'read'    => false,
+				'write'   => false,
+				'hidden'  => true,
+				'locked'  => true,
+			),
+			array(
+				'pattern' => '/.htaccess$/i',
+				'read'    => false,
+				'write'   => false,
+				'hidden'  => true,
+				'locked'  => true,
+			),
+			array(
+				'pattern' => '/wp-config(.|$)/i',
+				'read'    => false,
+				'write'   => false,
+				'hidden'  => true,
+				'locked'  => true,
+			),
+			array(
+				'pattern' => '/.(html?|xhtml|shtml)$/i',
+				'read'    => false,
+				'write'   => false,
+				'hidden'  => true,
+				'locked'  => true,
+			),
+		);
+	}
+
+	/**
+	 * Abort AJAX requests from users without file manager access.
+	 *
+	 * @return void
+	 */
+	public static function verify_ajax_access() {
+		if ( ! self::user_has_file_manager_access() ) {
+			wp_die( esc_html__( 'You do not have permission to access the file manager.', 'file-manager-advanced' ), esc_html__( 'Forbidden', 'file-manager-advanced' ), array( 'response' => 403 ) );
+		}
+	}
+
+	/**
+	 * Whether a filename is allowed for non-administrator write operations.
+	 *
+	 * @param string $name File name.
+	 * @return bool
+	 */
+	public static function is_restricted_write_filename_allowed( $name ) {
+		if ( empty( $name ) ) {
+			return false;
+		}
+
+		return (bool) afm_plugin_file_validName( $name );
+	}
+}
--- a/file-manager-advanced/application/library/php/editors/OnlineConvert/editor.php
+++ b/file-manager-advanced/application/library/php/editors/OnlineConvert/editor.php
@@ -89,7 +89,11 @@
             $response = curl_exec($ch);
             $info = curl_getinfo($ch);
             $error = curl_error($ch);
-            curl_close($ch);
+            if (PHP_VERSION_ID < 80000) {
+                curl_close($ch);
+            } else {
+                unset($ch);
+            }

             if (!empty($error)) {
                 $res = array('error' => $error);
--- a/file-manager-advanced/application/library/php/editors/ZohoOffice/editor.php
+++ b/file-manager-advanced/application/library/php/editors/ZohoOffice/editor.php
@@ -80,6 +80,8 @@

     public function init()
     {
+        $this->gcCallbackStates();
+
         if (!defined('ELFINDER_ZOHO_OFFICE_APIKEY') || !function_exists('curl_init')) {
             return array('error', array(elFinder::ERROR_CONF, '`ELFINDER_ZOHO_OFFICE_APIKEY` or curl extension'));
         }
@@ -100,7 +102,11 @@
                     curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie);
                 }
                 $res = curl_exec($ch);
-                curl_close($ch);
+                if (PHP_VERSION_ID < 80000) {
+                    curl_close($ch);
+                } else {
+                    unset($ch);
+                }
                 if ($res) {
                     if ($data = json_decode($res, true)) {
                         $save = !empty($data['cansave']);
@@ -139,7 +145,7 @@
                     'callback_settings' => array(
                         'save_format' => $format,
                         'save_url_params' => array(
-                            'hash' => $hash
+                            'content' => 'content'
                         )
                     ),
                     'editor_settings' => $this->editor_settings[$srvsName],
@@ -149,6 +155,26 @@
                 );
                 $data['editor_settings']['language'] = $lang;
                 if ($save) {
+                    $ttl = defined('ELFINDER_ZOHO_OFFICE_CALLBACK_TTL')
+                        ? (int)ELFINDER_ZOHO_OFFICE_CALLBACK_TTL
+                        : 3600;
+
+                    if ($ttl <= 0) {
+                        $ttl = 3600;
+                    } else if ($ttl > 86400) {
+                        $ttl = 86400;
+                    }
+                    $saveParams = $this->createCallbackState('save', $hash, $this->getCallbackStateSecret(), array(), $ttl);
+                    if ($saveParams === false) {
+                        $save = false;
+                    } else {
+                        $data['callback_settings']['save_url_params'] = array_merge(
+                            $data['callback_settings']['save_url_params'],
+                            $saveParams
+                        );
+                    }
+                }
+                if ($save) {
                     $conUrl = elFinder::getConnectorUrl();
                     $data['callback_settings']['save_url'] = $conUrl . (strpos($conUrl, '?') !== false? '&' : '?') . 'cmd=editor&name=' . $this->myName . '&method=save' . $cdata;
                 }
@@ -169,7 +195,11 @@
                 curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
                 $res = curl_exec($ch);
                 $error = curl_error($ch);
-                curl_close($ch);
+                if (PHP_VERSION_ID < 80000) {
+                    curl_close($ch);
+                } else {
+                    unset($ch);
+                }

                 $fp && fclose($fp);

@@ -196,17 +226,33 @@

     public function save()
     {
-        if (!empty($_POST) && !empty($_POST['hash']) && !empty($_FILES) && !empty($_FILES['content'])) {
-            $hash = $_POST['hash'];
-            /** @var elFinderVolumeDriver $volume */
-            if ($volume = $this->elfinder->getVolume($hash)) {
-                if ($content = file_get_contents($_FILES['content']['tmp_name'])) {
-                    if ($volume->putContents($hash, $content)) {
-                        return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 200 OK');
-                    }
-                }
+        $state = $this->verifyCallbackRequest('save', $_POST, $this->getCallbackStateSecret());
+        if ($state === false) {
+            return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 403 Forbidden');
+        }
+
+        if (empty($_FILES['content']) || (isset($_FILES['content']['error']) && (int)$_FILES['content']['error'] !== 0)) {
+            return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 500 Internal Server Error');
+        }
+
+        $tmpName = isset($_FILES['content']['tmp_name']) ? $_FILES['content']['tmp_name'] : '';
+        if (!$tmpName || !is_uploaded_file($tmpName) && !is_file($tmpName)) {
+            return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 500 Internal Server Error');
+        }
+
+        $content = file_get_contents($tmpName);
+        if ($content === false) {
+            return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 500 Internal Server Error');
+        }
+
+        $hash = $state['hash'];
+        /** @var elFinderVolumeDriver $volume */
+        if ($volume = $this->elfinder->getVolume($hash)) {
+            if ($volume->putContents($hash, $content)) {
+                return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 200 OK');
             }
         }
+
         return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 500 Internal Server Error');
     }

@@ -222,4 +268,13 @@
         }
         return array('cansave' => $res);
     }
+
+    protected function getCallbackStateSecret()
+    {
+        return hash('sha256', implode('|', array(
+            __CLASS__,
+            __FILE__,
+            ELFINDER_ZOHO_OFFICE_APIKEY
+        )));
+    }
 }
--- a/file-manager-advanced/application/library/php/editors/editor.php
+++ b/file-manager-advanced/application/library/php/editors/editor.php
@@ -8,6 +8,20 @@
 class elFinderEditor
 {
     /**
+     * Lifetime of callback states.
+     *
+     * @var int
+     */
+    protected $callbackStateTtl = 86400;
+
+    /**
+     * Dedicated directory name for callback states.
+     *
+     * @var string
+     */
+    protected $callbackStateDirName = 'elfinder_editor_callback_state';
+
+    /**
      * Array of allowed method by request from client side.
      *
      * @var array
@@ -76,4 +90,319 @@
     {
         return isset($this->args[$key]) ? $this->args[$key] : $empty;
     }
+
+    /**
+     * Create callback state and return token params for a callback request.
+     *
+     * @param string $method
+     * @param string $hash
+     * @param string $secret
+     * @param array  $meta
+     * @param int    $ttl
+     *
+     * @return array|false
+     */
+    protected function createCallbackState($method, $hash, $secret, $meta = array(), $ttl = null)
+    {
+        $this->gcCallbackStates();
+
+        $token = $this->createCallbackStateToken();
+        $expires = time() + max(60, is_null($ttl) ? (int)$this->callbackStateTtl : (int)$ttl);
+        $state = array(
+            'editor' => get_class($this),
+            'method' => (string)$method,
+            'token' => $token,
+            'hash' => $hash,
+            'expires' => $expires,
+            'meta' => is_array($meta) ? $meta : array()
+        );
+
+        if (!$this->writeCallbackState($token, $state)) {
+            return false;
+        }
+
+        return array(
+            'token' => $token,
+            'expires' => $expires,
+            'sig' => $this->createCallbackSignature($method, $token, $expires, $secret)
+        );
+    }
+
+    /**
+     * Verify callback request and return state.
+     *
+     * @param string $method
+     * @param array  $post
+     * @param string $secret
+     * @param string $tokenKey
+     * @param string $expiresKey
+     * @param string $sigKey
+     *
+     * @return array|false
+     */
+    protected function verifyCallbackRequest($method, $post, $secret, $tokenKey = 'token', $expiresKey = 'expires', $sigKey = 'sig')
+    {
+        $this->gcCallbackStates();
+
+        if (!is_array($post)) {
+            return false;
+        }
+
+        $token = isset($post[$tokenKey]) ? (string)$post[$tokenKey] : '';
+        $expires = isset($post[$expiresKey]) ? (string)$post[$expiresKey] : '';
+        $sig = isset($post[$sigKey]) ? (string)$post[$sigKey] : '';
+
+        if ($token === '' || $expires === '' || $sig === '' || !ctype_digit($expires)) {
+            return false;
+        }
+
+        $expires = (int)$expires;
+        if ($expires < time()) {
+            $this->deleteCallbackState($token);
+            return false;
+        }
+
+        $expectedSig = $this->createCallbackSignature($method, $token, $expires, $secret);
+        if (!$this->hashEquals($expectedSig, $sig)) {
+            return false;
+        }
+
+        $state = $this->readCallbackState($token);
+        if (!$state) {
+            return false;
+        }
+
+        if (empty($state['editor']) || !$this->hashEquals($state['editor'], get_class($this))
+            || empty($state['method']) || !$this->hashEquals($state['method'], (string)$method)
+            || empty($state['token']) || !$this->hashEquals($state['token'], $token)
+            || !isset($state['expires']) || (int)$state['expires'] !== $expires
+            || empty($state['hash'])) {
+            return false;
+        }
+
+        return $state;
+    }
+
+    /**
+     * Consume callback state.
+     *
+     * @param string $token
+     *
+     * @return void
+     */
+    protected function consumeCallbackState($token)
+    {
+        $this->deleteCallbackState($token);
+    }
+
+    /**
+     * Garbage collect expired callback states.
+     *
+     * @return void
+     */
+    protected function gcCallbackStates()
+    {
+        $dir = $this->getCallbackStateDir(false);
+        if (!$dir) {
+            return;
+        }
+
+        $files = glob($dir . DIRECTORY_SEPARATOR . '*.json');
+        if (!$files) {
+            return;
+        }
+
+        $now = time();
+        foreach ($files as $path) {
+            if (!is_file($path)) {
+                continue;
+            }
+
+            $remove = false;
+            $json = file_get_contents($path);
+            if ($json === false || $json === '') {
+                $remove = true;
+            } else {
+                $state = json_decode($json, true);
+                if (!is_array($state) || empty($state['expires']) || (int)$state['expires'] < $now) {
+                    $remove = true;
+                }
+            }
+
+            if ($remove) {
+                @unlink($path);
+            }
+        }
+    }
+
+    /**
+     * Return callback state directory.
+     *
+     * @param bool $create
+     *
+     * @return string|false
+     */
+    protected function getCallbackStateDir($create = false)
+    {
+        $base = elFinder::getCommonTempPath();
+        if (!$base) {
+            return false;
+        }
+
+        $dir = $base . DIRECTORY_SEPARATOR . $this->callbackStateDirName;
+        if (!is_dir($dir)) {
+            if (!$create || !@mkdir($dir, 0700, true)) {
+                return false;
+            }
+        }
+
+        return is_writable($dir) ? $dir : false;
+    }
+
+    /**
+     * Return callback state file path.
+     *
+     * @param string $token
+     * @param bool   $create
+     *
+     * @return string|false
+     */
+    protected function getCallbackStatePath($token, $create = false)
+    {
+        $dir = $this->getCallbackStateDir($create);
+        if (!$dir || !is_string($token) || $token === '') {
+            return false;
+        }
+
+        return $dir . DIRECTORY_SEPARATOR . hash('sha256', $token) . '.json';
+    }
+
+    /**
+     * Persist callback state.
+     *
+     * @param string $token
+     * @param array  $state
+     *
+     * @return bool
+     */
+    protected function writeCallbackState($token, $state)
+    {
+        $path = $this->getCallbackStatePath($token, true);
+        if (!$path) {
+            return false;
+        }
+
+        $json = json_encode($state);
+        if ($json === false) {
+            return false;
+        }
+
+        return file_put_contents($path, $json, LOCK_EX) !== false;
+    }
+
+    /**
+     * Read callback state.
+     *
+     * @param string $token
+     *
+     * @return array|false
+     */
+    protected function readCallbackState($token)
+    {
+        $path = $this->getCallbackStatePath($token, false);
+        if (!$path || !is_file($path)) {
+            return false;
+        }
+
+        $json = file_get_contents($path);
+        if ($json === false || $json === '') {
+            return false;
+        }
+
+        $state = json_decode($json, true);
+
+        return is_array($state) ? $state : false;
+    }
+
+    /**
+     * Delete callback state.
+     *
+     * @param string $token
+     *
+     * @return void
+     */
+    protected function deleteCallbackState($token)
+    {
+        $path = $this->getCallbackStatePath($token, false);
+        if ($path && is_file($path)) {
+            @unlink($path);
+        }
+    }
+
+    /**
+     * Create callback signature.
+     *
+     * @param string $method
+     * @param string $token
+     * @param int    $expires
+     * @param string $secret
+     *
+     * @return string
+     */
+    protected function createCallbackSignature($method, $token, $expires, $secret)
+    {
+        $payload = implode('|', array(get_class($this), (string)$method, (string)$token, (string)$expires));
+
+        return hash_hmac('sha256', $payload, (string)$secret);
+    }
+
+    /**
+     * Create random callback state token.
+     *
+     * @return string
+     */
+    protected function createCallbackStateToken()
+    {
+        if (function_exists('random_bytes')) {
+            return bin2hex(random_bytes(32));
+        }
+
+        if (function_exists('openssl_random_pseudo_bytes')) {
+            $bytes = openssl_random_pseudo_bytes(32);
+            if ($bytes !== false) {
+                return bin2hex($bytes);
+            }
+        }
+
+        return md5(uniqid(mt_rand(), true)) . md5(uniqid(mt_rand(), true));
+    }
+
+    /**
+     * Constant-time string comparison.
+     *
+     * @param string $known
+     * @param string $user
+     *
+     * @return bool
+     */
+    protected function hashEquals($known, $user)
+    {
+        if (function_exists('hash_equals')) {
+            return hash_equals((string)$known, (string)$user);
+        }
+
+        $known = (string)$known;
+        $user = (string)$user;
+        if (strlen($known) !== strlen($user)) {
+            return false;
+        }
+
+        $result = 0;
+        $length = strlen($known);
+        for ($i = 0; $i < $length; $i++) {
+            $result |= ord($known[$i]) ^ ord($user[$i]);
+        }
+
+        return $result === 0;
+    }
 }
--- a/file-manager-advanced/application/library/php/elFinder.class.php
+++ b/file-manager-advanced/application/library/php/elFinder.class.php
@@ -32,7 +32,7 @@
      *
      * @var integer
      */
-    protected static $ApiRevision = 67;
+    protected static $ApiRevision = 69;

     /**
      * Storages (root dirs)
@@ -611,8 +611,8 @@
             $errLevel |= E_DEPRECATED | E_USER_DEPRECATED;
         }
         // E_STRICT is deprecated; see https://wiki.php.net/rfc/deprecations_php_8_4#remove_e_strict_error_level_and_deprecate_e_strict_constant
-        if (defined('E_STRICT')) {
-            $errLevel |= @E_STRICT;
+        if (PHP_VERSION_ID < 80400 && defined('E_STRICT')) {
+            $errLevel |= E_STRICT;
         }
         set_error_handler('elFinder::phpErrorHandler', $errLevel);

@@ -672,6 +672,12 @@
                 'keys' => array(
                     'default' => !empty($opts['sessionCacheKey']) ? $opts['sessionCacheKey'] : 'elFinderCaches',
                     'netvolume' => !empty($opts['netVolumesSessionKey']) ? $opts['netVolumesSessionKey'] : 'elFinderNetVolumes'
+                ),
+                'cookieParams' => array(
+                    'path' => '/',
+                    'secure' => true,
+                    'httponly' => true,
+                    'samesite' => defined('ELFINDER_COOKIE_SAMESITE')? ELFINDER_COOKIE_SAMESITE : 'Lax'
                 )
             );
             if (!class_exists('elFinderSession')) {
@@ -857,6 +863,7 @@

             if (class_exists($class)) {
                 /* @var elFinderVolumeDriver $volume */
+                $vName = preg_replace('/^elFinderVolume/', '', $class);
                 $volume = new $class();

                 try {
@@ -880,13 +887,13 @@
                         if (!empty($o['_isNetVolume'])) {
                             $this->removeNetVolume($i, $volume);
                         }
-                        $this->mountErrors[] = 'Driver "' . $class . '" : ' . implode(' ', $volume->error());
+                        $this->mountErrors = array_merge($this->mountErrors, array(self::ERROR_NETMOUNT, $vName), $volume->error());
                     }
                 } catch (Exception $e) {
                     if (!empty($o['_isNetVolume'])) {
                         $this->removeNetVolume($i, $volume);
                     }
-                    $this->mountErrors[] = 'Driver "' . $class . '" : ' . $e->getMessage();
+                    $this->mountErrors = array_merge($this->mountErrors, array(self::ERROR_NETMOUNT, $vName, $e->getMessage()));
                 }
             } else {
                 if (!empty($o['_isNetVolume'])) {
@@ -2087,7 +2094,7 @@
         }

         if ($args['cpath'] && $args['reqid']) {
-            setcookie('elfdl' . $args['reqid'], '1', 0, $args['cpath']);
+            setcookie('elfdl' . $args['reqid'], '1', 0, urlencode($args['cpath']));
         }

         $result = array(
@@ -2728,7 +2735,11 @@
             }
             return $this->curl_get_contents($new_url, $timeout, $redirect_max - 1, $ua, $outfp, $info);
         }
-        curl_close($ch);
+        if (PHP_VERSION_ID < 80000) {
+            curl_close($ch);
+        } else {
+            unset($ch);
+        }
         return $outfp ? $outfp : $result;
     }

@@ -4202,11 +4213,28 @@
                 $origin = isset($_SERVER['HTTP_ORIGIN'])? str_replace(''', '\'', $_SERVER['HTTP_ORIGIN']) : '*';
                 $script .= '
 var go = function() {
-    var w = window.opener || window.parent || window,
-        close = function(){
-            window.open("about:blank","_self").close();
-            return false;
-        };
+    var w = window.opener || window.parent || window;
+    var closeWindow = function(){
+        try {
+            window.close();
+        } catch(e) {}
+        return false;
+    };
+    var showMessage = function() {
+        var msg = document.getElementById('msg');
+        var link = msg && msg.getElementsByTagName('a')[0];
+        if (msg) {
+            msg.style.display = 'inline';
+        }
+        if (link) {
+            link.onclick = function(ev) {
+                if (ev && ev.preventDefault) {
+                    ev.preventDefault();
+                }
+                return closeWindow();
+            };
+        }
+    };
     try {
         var elf = w.document.getElementById('' . $

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.