Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 06 / 09 40 MIN

Input and output

File Uploads Done Safely

A working upload validator for WordPress: why the type field is worthless, how to inspect contents, what to do with the filename, and where files must not execute.

An avatar endpoint rejects anything that is not an image by comparing $_FILES['avatar']['type'] against two MIME strings, and in testing the browser always sends one of them. The handler stores the file under the name the client chose. The site is fine until the host's file scanner reports a PHP file in the uploads directory, and the request that put it there looked exactly like an avatar upload.

The value being compared is a string the client wrote into the multipart body, and PHP copies it into the array without opening the temporary file. A request claiming image/jpeg gets image/jpeg in the array, with a real temporary path, a real byte count and an error code of zero. Everything about that array is convincing except its relationship to the bytes on disk.

An upload is the one input that arrives as a file and leaves as something the server may later execute, serve, or parse with a library written in C. That is why the checks are more specific than for a text field. This lesson builds a validator and runs it against files chosen to fail each check in turn.

Trust the temporary path, and treat every other field as a claim

A single $_FILES entry has five fields and one of them is trustworthy. name is the filename from the client, which is a path as far as your code is concerned. type is the client's claim. size is a number the client can send; PHP does not verify it against the temporary file for you in every configuration. error is set by PHP and worth checking. tmp_name is a path PHP chose for the bytes it actually received, and it is the only field that describes reality.

So nothing about the file is decided before something reads tmp_name. Log type if it helps you debug, and never branch on it.

An extension allowlist is the first check, and the bytes decide

Start with the extensions the feature actually needs, and not with a blocklist of suffixes you consider dangerous, because the set of suffixes a server executes belongs to the server. Apache maps handlers in its configuration, nginx matches with a location regex, and the mapping can change when the site moves host.

The extension check has to read the extension from the client name predictably, and say what happens when the extension and the contents disagree. WordPress gives you both halves. sanitize_file_name() normalises the name, and wp_check_filetype_and_ext() takes the temporary path, the candidate filename and an optional MIME map, then reads the file to establish what it is.

// Actual output on WordPress 7.0.7 and PHP 8.5.6.
sanitize_file_name( '../..//invoice.php.pdf' );
// 'invoice.php_.pdf'

sanitize_file_name( 'avatar.php.jpg' );
// 'avatar.php_.jpg'

sanitize_file_name( 'shell.pHp' );
// 'shell.pHp'      // casing is not touched

sanitize_file_name( '..%2f..%2fwp-config.php' );
// '2f.2fwp-config.php'

Three behaviours there are worth knowing before you rely on the function. Directory separators and percent signs are removed, and the leading dots go with them, so traversal is not possible through the returned name. An intermediate extension of two to five letters that WordPress does not recognise gets an underscore appended, which is why avatar.php.jpg is stored as avatar.php_.jpg and stops looking like a file the server might hand to PHP. And shell.pHp survives the sanitiser unchanged, so a comparison that does not lowercase the extension will pass a value that a case-insensitive handler treats as PHP.

Now the content check. Hand the temporary path to wp_check_filetype_and_ext() and read what comes back.

$allowed = array(
    'jpg'  => 'image/jpeg',
    'png'  => 'image/png',
    'webp' => 'image/webp',
);

// A text file that arrived as note.txt.
wp_check_filetype_and_ext( '/tmp/uploads/note.txt', 'note.txt', $allowed );
// array( 'ext' => 'txt', 'type' => 'text/plain', 'proper_filename' => false )

// A PHP script renamed to avatar.jpg.
wp_check_filetype_and_ext( '/tmp/uploads/avatar.jpg', 'avatar.jpg', $allowed );
// array( 'ext' => false, 'type' => false, 'proper_filename' => false )

// A real PNG submitted with a .gif name.
wp_check_filetype_and_ext( '/tmp/uploads/real.png', 'real.gif', $allowed );
// array( 'ext' => 'png', 'type' => 'image/png', 'proper_filename' => 'real.png' )

The second result is the answer to the renamed-script problem: the extension claims JPEG, the bytes are not an image, so both ext and type come back false and the caller is expected to refuse. The third result is the same function choosing a different tactic. The bytes really are a PNG, so instead of refusing it reports the name the file should have had, in proper_filename. That is right for a media library and wrong for a validator that wants to compare the extension it approved with the type it detected, because silently renaming is how a PNG ends up stored under a .gif path.

Two characteristics of the function matter when you read its code rather than its documentation. It is deliberately forgiving where the MIME database is vague: a file the system identifies as application/octet-stream or application/zip passes for binary major types without an exact match. And it tests for the fileinfo extension before using it, so a PHP build without it does less checking rather than failing loudly. Both are reasons to treat the result as a signal from a helpful library rather than as a verdict.

Generate the filename instead of accepting one

Even a sanitised client name is a value the client had a hand in. The extension is the only part that carries information your code needs, because it is what the server maps to a handler. The rest can be yours.

// Actual output on WordPress 7.0.7. Both files exist in the directory already.
wp_unique_filename( '/var/www/uploads', 'photo.jpg' );
// 'photo-2.jpg'

// The callback receives ( $dir, $name, $ext ) and its return value is the stored name.
wp_unique_filename( '/var/www/uploads', 'photo.jpg', static function ( $dir, $name, $ext ) {
    return 'acme-' . md5( $name ) . $ext;
} );
// 'acme-72acded3acd45e4c8b6ed680854b8ab1.jpg'

wp_unique_filename() resolves collisions by appending a number, which stops a second upload from overwriting the first, and it sanitises its candidate before doing so. The callback takes the name over completely: whatever it returns becomes the stored filename, and the extension arrives separately so you never parse a name to find it. So there are three options in increasing order of strength: accept the sanitised name, let wp_unique_filename() de-duplicate it, or generate a name of your own. The third is a UUID from wp_generate_uuid4() plus the extension you already verified, with the original name kept in the database as a display label where it cannot be executed.

Where the file lands is part of the validation

A file that failed every check above is dangerous. A file that passed every check above is an arbitrary sequence of bytes your server is now responsible for, and the location decides what happens when a browser or an interpreter asks for it. The strongest arrangement keeps uploads outside the document root, on a host that serves them separately, so no PHP configuration is in reach of the stored bytes.

Most WordPress 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 in that path and prove it with a request rather than by reading a configuration file.

The check that survives a host migration

A rule in the web server configuration is invisible from inside WordPress and can disappear when the site moves. Prove it by writing a file that echoes a known string into the uploads directory and requesting it: if the response is the string, the directory executes PHP. Put that request in the repository as a deployment check, so a new host cannot quietly remove the control.

Serving the file back needs the same reasoning. If the uploads directory sits on the site's origin and the response carries a content type derived from the extension, an HTML document uploaded as .txt is a page on your domain. The control for that is in the response, not in PHP: a Content-Disposition: attachment header for anything that is not an image you generated.

Three 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 elements and event attributes, and a browser rendering one inline from your domain runs them on your origin. WordPress does not include image/svg+xml in its default allowed MIME list, so a site that accepts SVG has something adding it through the upload_mimes filter. If the feature needs vector uploads, treat the file as XML: strip script, event attributes and external references with a library that understands XML, and serve the result from an origin with nothing else on it. A regular expression over the file is not that.

Your image pipeline is attacker-facing code. Generating a thumbnail hands the bytes to GD or to ImageMagick, both of which parse complex binary formats in C, and a parsing bug in either one executes in your PHP process with the web server user's permissions. Cap the uploaded size and the pixel dimensions before anything opens the file, and remember that a small PNG can decode into a very large bitmap. WordPress picks its editor through the wp_image_editors filter, so an install that has never needed Imagick can shorten that list.

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

wp_handle_upload moves the file; the permission check is yours

wp_handle_upload() is the ordinary route from a validated $_FILES entry to a file in the uploads directory. Read what it does before you call it, because the list of checks is shorter than people assume. It requires $_POST['action'] to equal the action value in the overrides, rejects an error code above zero, refuses an empty file, tests is_uploaded_file() on the temporary path, 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 capability check, no nonce. media_handle_upload() is the same story with an attachment created at the end. The functions cannot make that decision for you, because the capability depends on what the feature is for: an avatar needs upload_files, an import needs import, and a front-end form may legitimately be open to visitors with no account at all. So the handler runs its own checks first, then calls WordPress to do the moving.

add_action( 'admin_post_acme_avatar', 'acme_handle_avatar' );

function acme_handle_avatar(): void {
    // 1. Permission, before anything touches $_FILES.
    if ( ! current_user_can( 'upload_files' ) ) {
        wp_die( esc_html__( 'You are not allowed to upload files.', 'acme' ), 403 );
    }

    // 2. Intent. The nonce action matches the form's action field below.
    check_admin_referer( 'acme_avatar' );

    // 3. Validation. This is the function in the lab.
    $file = acme_validate_upload( $_FILES['acme_avatar'] ?? array() );

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

    // 4. Moving. The overrides tighten what the handler will accept and take the name.
    $overrides = array(
        'action'                   => 'acme_avatar',
        'test_form'                => true,
        'mimes'                    => acme_allowed_upload_mimes(),
        'unique_filename_callback' => static function ( $dir, $name, $ext ) {
            return 'acme-' . wp_generate_uuid4() . $ext;
        },
    );

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

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

    // $moved['file'] is an absolute path, $moved['url'] the public URL.
    update_user_meta( get_current_user_id(), 'acme_avatar', $moved['file'] );
}

Two details in the overrides are easy to get wrong. The action value has to match both $_POST['action'] and the nonce action, or the handler refuses a request that was otherwise fine. And mimes is the map the type check uses, so passing your own allowlist means the handler applies the same policy your validator did rather than the site-wide list the media library also consults.

That site-wide list is where SVG usually arrives. upload_mimes filters the MIME types WordPress allows everywhere, so adding a type there enables it for the media library, the block editor and every plugin. A plugin that needs to accept a PDF should narrow its own $mimes map rather than widen the global one.

Lab 40 minutes

Write acme_validate_upload(): it takes one $_FILES entry and returns the entry, normalised, or a WP_Error naming what was wrong. It must check the error code, the fact that the file arrived as an upload, the size, the extension against an allowlist, and the contents against the extension. It must take nothing from the client name but the extension.

Then run it against these six inputs and record the outcome for each: a PHP script renamed to avatar.jpg; a text file renamed to note.png; a real PNG; a real PNG submitted with a .gif name; an SVG submitted with a .jpg name; and a legitimate JPEG whose type field says text/plain.

Answer two questions. Which input does your validator accept that a reviewer would want refused, and what would you add to catch it? And which check would you have to remove from a version that accepted uploads from visitors who are not logged in, given that those visitors may legitimately have no capability?

Show the solution and the reasoning
function acme_allowed_upload_mimes(): array {
    return array(
        'jpg'  => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'png'  => 'image/png',
        'webp' => 'image/webp',
    );
}

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

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

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

    if ( ! is_uploaded_file( $file['tmp_name'] ) ) {
        return new WP_Error( 'acme_not_uploaded', 'That file did not arrive as an upload.' );
    }

    if ( (int) $file['size'] > 2 * MB_IN_BYTES ) {
        return new WP_Error( 'acme_too_big', 'Images must be 2 MB or smaller.' );
    }

    $name = sanitize_file_name( (string) $file['name'] );
    $ext  = strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) );

    if ( ! isset( $allowed[ $ext ] ) ) {
        return new WP_Error( 'acme_extension', 'Only JPEG, PNG and WebP images are accepted.' );
    }

    $check = wp_check_filetype_and_ext( $file['tmp_name'], 'upload.' . $ext, $allowed );

    if ( empty( $check['type'] ) || $check['type'] !== $allowed[ $ext ] ) {
        return new WP_Error( 'acme_mime', 'The file contents do not match the extension.' );
    }

    $size = wp_getimagesize( $file['tmp_name'] );

    if ( ! $size || $size[0] > 4000 || $size[1] > 4000 ) {
        return new WP_Error( 'acme_dimensions', 'Images must be 4000 pixels or smaller per side.' );
    }

    $file['name'] = 'upload.' . $ext;
    $file['type'] = $check['type'];

    return $file;
}

The outcomes, run against real files with the function above:

a PHP file named shell.jpg       REFUSED  acme_mime       contents do not match the extension
text renamed to note.jpg        REFUSED  acme_mime       contents do not match the extension
a real PNG                      ACCEPTED name=upload.png  type=image/png
a real PNG called .svg          REFUSED  acme_extension  only JPEG, PNG and WebP
an SVG                          REFUSED  acme_extension  only JPEG, PNG and WebP
an SVG renamed to .jpg          REFUSED  acme_mime       contents do not match the extension
a PNG renamed to .jpg           REFUSED  acme_mime       contents do not match the extension
a real PNG with a lying type    ACCEPTED name=upload.png  type=image/png
a truncated upload              REFUSED  acme_incomplete  the upload did not finish
a traversal name               REFUSED  acme_extension  only JPEG, PNG and WebP
a 3 MB JPEG                     REFUSED  acme_too_big    images must be 2 MB or smaller

The input a reviewer might still want refused is the last accepted row, the real PNG whose type field claims text/plain. Refusing it would mean trusting $_FILES['type'], which is the mistake the whole lesson is about. The field is not used anywhere in the validator, and the stored type is the one WordPress detected from the bytes, so a file is only ever refused for something the validator verified itself.

The SVG renamed to .jpg is worth reading twice. The extension check passes nothing unusual, and the content check then compares the detected type against image/jpeg, finds image/svg+xml or text/xml, and refuses. Comparing against the allowlisted type is what makes that work, which is why the check is $check['type'] !== $allowed[ $ext ] rather than merely that the type is non-empty.

For a front-end upload from visitors who may have no account, the capability check is the one that comes out, and something has to take its place. A nonce protects the form from cross-site submission, a rate limit per connection limits volume, and a moderation queue keeps the file out of any public listing until a person has looked at it. None of those is a substitute for permission, so the honest description of that feature is anonymous file hosting on a site that also has an admin area. If that is not a trade you would defend in a review, the capability check stays and the feature requires an account.

Progress

Mark this lesson done and it stays marked in this browser.