self::SNIFF_HEAD + self::SNIFF_TAIL ) { fseek( $fh, -self::SNIFF_TAIL, SEEK_END ); $buf .= (string) fread( $fh, self::SNIFF_TAIL ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose // "]+language\s*=\s*["\']?php/i', $buf ) ) { return 'contains PHP code'; } // SVG logos are fine; SVG with script or event handlers is an XSS payload. if ( in_array( $last, [ 'svg', 'svgz' ], true ) || false !== stripos( substr( $buf, 0, 1024 ), ' &$info ) { if ( ! is_array( $info ) || ! isset( $info['name'] ) ) { continue; } // Normalise single and multi (name[]) uploads into leaf paths. foreach ( self::leaves( $info['name'] ) as $keys ) { $name = self::dig( $info['name'], $keys ); $tmp = isset( $info['tmp_name'] ) ? self::dig( $info['tmp_name'], $keys ) : ''; if ( ! is_string( $name ) || '' === $name ) { continue; } $why = self::verdict( $name, is_string( $tmp ) ? $tmp : '' ); /** * Allow a specific upload the shield would refuse. * * @param bool $allow * @param string $name * @param string $why */ if ( '' === $why || apply_filters( 'gemsecurity/upload_shield_allow', false, sanitize_text_field( wp_unslash( $name ) ), $why ) ) { continue; } if ( is_string( $tmp ) && '' !== $tmp && is_file( $tmp ) ) { wp_delete_file( $tmp ); } self::poke( $info, $keys, UPLOAD_ERR_EXTENSION, 0 ); self::$blocked[ strtolower( $name ) ] = $why; $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; PluginGuard::queue_event( 'upload_blocked', 'upload:' . $ip, sprintf( '%s — %s (%s)', sanitize_file_name( $name ), $why, strtok( $uri, '?' ) ) ); PluginGuard::queue_ban( $ip, 'Upload Shield: ' . $why ); } } unset( $info ); // phpcs:enable } /** Names blocked in layer 1, so the prefilter can explain why. */ private static $blocked = []; /** Key paths to every leaf of a (possibly nested) $_FILES name entry. */ private static function leaves( $node, array $prefix = [] ) { if ( ! is_array( $node ) ) { return [ $prefix ]; } $out = []; foreach ( $node as $k => $child ) { $out = array_merge( $out, self::leaves( $child, array_merge( $prefix, [ $k ] ) ) ); } return $out; } private static function dig( $node, array $keys ) { foreach ( $keys as $k ) { if ( ! is_array( $node ) || ! isset( $node[ $k ] ) ) { return null; } $node = $node[ $k ]; } return $node; } /** Mark one leaf failed: error code set, temp path and size cleared. */ private static function poke( array &$info, array $keys, $error, $size ) { foreach ( [ 'error' => $error, 'tmp_name' => '', 'size' => $size ] as $field => $val ) { if ( ! isset( $info[ $field ] ) ) { continue; } if ( ! $keys ) { $info[ $field ] = $val; continue; } $ref = &$info[ $field ]; foreach ( $keys as $k ) { if ( ! is_array( $ref ) || ! array_key_exists( $k, $ref ) ) { unset( $ref ); continue 2; } $ref = &$ref[ $k ]; } $ref = $val; unset( $ref ); } } /* ------------------------------------------------------------------ */ /* Layer 2/3: core upload paths */ /* ------------------------------------------------------------------ */ /** * @param array $file * @return array */ public static function prefilter( $file ) { $name = isset( $file['name'] ) ? (string) $file['name'] : ''; $why = isset( self::$blocked[ strtolower( $name ) ] ) ? self::$blocked[ strtolower( $name ) ] : self::verdict( $name, isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '' ); if ( '' !== $why && ! apply_filters( 'gemsecurity/upload_shield_allow', false, sanitize_text_field( wp_unslash( $name ) ), $why ) ) { if ( ! isset( self::$blocked[ strtolower( $name ) ] ) ) { $ip = \GemSecurity\Helper::get_ip_address(); PluginGuard::queue_event( 'upload_blocked', 'upload:' . $ip, sprintf( '%s — %s', sanitize_file_name( $name ), $why ) ); PluginGuard::queue_ban( $ip, 'Upload Shield: ' . $why ); } /* translators: %s: reason. */ $file['error'] = sprintf( __( 'GemSecurity Upload Shield refused this file (%s).', 'gemsecurity' ), $why ); } return $file; } /** * @param array $mimes * @return array */ public static function mimes( $mimes ) { $deny = self::exec_extensions(); if ( self::$markup ) { $deny = array_merge( $deny, self::markup_extensions() ); } foreach ( array_keys( (array) $mimes ) as $exts ) { foreach ( explode( '|', $exts ) as $e ) { if ( in_array( $e, $deny, true ) ) { unset( $mimes[ $exts ] ); break; } } } return $mimes; } /** Nobody may bypass the MIME allowlist, including the core bypass capability. */ public static function deny_unfiltered( $caps, $cap ) { return 'unfiltered_upload' === $cap ? [ 'do_not_allow' ] : $caps; } /* ------------------------------------------------------------------ */ /* Layer 4: no execution inside uploads */ /* ------------------------------------------------------------------ */ const MARKER = 'GemSecurity Upload Shield'; /** uploads/.htaccess (kept for callers that only care about uploads). */ public static function htaccess_path() { $up = wp_get_upload_dir(); return trailingslashit( $up['basedir'] ) . '.htaccess'; } /** * Directories an older release also wrote to (WordPress core and * wp-content folders). Nothing is written there any more; the list only * lets "turn off" clean up what an earlier version left behind. * * @return string[] */ private static function legacy_dirs() { return [ \GemSecurity\Paths::includes(), \GemSecurity\Paths::content() . '/languages', \GemSecurity\Paths::content() . '/upgrade', \GemSecurity\Paths::content() . '/upgrade-temp-backup', ]; } /** * Directories that must never run code. Only the uploads folder: it is the * one place a plugin may write to, and the place an attacker's upload ends * up. Core and wp-content folders are left alone. * * @return array dir => allowed file names */ public static function htaccess_targets() { $up = wp_get_upload_dir(); $targets = [ untrailingslashit( $up['basedir'] ) => [], ]; /** * Filter the directories Upload Shield locks against script execution. * * @param array $targets dir => allowed file names. */ $targets = (array) apply_filters( 'gemsecurity/no_exec_dirs', $targets ); return array_filter( $targets, static function ( $dir ) { return is_dir( $dir ); }, ARRAY_FILTER_USE_KEY ); } /** Write/refresh the deny block in every target's .htaccess (Apache, LiteSpeed). */ public static function install_htaccess() { if ( ! function_exists( 'insert_with_markers' ) ) { require_once ABSPATH . 'wp-admin/includes/misc.php'; } $exts = implode( '|', array_diff( self::exec_extensions(), [ 'htaccess', 'exe', 'msi', 'dll', 'so', 'scr', 'com', 'bat', 'cmd', 'vbs', 'vbe', 'wsf', 'ps1', 'jar' ] ) ); $ok = true; foreach ( self::htaccess_targets() as $dir => $allow ) { $lines = [ '', ' ', ' Require all denied', ' ', ' ', ' Order allow,deny', ' Deny from all', ' ', '', ]; foreach ( $allow as $file ) { $lines = array_merge( $lines, [ '', ' ', ' Require all granted', ' ', ' ', ' Order allow,deny', ' Allow from all', ' ', '', ] ); } $path = $dir . '/.htaccess'; if ( ! file_exists( $path ) && ! is_writable( $dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable $ok = false; continue; } $ok = insert_with_markers( $path, self::MARKER, $lines ) && $ok; } return $ok; } public static function remove_htaccess() { if ( ! function_exists( 'insert_with_markers' ) ) { require_once ABSPATH . 'wp-admin/includes/misc.php'; } $ok = true; $dirs = array_unique( array_merge( array_keys( self::htaccess_targets() ), self::legacy_dirs() ) ); foreach ( $dirs as $dir ) { $path = $dir . '/.htaccess'; if ( ! is_file( $path ) ) { continue; } // Cut our whole section (markers included) and keep everything else // byte-for-byte; delete the file only if nothing else was in it. $body = (string) @file_get_contents( $path ); // phpcs:ignore $rest = preg_replace( '/\n?# BEGIN ' . preg_quote( self::MARKER, '/' ) . '.*?# END ' . preg_quote( self::MARKER, '/' ) . '\n?/s', '', $body ); if ( '' === trim( (string) $rest ) ) { wp_delete_file( $path ); } elseif ( $rest !== $body ) { $ok = false !== @file_put_contents( $path, $rest, LOCK_EX ) && $ok; // phpcs:ignore } } return $ok; } /** True when every target directory carries the block. */ public static function htaccess_installed() { foreach ( array_keys( self::htaccess_targets() ) as $dir ) { $path = $dir . '/.htaccess'; if ( ! is_file( $path ) || false === strpos( (string) @file_get_contents( $path ), 'BEGIN ' . self::MARKER ) ) { // phpcs:ignore return false; } } return true; } /** Relative paths of the protected directories (for the UI). */ public static function htaccess_dirs() { return array_map( static function ( $dir ) { return \GemSecurity\Paths::relative( wp_normalize_path( $dir ) ); }, array_keys( self::htaccess_targets() ) ); } /** * Check whether PHP can run inside uploads, without writing any code there. * * A live test would mean dropping an executable file into uploads, so this * reads the setup instead: on Apache/LiteSpeed it confirms our deny rules are * installed; on any other server it cannot know, and says so. * * @return array { result: executes|blocked|unknown, detail, at } */ public static function probe() { $out = [ 'result' => 'unknown', 'detail' => '', 'at' => current_time( 'mysql' ) ]; $software = isset( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) ) : ''; $uses_htaccess = false !== strpos( $software, 'apache' ) || false !== strpos( $software, 'litespeed' ); if ( $uses_htaccess && self::htaccess_installed() ) { $out['result'] = 'blocked'; $out['detail'] = __( 'Deny rules are installed in .htaccess and this server reads .htaccess, so PHP files in uploads will not run.', 'gemsecurity' ); } elseif ( $uses_htaccess ) { $out['detail'] = __( 'This server reads .htaccess but the deny rules are not installed yet. Turn on Upload Shield to install them.', 'gemsecurity' ); } else { $out['detail'] = __( 'This server does not read .htaccess, so it cannot be verified from here. Ask your host to add the rule shown below.', 'gemsecurity' ); } return $out; } /** Server config to paste where .htaccess has no effect. */ public static function nginx_snippet() { $paths = []; foreach ( self::htaccess_dirs() as $rel ) { $paths[] = preg_quote( '/' . ltrim( $rel, '/' ), '#' ); } return "location ~* ^(" . implode( '|', $paths ) . ")/.*\\.(php[0-9]?|phtml|phar|pht|phps|cgi|pl|py|sh)(\\.|$) {\n" . " deny all;\n}"; } }