Skip to content
System nominalUTC --:--:--Articles 041

Handling File Uploads in a Plugin Without Creating an Incident

The type field in $_FILES is whatever the browser sent. Upload handling starts with an allowlist, a content check and a filename the client never chooses.

Tomas Lindqvist · @tlindqvist

Hardening and operations

Topic
SEC
Section
Security & Hardening
Published
05 FEB 2026
Reading time
8 MIN
Words
2325W
Technical cover graphic for Handling File Uploads in a Plugin Without Creating an Incident
Generated schematic — SEC series, REV 2.6.

An upload endpoint passed review because it rejected anything that was not an image. It compared $_FILES['avatar']['type'] against two mime strings, and in testing the browser always sent one of them. Weeks later the same handler stored a file named avatar.jpg, and the first thing to notice was the host's file scanner.

That value is a string the client writes into a multipart part header. A browser fills it in from the extension of the local file, and nothing else is obliged to. PHP copies the header into the array without opening the temporary file.

in_array( $file['type'], array( 'image/jpeg', 'image/png' ) ) looks like a mime test. It compares two client-supplied strings. On a single-site install I went through (WordPress 6.7.1, PHP 8.3, nginx), three of four upload handlers trusted it, and none chose the filename.

The type value in $_FILES is a claim, not a fact

A multipart body is a sequence of parts, each with its own headers. The client writes a Content-Type header for that part and a filename parameter on the same line, and both are request input with the same standing as a query string parameter. The array looks convincing only because it is internally consistent: send a part whose header claims image/jpeg and PHP reports image/jpeg, with a real temporary path, a real byte count and a zero error code.

Array
(
    [avatar] => Array
        (
            [name] => avatar.jpg
            [type] => image/jpeg
            [tmp_name] => /tmp/phpQ2x9Lm
            [error] => 0
            [size] => 9184
        )
)

That transcript came from a request built by a client that sets part headers by hand, which is one flag on the command line or one line in any HTTP library. Nothing in those five fields describes the contents of /tmp/phpQ2x9Lm, so nothing in them can authorise a write. Log type if you like, and never accept a file because of it. Every decision after that point has to come from reading the file.

An extension allowlist is the first real check, and never the decisive one

Start with an allowlist of the extensions the feature actually needs: jpg, jpeg, png, webp, maybe pdf. A blocklist of executable suffixes fails because the set of suffixes a server executes belongs to the server: Apache maps handlers with AddHandler, nginx matches with a location regex, and every engine spells them differently. Lowercase the extension before you compare it, or AVATAR.JPG passes a check that avatar.jpg would fail.

Extension checking also says nothing about the bytes. A .php file renamed to .jpg defeats the check outright, and it stays harmless only while no layer in the chain maps .jpg to the PHP interpreter. A double extension is a bet on that same configuration, and WordPress's sanitiser already postfixes an unrecognised intermediate extension with an underscore, so shell.php.jpg is stored as shell.php_.jpg.

So read the file. PHP's fileinfo extension returns a type derived from the bytes through finfo_open( FILEINFO_MIME_TYPE ) and finfo_file(). WordPress wraps that in wp_check_filetype_and_ext(), which maps the extension, asks wp_get_image_mime() what an image really is, and falls back to fileinfo for everything else.

Treat the result as a signal rather than a verdict. Where the mime database is vague the function is deliberately forgiving: a file labelled application/octet-stream or application/zip passes for binary major types without an exact match, and when an image's extension and its detected type disagree WordPress renames the file to match the bytes instead of rejecting it. Right behaviour for a media library, and not proof that a file is safe.

Version constraint

fileinfo is compiled in by default from PHP 5.3 onward. wp_check_filetype_and_ext() tests for it first, so a build without the extension does less rather than failing loudly.

Take the filename apart, then refuse to use it

A client-supplied name is a path, not a label. It can carry directory separators, percent-encoded ones, a null byte, a leading dot, a run of dots, and unicode that renders as a different character than it encodes. Normalise it with sanitize_file_name(), then stop treating it as a filename.

It removes /, \, %, + and the null byte, collapses a run of dots into one, replaces whitespace runs with dashes, trims leading and trailing dots and dashes, and postfixes an unrecognised intermediate extension of two to five letters with an underscore. So invoice.php.pdf becomes invoice.php_.pdf.

Then generate your own name. wp_unique_filename() sanitises the candidate, resolves collisions with -1, -2, and takes a callback when the name should be yours. A UUID from wp_generate_uuid4() plus the extension you already verified leaves the client no influence over the path. Keep the original name in the database as a display label, where it cannot be executed.

Where the file lives matters as much as what it is called. The strongest arrangement puts uploads outside the document root, on a host that serves them from a separate origin. Most installs cannot do that, because wp_upload_dir() and the media library assume the uploads directory is public. Where you cannot move the files, deny execution there:

# nginx: no PHP handler inside the uploads prefix
location ^~ /wp-content/uploads/ {
    location ~ \.php$ {
        return 403;
    }
}

On Apache, the same intent means removing the PHP handler for that path. A .htaccess with php_flag engine off works only where PHP runs as an Apache module and AllowOverride permits it, which is a lot of qualification for one line of configuration.

Four traps that survive a correct extension and content check

SVG is a script container with an image's reputation. It is an XML document, so it can carry script and event attributes, and a browser rendering one inline from your domain runs that script on your origin. WordPress does not include image/svg+xml in its default allowed mime list, so a site that accepts SVG has a plugin or a theme adding it through upload_mimes. If you need vector uploads, normalise the XML with a library that understands XML, not a regular expression, strip script, event attributes and external references, and serve the result from a domain with nothing else on it.

Your image pipeline is attacker-facing code. Thumbnail generation hands the file to GD or ImageMagick, and both parse complex binary formats in C. A parsing bug there runs in your PHP process with the web server user's permissions. Cap file size and pixel dimensions before generating anything, and remember that a small PNG can decode into a very large bitmap. WordPress chooses its editor through the wp_image_editors filter, defaulting to Imagick and GD, so an install that never needed Imagick can shorten that list.

A double extension is a bet on configuration you cannot audit. photo.php.jpg is harmless where handlers are matched by the final extension and dangerous where a directive maps .jpg to PHP. That directive is invisible from inside WordPress and can change when the site moves host.

A stored file is a resource on your domain. Even when nothing executes it, the file lends your origin to whatever is inside: a page, a redirect, a document bearing somebody else's branding. Files one visitor uploads and another downloads belong behind a domain whose reputation you can afford to lose.

WordPress hands you a handler, not a permission check

wp_handle_upload() is the ordinary route from a $_FILES entry to a file in the uploads directory. It checks the error code, requires the posted action field to match, refuses an empty file, runs the type check through wp_check_filetype_and_ext(), asks wp_upload_dir() where to write, obtains a unique name and moves the file. Nowhere in that list is a question about who sent the request: no current_user_can() call and no nonce check. media_handle_upload() is the same story with an attachment created at the end. The permission test is the caller's job, and the admin screen that links to the form is not the caller.

add_action( 'admin_post_wpsifero_upload', 'wpsifero_handle_upload' );

function wpsifero_handle_upload() {
    if ( ! current_user_can( 'upload_files' ) ) {
        wp_die( esc_html__( 'You are not allowed to upload files.', 'wpsifero' ), 403 );
    }

    check_admin_referer( 'wpsifero_upload' );

    $file = wpsifero_validate_upload( $_FILES['wpsifero_upload'] ?? array() );

    if ( is_wp_error( $file ) ) {
        wp_die( esc_html( $file->get_error_message() ), 400 );
    }

    $overrides = array(
        // Matched against $_POST['action'] by the handler, and against the nonce above.
        'action'                   => 'wpsifero_upload',
        'test_form'                => true,
        'mimes'                    => wpsifero_allowed_mimes(),
        // Receives ( $dir, $name, $ext ) and owns the stored name outright.
        'unique_filename_callback' => function ( $dir, $name, $ext ) {
            return 'wpsifero-' . wp_generate_uuid4() . $ext;
        },
    );

    $moved = wp_handle_upload( $file, $overrides );

    if ( ! empty( $moved['error'] ) ) {
        wp_die( esc_html( $moved['error'] ), 400 );
    }

    $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;

    if ( $post_id ) {
        // $moved['file'] is an absolute path, $moved['url'] the public URL.
        update_post_meta( $post_id, '_wpsifero_upload', $moved['file'] );
    }
}

The validator runs the allowlist, the content check and the normalisation:

function wpsifero_allowed_mimes() {
    return array(
        'jpg'  => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'jpe'  => 'image/jpeg',
        'png'  => 'image/png',
        'webp' => 'image/webp',
        'pdf'  => 'application/pdf',
    );
}

/**
 * @param array $file One element of $_FILES.
 * @return array|WP_Error
 */
function wpsifero_validate_upload( $file ) {
    $allowed = wpsifero_allowed_mimes();

    if ( ! is_array( $file ) || ! isset( $file['tmp_name'], $file['name'], $file['error'] ) ) {
        return new WP_Error( 'wpsifero_no_file', 'No file was uploaded.' );
    }

    if ( UPLOAD_ERR_OK !== (int) $file['error'] ) {
        return new WP_Error( 'wpsifero_incomplete', 'The upload did not complete.' );
    }

    // True only for a file that arrived as an upload in this request.
    if ( ! is_uploaded_file( $file['tmp_name'] ) ) {
        return new WP_Error( 'wpsifero_not_uploaded', 'That is not an uploaded file.' );
    }

    // Normalise first, then take nothing from the client name but the extension.
    $name = sanitize_file_name( $file['name'] );
    $ext  = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );

    if ( ! isset( $allowed[ $ext ] ) ) {
        return new WP_Error( 'wpsifero_extension', 'That file type is not accepted.' );
    }

    if ( ! extension_loaded( 'fileinfo' ) ) {
        return new WP_Error( 'wpsifero_no_fileinfo', 'File contents cannot be verified here.' );
    }

    $finfo = finfo_open( FILEINFO_MIME_TYPE );
    $mime  = $finfo ? finfo_file( $finfo, $file['tmp_name'] ) : '';

    if ( $finfo ) {
        finfo_close( $finfo );
    }

    if ( $mime !== $allowed[ $ext ] ) {
        return new WP_Error( 'wpsifero_mime', 'The contents do not match the extension.' );
    }

    // The name given to wp_handle_upload() is a hint; the callback decides the stored one.
    $file['name'] = 'upload.' . $ext;
    $file['type'] = $allowed[ $ext ];

    return $file;
}

Two details there are easy to miss. The nonce action and the form's action value should be the same string, because wp_handle_upload() compares $_POST['action'] against the value in the overrides array and its default is wp_handle_upload. And the upload_mimes filter is worth narrowing as well as the handler's own map, because every other upload path on the site consults it. It is site-wide, so adding a type enables it for the media library, the block editor and every other plugin. Accounts holding unfiltered_upload pass the core type test even when it fails, so the filter constrains the people you were not worried about.

CheckWhat it stopsWhat it cannot stop
Extension allowlistExecutable suffixes, odd casing, files with no extensionScript inside a file named .jpg; anything the server maps by configuration
Content check with fileinfoA script sent as image/jpeg; a document sent with the wrong mimeA polyglot with a valid header; a format your image library parses badly
sanitize_file_name()Traversal, null bytes, leading dots, .htaccess, double extensionsUnicode lookalikes, and the fact that the file is still on your origin
Server-side filenameOverwriting an existing upload; a path chosen by the clientNothing about the bytes, at all
No execution in the directoryDirect execution of whatever landed thereA permissive content type on the response that serves it back

Where boundary validation stops working

Every check above makes a promise about the moment of upload only. A file that passes is still an arbitrary sequence of bytes on disk, and something downstream will serve it or parse it.

Serving is where the promise breaks most easily. If the uploads directory sits on the same origin as the site and the response carries a content type derived from the extension, an HTML document uploaded with a .txt name is a page and an SVG is script, and your validation becomes a suggestion. The controls that matter there are not in the plugin: X-Content-Type-Options: nosniff, and a Content-Disposition: attachment response for anything that is not an image you generated.

Parsing is the other half. The moment you open the file with an image library or a scanner, you have given attacker-controlled bytes to a parser somebody else wrote, and a bug in that parser is a bug in your process. Boundary checks do not change that. What helps is isolation: a separate process or queue worker with a memory limit and no database credentials.

Which is why the arrangement worth paying for keeps the file off the application server. A separate origin, or object storage behind a CDN, gives you somewhere to hold user files with no PHP on it, no session cookies in scope and no database within reach. The work becomes issuing a signed upload URL and validating what comes back. Where that is not feasible, put the constraint in the repository: a note that the uploads directory must not execute, and a deployment check that fails when it does.

What to do on Monday

  1. Grep for $_FILES and list every handler. The forgotten ones are the ones that matter.
  2. Put a capability check and a nonce check in front of each one. upload_files is not the same as being logged in.
  3. Replace every comparison against $file['type'] with a check that reads the file.
  4. Take the extension from an allowlist and generate the rest of the name yourself.
  5. Confirm the uploads directory does not execute, and prove it with a request, not a config file.
  6. Cap file size and pixel dimensions before image processing.

Tomas Lindqvist · @tlindqvist

Hardening and operations

Threat models plugins, audits capability and nonce handling, and builds the observability that tells you an integration broke before a customer does.

Continue

Related in Security & Hardening