Secure File Uploads in WordPress: Type Validation, Size Limits, and Storage Hardening

File upload vulnerabilities are among the most critical in web applications — an attacker who can upload a PHP file to a web-accessible directory achieves Remote Code Execution (RCE), which is a complete server compromise. WordPress’s media uploader has several built-in protections (MIME type checking, extension validation, nonce verification) but plugins that implement custom upload handlers often bypass these protections, creating vulnerabilities. The correct security model for file uploads has four layers: (1) client-side validation for UX only (never trust this layer), (2) server-side extension allowlisting (reject files with PHP, PHP3, PHTML, PHAR, or other executable extensions), (3) MIME type verification using wp_check_filetype_and_ext() which reads the actual file bytes rather than trusting the Content-Type header sent by the client, and (4) storing uploaded files outside the web root or with an .htaccess rule that denies direct execution. WordPress core uses wp_handle_upload() and wp_handle_sideload() as the standard upload handlers — they validate MIME types against the allowed list, move the file to /wp-content/uploads/, and return the new URL and path. The allowed MIME types are controlled by the upload_mimes filter. SVG files are not allowed by default because they can contain embedded JavaScript — enabling SVG uploads requires sanitizing the SVG XML before storage using a library like svg-sanitizer. File size limits are enforced by PHP’s upload_max_filesize and post_max_size directives in conjunction with WordPress’s wp_max_upload_size(). Image dimensions should be validated with getimagesize() (which verifies the file is a real image by reading its binary header) after upload to prevent oversized image attacks that exhaust server memory during thumbnail generation. The nonces post protects upload forms from CSRF; this post covers the uploaded file content validation and storage hardening layer.

Problem: A WordPress plugin allows users to upload a “profile document” (PDF or image) — the upload handler trusts the browser’s Content-Type header and only checks the file extension. An attacker renames shell.php to shell.jpg, uploads it, and the file is stored in a web-accessible directory where requesting its URL causes the server to execute it as PHP.

Solution: Use wp_check_filetype_and_ext() to verify the real MIME type from the file bytes (not the extension), restrict to a specific allowlist of safe types, validate image dimensions, and add a deny-execution .htaccess to the uploads folder.

add_action( 'wp_ajax_myplugin_upload_document', 'myplugin_handle_document_upload' );

function myplugin_handle_document_upload(): void {
    // 1. Verify nonce (CSRF protection)
    check_ajax_referer( 'myplugin_upload_document', '_nonce' );

    // 2. Verify capability
    if ( ! current_user_can( 'upload_files' ) ) {
        wp_send_json_error( __( 'Permission denied.', 'myplugin' ), 403 );
    }

    if ( empty( $_FILES['document'] ) || $_FILES['document']['error'] !== UPLOAD_ERR_OK ) {
        wp_send_json_error( __( 'Upload error.', 'myplugin' ), 400 );
    }

    $file = $_FILES['document'];

    // 3. File size check (max 5MB)
    if ( $file['size'] > 5 * MB_IN_BYTES ) {
        wp_send_json_error( __( 'File too large. Maximum 5MB.', 'myplugin' ), 413 );
    }

    // 4. Real MIME type validation — reads file bytes, ignores extension & Content-Type
    $allowed_types = [ 'application/pdf', 'image/jpeg', 'image/png', 'image/webp' ];

    $type_check = wp_check_filetype_and_ext(
        $file['tmp_name'],
        sanitize_file_name( $file['name'] )
    );

    if ( empty( $type_check['type'] ) || ! in_array( $type_check['type'], $allowed_types, true ) ) {
        wp_send_json_error(
            __( 'Invalid file type. Only PDF, JPEG, PNG, and WebP are allowed.', 'myplugin' ),
            415
        );
    }

    // 5. For images: verify it's a real image and enforce dimension limits
    if ( str_starts_with( $type_check['type'], 'image/' ) ) {
        $image_info = @getimagesize( $file['tmp_name'] );
        if ( ! $image_info ) {
            wp_send_json_error( __( 'Invalid image file.', 'myplugin' ), 415 );
        }
        if ( $image_info[0] > 4096 || $image_info[1] > 4096 ) {
            wp_send_json_error( __( 'Image dimensions exceed 4096×4096 pixels.', 'myplugin' ), 413 );
        }
    }

    // 6. Move file through WordPress's secure upload pipeline
    $overrides = [ 'test_form' => false, 'test_type' => false ];
    $moved     = wp_handle_sideload( $file, $overrides );

    if ( isset( $moved['error'] ) ) {
        wp_send_json_error( $moved['error'], 500 );
    }

    // 7. Store server file path in user meta (never expose the full path in responses)
    $user_id = get_current_user_id();
    update_user_meta( $user_id, 'myplugin_document_path', $moved['file'] );
    update_user_meta( $user_id, 'myplugin_document_type', $moved['type'] );

    wp_send_json_success( [ 'message' => __( 'Document uploaded successfully.', 'myplugin' ) ] );
}

// Write deny-execution .htaccess to uploads directory on activation
register_activation_hook( __FILE__, function(): void {
    $uploads_dir = wp_upload_dir()['basedir'];
    $htaccess    = $uploads_dir . '/.htaccess';
    if ( ! file_exists( $htaccess ) ) {
        file_put_contents(
            $htaccess,
            "# Deny direct execution of server-side scripts in uploads
" .
            "
" .
            "    Require all denied
" .
            "
"
        );
    }
} );

NOTE: Storing the user-facing download URL in the database instead of the server file path creates an IDOR (Insecure Direct Object Reference) vulnerability — any user who knows or guesses another user’s document URL can download their file without authentication. Always store the server-side file path, serve documents through a PHP endpoint that calls current_user_can() and verifies ownership before calling readfile(), and send a Content-Disposition: attachment; filename="document.pdf" header to force a download dialog rather than browser rendering — which prevents XSS via rendered SVG or HTML disguised as a safe file type.