I’ll build this as a drop-in inc/security/uploads.php module for your RX theme, focused on upload hardening, MIME validation, SVG control, filename cleanup, upload-directory protection, image limits, and admin notices.
Below is an advanced inc/security/uploads.php file for your RX Theme. It uses WordPress upload hooks such as wp_handle_upload_prefilter, upload_mimes, wp_check_filetype_and_ext, and upload directory helpers like wp_get_upload_dir() / wp_upload_dir() for safer upload handling. WordPress officially describes wp_handle_upload_prefilter as a hook that can examine or alter an uploaded file before it is moved, and wp_check_filetype_and_ext() as a function for checking the real file type and extension match.
Create this file:
<?php
/**
* RX Theme Upload Security
*
* File: inc/security/uploads.php
*
* Purpose:
* - Harden WordPress media uploads.
* - Restrict risky file types.
* - Validate extension + MIME type.
* - Block dangerous double extensions.
* - Clean upload filenames.
* - Add upload directory protection files.
* - Limit upload sizes and image dimensions.
* - Optional safe SVG handling for trusted admins only.
* - Keep a small upload security log.
*
* @package RX_Theme
*/
namespace RX_Theme\Security\Uploads;
defined( 'ABSPATH' ) || exit;
/**
* --------------------------------------------------------------------------
* Configuration Helpers
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_config' ) ) {
/**
* Main upload security config.
*
* You can override values from child theme or plugin using:
*
* add_filter( 'rx_theme_uploads_security_config', function( $config ) {
* $config['max_upload_size'] = 10 * MB_IN_BYTES;
* return $config;
* } );
*
* @return array<string,mixed>
*/
function rx_uploads_config(): array {
$default = array(
/**
* Maximum upload size.
* Default: 8 MB.
*/
'max_upload_size' => 8 * MB_IN_BYTES,
/**
* Image dimension limits.
*/
'max_image_width' => 5000,
'max_image_height' => 5000,
/**
* Allow SVG?
* Best security: false.
* If true, only trusted admins with unfiltered_html can upload SVG.
*/
'allow_svg' => false,
/**
* Allow WebP and AVIF image formats.
*/
'allow_webp' => true,
'allow_avif' => true,
/**
* Allow common office document uploads.
*/
'allow_documents' => true,
/**
* Allow compressed archive uploads.
* Default false, because zip/rar uploads are often abused.
*/
'allow_archives' => false,
/**
* Block uploads from non-authenticated users.
* This protects custom front-end upload forms.
*/
'block_guest_uploads' => true,
/**
* Require upload_files capability.
*/
'require_upload_capability' => true,
/**
* Sanitize filename strongly.
*/
'strong_filename_sanitize' => true,
/**
* Convert spaces in file names to hyphens.
*/
'filename_spaces_to_hyphens' => true,
/**
* Lowercase file extensions.
*/
'lowercase_extensions' => true,
/**
* Prevent suspicious double extensions:
* example: image.jpg.php, document.pdf.exe
*/
'block_double_extensions' => true,
/**
* Create upload directory protection files.
*/
'create_upload_protection_files' => true,
/**
* Keep security logs in wp_options.
*/
'enable_security_log' => true,
/**
* Maximum log rows.
*/
'security_log_limit' => 80,
/**
* Prevent direct PHP execution in uploads through .htaccess/web.config.
*/
'disable_php_execution_in_uploads' => true,
/**
* Block HTML-like uploads.
*/
'block_html_like_files' => true,
/**
* Block XML by default.
* SVG is handled separately.
*/
'block_xml_files' => true,
/**
* Add admin media upload hints.
*/
'admin_upload_notice' => true,
);
$config = apply_filters( 'rx_theme_uploads_security_config', $default );
return is_array( $config ) ? $config : $default;
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_allowed_mimes' ) ) {
/**
* Safe MIME allowlist.
*
* @return array<string,string>
*/
function rx_uploads_allowed_mimes(): array {
$config = rx_uploads_config();
$mimes = array(
// Images.
'jpg|jpeg|jpe' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'ico' => 'image/x-icon',
// Basic text.
'txt' => 'text/plain',
'csv' => 'text/csv',
);
if ( ! empty( $config['allow_webp'] ) ) {
$mimes['webp'] = 'image/webp';
}
if ( ! empty( $config['allow_avif'] ) ) {
$mimes['avif'] = 'image/avif';
}
if ( ! empty( $config['allow_documents'] ) ) {
$mimes = array_merge(
$mimes,
array(
'pdf' => 'application/pdf',
// Microsoft Office legacy.
'doc' => 'application/msword',
'xls' => 'application/vnd.ms-excel',
'ppt' => 'application/vnd.ms-powerpoint',
// Microsoft Office modern.
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// OpenDocument.
'odt' => 'application/vnd.oasis.opendocument.text',
'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
'odp' => 'application/vnd.oasis.opendocument.presentation',
)
);
}
if ( ! empty( $config['allow_archives'] ) ) {
$mimes = array_merge(
$mimes,
array(
'zip' => 'application/zip',
'gz' => 'application/gzip',
)
);
}
if ( ! empty( $config['allow_svg'] ) && rx_uploads_current_user_can_upload_svg() ) {
$mimes['svg'] = 'image/svg+xml';
}
/**
* Final MIME allowlist filter.
*/
return apply_filters( 'rx_theme_uploads_allowed_mimes', $mimes );
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_blocked_extensions' ) ) {
/**
* Dangerous extensions that should never be uploaded through Media Library.
*
* @return string[]
*/
function rx_uploads_blocked_extensions(): array {
$extensions = array(
// PHP / server executable.
'php',
'php3',
'php4',
'php5',
'php7',
'php8',
'phtml',
'phar',
'cgi',
'pl',
'py',
'rb',
'asp',
'aspx',
'jsp',
'shtml',
'fcgi',
// Shell / executable.
'exe',
'dll',
'bat',
'cmd',
'com',
'scr',
'msi',
'vbs',
'ps1',
'sh',
'bash',
'zsh',
'fish',
// Web active files.
'html',
'htm',
'xhtml',
'js',
'mjs',
'jsx',
'ts',
'tsx',
'css',
'less',
'sass',
'scss',
'json',
'map',
'wasm',
'xml',
'xsl',
// Config / sensitive.
'env',
'ini',
'conf',
'config',
'htaccess',
'htpasswd',
'log',
'sql',
'db',
'sqlite',
'lock',
'yml',
'yaml',
'toml',
// Risky archives by default.
'rar',
'7z',
'tar',
'tgz',
'bz2',
'iso',
);
$config = rx_uploads_config();
if ( ! empty( $config['allow_archives'] ) ) {
$extensions = array_diff( $extensions, array( 'tar', 'tgz', 'gz' ) );
}
if ( ! empty( $config['allow_svg'] ) && rx_uploads_current_user_can_upload_svg() ) {
$extensions = array_diff( $extensions, array( 'xml' ) );
}
return array_values(
array_unique(
apply_filters( 'rx_theme_uploads_blocked_extensions', $extensions )
)
);
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_blocked_mimes' ) ) {
/**
* MIME types that should be blocked.
*
* @return string[]
*/
function rx_uploads_blocked_mimes(): array {
$mimes = array(
'text/html',
'application/xhtml+xml',
'application/x-httpd-php',
'application/x-php',
'application/php',
'application/javascript',
'text/javascript',
'application/ecmascript',
'text/ecmascript',
'text/css',
'application/x-msdownload',
'application/x-msdos-program',
'application/x-sh',
'application/x-shellscript',
'application/octet-stream',
'application/json',
'text/xml',
'application/xml',
);
return array_values(
array_unique(
apply_filters( 'rx_theme_uploads_blocked_mimes', $mimes )
)
);
}
}
/**
* --------------------------------------------------------------------------
* Capability Helpers
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_current_user_can_upload' ) ) {
/**
* Check whether current user can upload files.
*
* @return bool
*/
function rx_uploads_current_user_can_upload(): bool {
$config = rx_uploads_config();
if ( ! empty( $config['block_guest_uploads'] ) && ! is_user_logged_in() ) {
return false;
}
if ( ! empty( $config['require_upload_capability'] ) && ! current_user_can( 'upload_files' ) ) {
return false;
}
return true;
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_current_user_can_upload_svg' ) ) {
/**
* Only highly trusted users should upload SVG.
*
* @return bool
*/
function rx_uploads_current_user_can_upload_svg(): bool {
$allowed = is_user_logged_in() && current_user_can( 'upload_files' ) && current_user_can( 'unfiltered_html' );
return (bool) apply_filters( 'rx_theme_uploads_user_can_upload_svg', $allowed );
}
}
/**
* --------------------------------------------------------------------------
* Logging
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_log' ) ) {
/**
* Store a small upload security log.
*
* @param string $event Event name.
* @param array<string,mixed> $data Event data.
* @return void
*/
function rx_uploads_log( string $event, array $data = array() ): void {
$config = rx_uploads_config();
if ( empty( $config['enable_security_log'] ) ) {
return;
}
$logs = get_option( 'rx_theme_upload_security_log', array() );
if ( ! is_array( $logs ) ) {
$logs = array();
}
$user_id = get_current_user_id();
$logs[] = array(
'time' => current_time( 'mysql' ),
'event' => sanitize_key( $event ),
'user_id' => absint( $user_id ),
'ip' => rx_uploads_get_ip_hash(),
'data' => rx_uploads_sanitize_log_data( $data ),
);
$limit = isset( $config['security_log_limit'] ) ? absint( $config['security_log_limit'] ) : 80;
if ( $limit < 10 ) {
$limit = 10;
}
if ( count( $logs ) > $limit ) {
$logs = array_slice( $logs, -$limit );
}
update_option( 'rx_theme_upload_security_log', $logs, false );
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_sanitize_log_data' ) ) {
/**
* Sanitize log data.
*
* @param array<string,mixed> $data Raw data.
* @return array<string,mixed>
*/
function rx_uploads_sanitize_log_data( array $data ): array {
$clean = array();
foreach ( $data as $key => $value ) {
$key = sanitize_key( (string) $key );
if ( is_scalar( $value ) || null === $value ) {
$clean[ $key ] = sanitize_text_field( (string) $value );
} elseif ( is_array( $value ) ) {
$clean[ $key ] = wp_json_encode( $value );
}
}
return $clean;
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_get_ip_hash' ) ) {
/**
* Return a privacy-safe IP hash, not plain IP.
*
* @return string
*/
function rx_uploads_get_ip_hash(): string {
$ip = '';
if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
}
if ( '' === $ip ) {
return '';
}
return wp_hash( $ip );
}
}
/**
* --------------------------------------------------------------------------
* MIME and Extension Control
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_filter_upload_mimes' ) ) {
/**
* Replace WordPress upload MIME list with a safer allowlist.
*
* @param array<string,string> $mimes Existing mimes.
* @return array<string,string>
*/
function rx_uploads_filter_upload_mimes( array $mimes ): array {
unset( $mimes );
return rx_uploads_allowed_mimes();
}
add_filter( 'upload_mimes', __NAMESPACE__ . '\rx_uploads_filter_upload_mimes', 99 );
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_check_filetype_and_ext' ) ) {
/**
* Final MIME and extension check.
*
* @param array<string,mixed> $checked Filetype check result.
* @param string $file Full path to uploaded file.
* @param string $filename File name.
* @param array<string,string> $mimes Allowed mimes.
* @return array<string,mixed>
*/
function rx_uploads_check_filetype_and_ext( array $checked, string $file, string $filename, array $mimes ): array {
$extension = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
if ( rx_uploads_is_blocked_extension( $extension ) ) {
rx_uploads_log(
'blocked_extension_final_check',
array(
'filename' => $filename,
'extension' => $extension,
)
);
return array(
'ext' => false,
'type' => false,
'proper_filename' => false,
);
}
if ( ! empty( $checked['type'] ) && rx_uploads_is_blocked_mime( (string) $checked['type'] ) ) {
rx_uploads_log(
'blocked_mime_final_check',
array(
'filename' => $filename,
'mime' => (string) $checked['type'],
)
);
return array(
'ext' => false,
'type' => false,
'proper_filename' => false,
);
}
return $checked;
}
add_filter( 'wp_check_filetype_and_ext', __NAMESPACE__ . '\rx_uploads_check_filetype_and_ext', 99, 4 );
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_is_blocked_extension' ) ) {
/**
* Check if file extension is blocked.
*
* @param string $extension Extension.
* @return bool
*/
function rx_uploads_is_blocked_extension( string $extension ): bool {
$extension = strtolower( trim( $extension, ". \t\n\r\0\x0B" ) );
if ( '' === $extension ) {
return true;
}
return in_array( $extension, rx_uploads_blocked_extensions(), true );
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_is_blocked_mime' ) ) {
/**
* Check if MIME is blocked.
*
* @param string $mime MIME type.
* @return bool
*/
function rx_uploads_is_blocked_mime( string $mime ): bool {
$mime = strtolower( trim( $mime ) );
if ( '' === $mime ) {
return true;
}
return in_array( $mime, rx_uploads_blocked_mimes(), true );
}
}
/**
* --------------------------------------------------------------------------
* Upload Prefilter
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_prefilter' ) ) {
/**
* Validate upload before WordPress moves it to uploads directory.
*
* @param array<string,mixed> $file One item from $_FILES.
* @return array<string,mixed>
*/
function rx_uploads_prefilter( array $file ): array {
if ( ! rx_uploads_current_user_can_upload() ) {
$file['error'] = __( 'RX Security: You are not allowed to upload files.', 'rx-theme' );
rx_uploads_log(
'blocked_upload_permission',
array(
'name' => isset( $file['name'] ) ? (string) $file['name'] : '',
)
);
return $file;
}
$config = rx_uploads_config();
$filename = isset( $file['name'] ) ? (string) $file['name'] : '';
$tmp_name = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
$size = isset( $file['size'] ) ? absint( $file['size'] ) : 0;
if ( '' === $filename ) {
$file['error'] = __( 'RX Security: Empty filename is not allowed.', 'rx-theme' );
rx_uploads_log( 'blocked_empty_filename' );
return $file;
}
$filename = rx_uploads_normalize_filename( $filename );
$file['name'] = $filename;
if ( ! empty( $config['max_upload_size'] ) && $size > absint( $config['max_upload_size'] ) ) {
$file['error'] = sprintf(
/* translators: %s: max upload size */
__( 'RX Security: File is too large. Maximum allowed size is %s.', 'rx-theme' ),
size_format( absint( $config['max_upload_size'] ) )
);
rx_uploads_log(
'blocked_file_size',
array(
'name' => $filename,
'size' => $size,
)
);
return $file;
}
if ( ! empty( $config['block_double_extensions'] ) && rx_uploads_has_dangerous_double_extension( $filename ) ) {
$file['error'] = __( 'RX Security: Suspicious double file extension blocked.', 'rx-theme' );
rx_uploads_log(
'blocked_double_extension',
array(
'name' => $filename,
)
);
return $file;
}
$extension = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
if ( rx_uploads_is_blocked_extension( $extension ) ) {
$file['error'] = __( 'RX Security: This file extension is not allowed.', 'rx-theme' );
rx_uploads_log(
'blocked_extension',
array(
'name' => $filename,
'extension' => $extension,
)
);
return $file;
}
if ( '' !== $tmp_name && is_readable( $tmp_name ) ) {
$type_check = wp_check_filetype_and_ext(
$tmp_name,
$filename,
rx_uploads_allowed_mimes()
);
if ( empty( $type_check['ext'] ) || empty( $type_check['type'] ) ) {
$file['error'] = __( 'RX Security: File type could not be verified.', 'rx-theme' );
rx_uploads_log(
'blocked_unverified_filetype',
array(
'name' => $filename,
)
);
return $file;
}
if ( rx_uploads_is_blocked_mime( (string) $type_check['type'] ) ) {
$file['error'] = __( 'RX Security: This MIME type is not allowed.', 'rx-theme' );
rx_uploads_log(
'blocked_mime',
array(
'name' => $filename,
'mime' => (string) $type_check['type'],
)
);
return $file;
}
if ( 'svg' === $extension ) {
$file = rx_uploads_validate_svg_file( $file );
if ( ! empty( $file['error'] ) ) {
return $file;
}
}
if ( rx_uploads_is_image_extension( $extension ) ) {
$file = rx_uploads_validate_image_dimensions( $file );
if ( ! empty( $file['error'] ) ) {
return $file;
}
}
if ( rx_uploads_file_contains_php_signature( $tmp_name ) ) {
$file['error'] = __( 'RX Security: File contains blocked server-side code signature.', 'rx-theme' );
rx_uploads_log(
'blocked_php_signature',
array(
'name' => $filename,
)
);
return $file;
}
}
rx_uploads_log(
'upload_prefilter_passed',
array(
'name' => $filename,
'size' => $size,
)
);
return $file;
}
add_filter( 'wp_handle_upload_prefilter', __NAMESPACE__ . '\rx_uploads_prefilter', 99 );
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_normalize_filename' ) ) {
/**
* Normalize uploaded file name.
*
* @param string $filename Original filename.
* @return string
*/
function rx_uploads_normalize_filename( string $filename ): string {
$config = rx_uploads_config();
$filename = wp_basename( $filename );
$filename = remove_accents( $filename );
$filename = sanitize_file_name( $filename );
if ( ! empty( $config['filename_spaces_to_hyphens'] ) ) {
$filename = preg_replace( '/\s+/', '-', $filename );
}
if ( ! empty( $config['strong_filename_sanitize'] ) ) {
$filename = preg_replace( '/[^A-Za-z0-9._-]/', '-', $filename );
$filename = preg_replace( '/-+/', '-', $filename );
$filename = preg_replace( '/_+/', '_', $filename );
$filename = trim( $filename, '.-_' );
}
if ( ! empty( $config['lowercase_extensions'] ) && false !== strpos( $filename, '.' ) ) {
$parts = explode( '.', $filename );
$ext = strtolower( (string) array_pop( $parts ) );
$name = implode( '.', $parts );
$filename = $name . '.' . $ext;
}
if ( '' === $filename || '.' === $filename || '..' === $filename ) {
$filename = 'rx-upload-' . time() . '.dat';
}
return apply_filters( 'rx_theme_uploads_normalized_filename', $filename );
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_has_dangerous_double_extension' ) ) {
/**
* Detect dangerous double extensions.
*
* Examples:
* - image.jpg.php
* - document.pdf.exe
* - photo.png.phtml
*
* @param string $filename Filename.
* @return bool
*/
function rx_uploads_has_dangerous_double_extension( string $filename ): bool {
$parts = explode( '.', strtolower( $filename ) );
if ( count( $parts ) < 3 ) {
return false;
}
array_shift( $parts );
$blocked = rx_uploads_blocked_extensions();
foreach ( $parts as $part ) {
$part = trim( $part );
if ( in_array( $part, $blocked, true ) ) {
return true;
}
}
return false;
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_file_contains_php_signature' ) ) {
/**
* Detect obvious server-side code signatures inside uploaded files.
*
* This is intentionally small and fast. It does not scan the whole file.
*
* @param string $path Temporary uploaded file path.
* @return bool
*/
function rx_uploads_file_contains_php_signature( string $path ): bool {
if ( ! is_readable( $path ) || ! is_file( $path ) ) {
return false;
}
$handle = fopen( $path, 'rb' );
if ( false === $handle ) {
return false;
}
$chunk = fread( $handle, 8192 );
fclose( $handle );
if ( false === $chunk ) {
return false;
}
$signatures = array(
'<?php',
'<?=',
'<script language="php"',
'eval(',
'base64_decode(',
'shell_exec(',
'passthru(',
'proc_open(',
'popen(',
);
$lower = strtolower( $chunk );
foreach ( $signatures as $signature ) {
if ( false !== strpos( $lower, strtolower( $signature ) ) ) {
return true;
}
}
return false;
}
}
/**
* --------------------------------------------------------------------------
* Image Validation
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_is_image_extension' ) ) {
/**
* Check image extension.
*
* @param string $extension Extension.
* @return bool
*/
function rx_uploads_is_image_extension( string $extension ): bool {
return in_array(
strtolower( $extension ),
array( 'jpg', 'jpeg', 'jpe', 'png', 'gif', 'bmp', 'ico', 'webp', 'avif' ),
true
);
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_validate_image_dimensions' ) ) {
/**
* Validate image dimensions.
*
* @param array<string,mixed> $file Upload file.
* @return array<string,mixed>
*/
function rx_uploads_validate_image_dimensions( array $file ): array {
$config = rx_uploads_config();
$tmp_name = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
$filename = isset( $file['name'] ) ? (string) $file['name'] : '';
if ( '' === $tmp_name || ! is_readable( $tmp_name ) ) {
return $file;
}
$image_size = @getimagesize( $tmp_name );
if ( false === $image_size || empty( $image_size[0] ) || empty( $image_size[1] ) ) {
$file['error'] = __( 'RX Security: Image dimensions could not be verified.', 'rx-theme' );
rx_uploads_log(
'blocked_invalid_image',
array(
'name' => $filename,
)
);
return $file;
}
$width = absint( $image_size[0] );
$height = absint( $image_size[1] );
$max_width = isset( $config['max_image_width'] ) ? absint( $config['max_image_width'] ) : 5000;
$max_height = isset( $config['max_image_height'] ) ? absint( $config['max_image_height'] ) : 5000;
if ( $width > $max_width || $height > $max_height ) {
$file['error'] = sprintf(
/* translators: 1: max width, 2: max height */
__( 'RX Security: Image is too large. Maximum allowed dimension is %1$d x %2$d pixels.', 'rx-theme' ),
$max_width,
$max_height
);
rx_uploads_log(
'blocked_image_dimensions',
array(
'name' => $filename,
'width' => $width,
'height' => $height,
)
);
}
return $file;
}
}
/**
* --------------------------------------------------------------------------
* SVG Security
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_validate_svg_file' ) ) {
/**
* Validate SVG upload.
*
* Strong recommendation:
* Keep SVG disabled unless you fully trust the uploader.
*
* @param array<string,mixed> $file Upload file.
* @return array<string,mixed>
*/
function rx_uploads_validate_svg_file( array $file ): array {
$config = rx_uploads_config();
$tmp_name = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
$filename = isset( $file['name'] ) ? (string) $file['name'] : '';
if ( empty( $config['allow_svg'] ) ) {
$file['error'] = __( 'RX Security: SVG uploads are disabled.', 'rx-theme' );
rx_uploads_log(
'blocked_svg_disabled',
array(
'name' => $filename,
)
);
return $file;
}
if ( ! rx_uploads_current_user_can_upload_svg() ) {
$file['error'] = __( 'RX Security: SVG upload requires trusted administrator permission.', 'rx-theme' );
rx_uploads_log(
'blocked_svg_permission',
array(
'name' => $filename,
)
);
return $file;
}
if ( '' === $tmp_name || ! is_readable( $tmp_name ) ) {
$file['error'] = __( 'RX Security: SVG file could not be read.', 'rx-theme' );
return $file;
}
$contents = file_get_contents( $tmp_name );
if ( false === $contents ) {
$file['error'] = __( 'RX Security: SVG file could not be opened.', 'rx-theme' );
return $file;
}
if ( rx_uploads_svg_has_dangerous_content( $contents ) ) {
$file['error'] = __( 'RX Security: SVG contains unsafe content.', 'rx-theme' );
rx_uploads_log(
'blocked_svg_unsafe_content',
array(
'name' => $filename,
)
);
return $file;
}
return $file;
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_svg_has_dangerous_content' ) ) {
/**
* Basic SVG dangerous content detection.
*
* @param string $svg SVG string.
* @return bool
*/
function rx_uploads_svg_has_dangerous_content( string $svg ): bool {
$lower = strtolower( $svg );
$blocked_patterns = array(
'<script',
'</script',
'javascript:',
'vbscript:',
'data:text/html',
'data:application',
'<foreignobject',
'<iframe',
'<object',
'<embed',
'<link',
'<meta',
'onload=',
'onerror=',
'onclick=',
'onmouseover=',
'onfocus=',
'onbegin=',
'onend=',
'<?php',
'<!entity',
'<!doctype',
);
foreach ( $blocked_patterns as $pattern ) {
if ( false !== strpos( $lower, $pattern ) ) {
return true;
}
}
return false;
}
}
/**
* --------------------------------------------------------------------------
* Upload Result Hook
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_after_upload' ) ) {
/**
* Log successful uploads.
*
* @param array<string,mixed> $upload Upload result.
* @param string $context Upload context.
* @return array<string,mixed>
*/
function rx_uploads_after_upload( array $upload, string $context ): array {
rx_uploads_log(
'upload_completed',
array(
'file' => isset( $upload['file'] ) ? wp_basename( (string) $upload['file'] ) : '',
'type' => isset( $upload['type'] ) ? (string) $upload['type'] : '',
'context' => $context,
)
);
return $upload;
}
add_filter( 'wp_handle_upload', __NAMESPACE__ . '\rx_uploads_after_upload', 99, 2 );
}
/**
* --------------------------------------------------------------------------
* Upload Directory Protection
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_protect_upload_directory' ) ) {
/**
* Create protective files inside uploads directory.
*
* @return void
*/
function rx_uploads_protect_upload_directory(): void {
$config = rx_uploads_config();
if ( empty( $config['create_upload_protection_files'] ) ) {
return;
}
$upload = function_exists( 'wp_get_upload_dir' ) ? wp_get_upload_dir() : wp_upload_dir();
if ( empty( $upload['basedir'] ) || ! is_dir( $upload['basedir'] ) || ! is_writable( $upload['basedir'] ) ) {
return;
}
$base_dir = trailingslashit( $upload['basedir'] );
rx_uploads_write_index_file( $base_dir );
if ( ! empty( $config['disable_php_execution_in_uploads'] ) ) {
rx_uploads_write_htaccess_file( $base_dir );
rx_uploads_write_webconfig_file( $base_dir );
}
}
add_action( 'admin_init', __NAMESPACE__ . '\rx_uploads_protect_upload_directory' );
add_action( 'after_switch_theme', __NAMESPACE__ . '\rx_uploads_protect_upload_directory' );
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_write_index_file' ) ) {
/**
* Write index.php to block directory listing.
*
* @param string $base_dir Upload base directory.
* @return void
*/
function rx_uploads_write_index_file( string $base_dir ): void {
$index_file = $base_dir . 'index.php';
if ( file_exists( $index_file ) ) {
return;
}
$content = "<?php\n// Silence is golden.\n";
@file_put_contents( $index_file, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_write_htaccess_file' ) ) {
/**
* Write Apache .htaccess protection.
*
* @param string $base_dir Upload base directory.
* @return void
*/
function rx_uploads_write_htaccess_file( string $base_dir ): void {
$file = $base_dir . '.htaccess';
if ( file_exists( $file ) ) {
$existing = (string) @file_get_contents( $file );
if ( false !== strpos( $existing, '# BEGIN RX Upload Security' ) ) {
return;
}
}
$rules = <<<HTACCESS
# BEGIN RX Upload Security
Options -Indexes
<FilesMatch "\.(php|php3|php4|php5|php7|php8|phtml|phar|cgi|pl|py|rb|asp|aspx|jsp|shtml|sh|bash|cmd|bat|exe|dll)$">
Require all denied
</FilesMatch>
<IfModule mod_php.c>
php_flag engine off
</IfModule>
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
</IfModule>
# END RX Upload Security
HTACCESS;
@file_put_contents( $file, $rules, FILE_APPEND ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
}
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_write_webconfig_file' ) ) {
/**
* Write IIS web.config protection.
*
* @param string $base_dir Upload base directory.
* @return void
*/
function rx_uploads_write_webconfig_file( string $base_dir ): void {
$file = $base_dir . 'web.config';
if ( file_exists( $file ) ) {
return;
}
$content = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<directoryBrowse enabled="false" />
<handlers>
<remove name="PHP_via_FastCGI" />
</handlers>
<security>
<requestFiltering>
<fileExtensions>
<add fileExtension=".php" allowed="false" />
<add fileExtension=".phtml" allowed="false" />
<add fileExtension=".phar" allowed="false" />
<add fileExtension=".cgi" allowed="false" />
<add fileExtension=".pl" allowed="false" />
<add fileExtension=".py" allowed="false" />
<add fileExtension=".rb" allowed="false" />
<add fileExtension=".exe" allowed="false" />
<add fileExtension=".dll" allowed="false" />
<add fileExtension=".bat" allowed="false" />
<add fileExtension=".cmd" allowed="false" />
</fileExtensions>
</requestFiltering>
</security>
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
</customHeaders>
</httpProtocol>
</system.webServer>
</configuration>
XML;
@file_put_contents( $file, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
}
}
/**
* --------------------------------------------------------------------------
* Media Library Display Support
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_fix_svg_admin_preview' ) ) {
/**
* Minimal SVG media library preview support.
*
* @param array<string,mixed> $response Attachment response.
* @param \WP_Post $attachment Attachment post.
* @param array<string,mixed> $meta Meta.
* @return array<string,mixed>
*/
function rx_uploads_fix_svg_admin_preview( array $response, \WP_Post $attachment, array $meta ): array {
if ( 'image/svg+xml' !== get_post_mime_type( $attachment ) ) {
return $response;
}
$response['sizes'] = array(
'full' => array(
'url' => wp_get_attachment_url( $attachment->ID ),
'width' => 512,
'height' => 512,
'orientation' => 'landscape',
),
);
$response['icon'] = wp_get_attachment_url( $attachment->ID );
return $response;
}
add_filter( 'wp_prepare_attachment_for_js', __NAMESPACE__ . '\rx_uploads_fix_svg_admin_preview', 10, 3 );
}
/**
* --------------------------------------------------------------------------
* Image Quality and Big Image Threshold
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_jpeg_quality' ) ) {
/**
* Set JPEG quality.
*
* @param int $quality Current quality.
* @return int
*/
function rx_uploads_jpeg_quality( int $quality ): int {
return absint(
apply_filters( 'rx_theme_uploads_jpeg_quality', 86, $quality )
);
}
add_filter( 'jpeg_quality', __NAMESPACE__ . '\rx_uploads_jpeg_quality', 20 );
add_filter( 'wp_editor_set_quality', __NAMESPACE__ . '\rx_uploads_jpeg_quality', 20 );
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_big_image_size_threshold' ) ) {
/**
* Set big image threshold.
*
* @param int|false $threshold Threshold.
* @param array $imagesize Image size.
* @param string $file File path.
* @param int $attachment_id Attachment ID.
* @return int|false
*/
function rx_uploads_big_image_size_threshold( $threshold, array $imagesize, string $file, int $attachment_id ) {
unset( $imagesize, $file, $attachment_id );
return apply_filters( 'rx_theme_uploads_big_image_threshold', 2560, $threshold );
}
add_filter( 'big_image_size_threshold', __NAMESPACE__ . '\rx_uploads_big_image_size_threshold', 20, 4 );
}
/**
* --------------------------------------------------------------------------
* Attachment Page Hardening
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_redirect_attachment_pages' ) ) {
/**
* Redirect attachment pages to parent post or file URL.
*
* This prevents thin SEO attachment pages.
*
* @return void
*/
function rx_uploads_redirect_attachment_pages(): void {
if ( ! is_attachment() || is_admin() ) {
return;
}
$attachment_id = get_queried_object_id();
if ( ! $attachment_id ) {
return;
}
$parent_id = wp_get_post_parent_id( $attachment_id );
if ( $parent_id ) {
wp_safe_redirect( get_permalink( $parent_id ), 301 );
exit;
}
$file_url = wp_get_attachment_url( $attachment_id );
if ( $file_url ) {
wp_safe_redirect( $file_url, 301 );
exit;
}
}
add_action( 'template_redirect', __NAMESPACE__ . '\rx_uploads_redirect_attachment_pages', 1 );
}
/**
* --------------------------------------------------------------------------
* Admin Notices and Tools
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_admin_notice' ) ) {
/**
* Admin upload security notice.
*
* @return void
*/
function rx_uploads_admin_notice(): void {
$config = rx_uploads_config();
if ( empty( $config['admin_upload_notice'] ) ) {
return;
}
if ( ! current_user_can( 'upload_files' ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || ! in_array( $screen->id, array( 'upload', 'media', 'post', 'page' ), true ) ) {
return;
}
$max_size = ! empty( $config['max_upload_size'] ) ? size_format( absint( $config['max_upload_size'] ) ) : __( 'site default', 'rx-theme' );
echo '<div class="notice notice-info is-dismissible">';
echo '<p><strong>' . esc_html__( 'RX Upload Security:', 'rx-theme' ) . '</strong> ';
echo esc_html(
sprintf(
/* translators: %s: max upload size */
__( 'Uploads are protected. Maximum upload size: %s. Risky executable, script, HTML, and double-extension files are blocked.', 'rx-theme' ),
$max_size
)
);
echo '</p>';
echo '</div>';
}
add_action( 'admin_notices', __NAMESPACE__ . '\rx_uploads_admin_notice' );
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_dashboard_widget' ) ) {
/**
* Add small upload security dashboard widget.
*
* @return void
*/
function rx_uploads_dashboard_widget(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
wp_add_dashboard_widget(
'rx_theme_upload_security_widget',
__( 'RX Upload Security', 'rx-theme' ),
__NAMESPACE__ . '\rx_uploads_dashboard_widget_render'
);
}
add_action( 'wp_dashboard_setup', __NAMESPACE__ . '\rx_uploads_dashboard_widget' );
}
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_dashboard_widget_render' ) ) {
/**
* Render dashboard widget.
*
* @return void
*/
function rx_uploads_dashboard_widget_render(): void {
$config = rx_uploads_config();
$logs = get_option( 'rx_theme_upload_security_log', array() );
if ( ! is_array( $logs ) ) {
$logs = array();
}
$logs = array_reverse( array_slice( $logs, -8 ) );
echo '<p>';
echo esc_html__( 'RX upload protection is active.', 'rx-theme' );
echo '</p>';
echo '<ul>';
echo '<li>' . esc_html__( 'Allowed MIME types are restricted.', 'rx-theme' ) . '</li>';
echo '<li>' . esc_html__( 'Suspicious double extensions are blocked.', 'rx-theme' ) . '</li>';
echo '<li>' . esc_html__( 'Upload directory protection files are created when possible.', 'rx-theme' ) . '</li>';
echo '<li>' . esc_html__( 'Maximum upload size:', 'rx-theme' ) . ' ' . esc_html( size_format( absint( $config['max_upload_size'] ) ) ) . '</li>';
echo '</ul>';
if ( empty( $logs ) ) {
echo '<p>' . esc_html__( 'No upload security log yet.', 'rx-theme' ) . '</p>';
return;
}
echo '<h4>' . esc_html__( 'Recent upload security log', 'rx-theme' ) . '</h4>';
echo '<ol>';
foreach ( $logs as $log ) {
if ( ! is_array( $log ) ) {
continue;
}
$time = isset( $log['time'] ) ? (string) $log['time'] : '';
$event = isset( $log['event'] ) ? (string) $log['event'] : '';
echo '<li>';
echo esc_html( $time . ' — ' . $event );
echo '</li>';
}
echo '</ol>';
}
}
/**
* --------------------------------------------------------------------------
* REST / Front-End Upload Extra Guard
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_rest_pre_dispatch_guard' ) ) {
/**
* Guard REST media uploads.
*
* @param mixed $result Response result.
* @param \WP_REST_Server $server REST server.
* @param \WP_REST_Request $request Request.
* @return mixed
*/
function rx_uploads_rest_pre_dispatch_guard( $result, \WP_REST_Server $server, \WP_REST_Request $request ) {
unset( $server );
$route = $request->get_route();
if ( false === strpos( $route, '/wp/v2/media' ) ) {
return $result;
}
if ( ! rx_uploads_current_user_can_upload() ) {
rx_uploads_log(
'blocked_rest_media_upload_permission',
array(
'route' => $route,
)
);
return new \WP_Error(
'rx_rest_upload_blocked',
__( 'RX Security: REST media upload is not allowed for this user.', 'rx-theme' ),
array( 'status' => 403 )
);
}
return $result;
}
add_filter( 'rest_pre_dispatch', __NAMESPACE__ . '\rx_uploads_rest_pre_dispatch_guard', 10, 3 );
}
/**
* --------------------------------------------------------------------------
* Optional Developer Helpers
* --------------------------------------------------------------------------
*/
if ( ! function_exists( __NAMESPACE__ . '\rx_uploads_get_security_status' ) ) {
/**
* Return upload security status.
*
* Useful for debugging:
* RX_Theme\Security\Uploads\rx_uploads_get_security_status();
*
* @return array<string,mixed>
*/
function rx_uploads_get_security_status(): array {
$config = rx_uploads_config();
$upload = function_exists( 'wp_get_upload_dir' ) ? wp_get_upload_dir() : wp_upload_dir();
return array(
'config' => $config,
'allowed_mimes' => rx_uploads_allowed_mimes(),
'blocked_extensions' => rx_uploads_blocked_extensions(),
'blocked_mimes' => rx_uploads_blocked_mimes(),
'uploads_basedir' => isset( $upload['basedir'] ) ? $upload['basedir'] : '',
'uploads_baseurl' => isset( $upload['baseurl'] ) ? $upload['baseurl'] : '',
'can_upload' => rx_uploads_current_user_can_upload(),
'can_upload_svg' => rx_uploads_current_user_can_upload_svg(),
);
}
}
Add this line in your theme functions.php:
require_once get_template_directory() . '/inc/security/uploads.php';
Recommended folder position:
rx-theme/
├── functions.php
└── inc/
└── security/
└── uploads.php
To enable SVG only for trusted admins, add this in functions.php or a child-theme file:
add_filter( 'rx_theme_uploads_security_config', function ( $config ) {
$config['allow_svg'] = true;
return $config;
} );
For maximum security, keep SVG disabled unless you really need it. SVG can contain active content, so this file blocks SVG by default and only allows it for trusted admins when you enable it.