agentsclimarketplace

Wp file upload security

Skill Lonsdale201/wp-agent-skills/wordpress/wp-file-upload-security

A community-maintained collection of agent skills for WordPress plugin and theme development.

Install
npx -y skills add Lonsdale201/wp-agent-skills --skill wp-file-upload-security

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 21 stars21 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Implement or audit secure WordPress file uploads and sideloads with media_handle_upload, wp_handle_upload, wp_check_filetype_and_ext, strict MIME/extension allowlists, capability and nonce checks, size limits, collision-safe image format conversion, EXIF orientation, attachment cleanup, SVG/archive policy, remote download cleanup, and private file storage. Use when code handles $_FILES, multipart forms, REST uploads, Media Library attachments, post-upload image conversion, imported remote files, ZIP extraction, or custom download endpoints.

SKILL.md

11.9 KB, ~2.6k tokens by cl100k_base, as published. Nobody here has run it

WordPress File Upload Security

Treat an upload as untrusted bytes plus attacker-controlled metadata. Use the core upload pipeline, then apply a narrower product policy; extension/MIME matching alone is not malware scanning or content safety.

Choose the flow

NeedAPI
Create a normal Media Library attachmentmedia_handle_upload()
Store a local upload without an attachment postwp_handle_upload()
Sideload a remote filedownload_url() then media_handle_sideload()
Let a REST client create mediaCore /wp/v2/media when its contract fits
Store a genuinely private documentProtected storage + authorized download controller, not a public uploads URL

Do not manually combine move_uploaded_file(), a client MIME, and the original name when core already provides unique naming, upload checks, and hooks.

Browser-to-Media-Library pattern

The form needs method="post" and enctype="multipart/form-data". The handler owns authorization and request intent before touching $_FILES.

function myplugin_handle_document_upload() {
    if ( ! current_user_can( 'upload_files' ) ) {
        return new WP_Error( 'myplugin_forbidden', __( 'Upload not allowed.', 'myplugin' ) );
    }
    check_admin_referer( 'myplugin_upload_document' );

    if ( empty( $_FILES['myplugin_document'] )
         || ! is_array( $_FILES['myplugin_document'] ) ) {
        return new WP_Error( 'myplugin_missing_upload', __( 'Choose a file.', 'myplugin' ) );
    }

    $file = $_FILES['myplugin_document'];
    if ( UPLOAD_ERR_OK !== (int) ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) ) {
        return new WP_Error( 'myplugin_upload_error', __( 'Upload failed.', 'myplugin' ) );
    }

    $max = min( wp_max_upload_size(), 5 * MB_IN_BYTES );
    if ( (int) ( $file['size'] ?? 0 ) < 1 || (int) $file['size'] > $max ) {
        return new WP_Error( 'myplugin_upload_size', __( 'Invalid file size.', 'myplugin' ) );
    }

    $mimes = array(
        'pdf' => 'application/pdf',
        'jpg|jpeg' => 'image/jpeg',
        'png' => 'image/png',
    );
    $checked = wp_check_filetype_and_ext(
        (string) $file['tmp_name'],
        (string) $file['name'],
        $mimes
    );

    // Enforce this feature's policy even for users with unfiltered_upload.
    if ( empty( $checked['ext'] ) || empty( $checked['type'] ) ) {
        return new WP_Error( 'myplugin_upload_type', __( 'File type not allowed.', 'myplugin' ) );
    }

    require_once ABSPATH . 'wp-admin/includes/file.php';
    require_once ABSPATH . 'wp-admin/includes/media.php';
    require_once ABSPATH . 'wp-admin/includes/image.php';

    $attachment_id = media_handle_upload(
        'myplugin_document',
        0,
        array(),
        array( 'test_form' => false, 'mimes' => $mimes )
    );

    if ( is_wp_error( $attachment_id ) ) {
        return $attachment_id;
    }

    return (int) $attachment_id;
}

media_handle_upload() defaults test_form to false, so the preceding nonce is not optional. If attaching to a post, also authorize that object (for example current_user_can( 'edit_post', $post_id )) before passing its ID.

Strict type policy

Never trust $_FILES['type']; it is client-supplied. The original filename is also untrusted. wp_handle_upload() calls wp_check_filetype_and_ext(), but pre-checking with the feature's own narrow $mimes array lets the plugin reject unknown types regardless of unfiltered_upload capability.

Important limits:

  • wp_check_filetype_and_ext() performs deeper content checks where core knows how, especially images, and uses fileinfo for other types when available.
  • A matching PDF/ZIP/office MIME does not prove the document is harmless.
  • If the product accepts risky formats, add format-specific parsing/scanning, resource limits, and operational quarantine before publishing the file.
  • Do not disable test_type or broaden global upload_mimes for one feature; pass a local allowlist to that upload call.

Image format conversion and derived-name collisions

Core choosing a unique source name does not make a later, differently suffixed target unique. For example, an existing photo.webp does not collide with a new photo.jpg during the normal JPEG upload check. Code that later replaces the extension and saves to photo.webp can overwrite the older attachment.

When converting uploads or generating derivatives:

  • never create the destination with only pathinfo() plus an extension swap;
  • run wp_unique_filename() against the final target basename immediately before a custom save, and verify the returned editor path is the intended unique target;
  • when product semantics permit, prefer core image-editor output-format hooks so core can account for alternate output names; verify separately whether the supported core version converts the original, only generated sizes, or both;
  • treat wp_unique_filename() as existing-name resolution, not an atomic filesystem reservation; do not claim concurrent-write safety without a test or an exclusive-write design;
  • validate the saved file before deleting the source, and preserve the source whenever the destination or downstream upload result is incomplete.

Normalize EXIF orientation before calculating resize dimensions and saving the replacement. Use the image editor's maybe_exif_rotate() path or keep the conversion inside the applicable core pipeline. Deleting the source before orientation and output validation removes the reliable recovery input.

SVG and active content

SVG is XML that can contain scripts, external references, event handlers, and hostile complexity. Core does not allow SVG uploads by default. Do not add SVG to upload_mimes and call it finished. Use a maintained SVG sanitizer with a documented element/attribute/URL policy, cap complexity/size, rasterize when possible, and serve with safe headers. The same active-content concern applies to HTML and other browser-executable formats.

Archives

An allowed ZIP MIME is not permission to call ZipArchive::extractTo(). Archives need:

  • compressed/uncompressed byte and entry-count limits;
  • path traversal, absolute path, symlink/hardlink/device-node rejection;
  • an extension/content allowlist for every extracted entry;
  • a fresh staging directory outside public execution paths;
  • cleanup on every failure.

For WordPress ZIP flows, initialize WP_Filesystem() and prefer unzip_file(); it validates entry paths and returns true|WP_Error. It still does not enforce your content policy or make arbitrary executable files safe.

Remote sideloads

download_url() uses the safe HTTP API and returns a temporary file. Validate the final bytes exactly like a browser upload and always delete leftovers.

require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';

$tmp = download_url( $url, 30 );
if ( is_wp_error( $tmp ) ) {
    return $tmp;
}

$name = sanitize_file_name( wp_basename( (string) wp_parse_url( $url, PHP_URL_PATH ) ) );
if ( '' === $name ) {
    $name = sanitize_file_name( wp_basename( $tmp ) );
}
$file = array(
    'name'     => $name,
    'tmp_name' => $tmp,
);

try {
    $checked = wp_check_filetype_and_ext( $tmp, $file['name'], $mimes );
    if ( empty( $checked['ext'] ) || empty( $checked['type'] ) ) {
        return new WP_Error( 'myplugin_sideload_type', 'Downloaded file type not allowed.' );
    }

    // media_handle_sideload() has no MIME-overrides argument; enforce the
    // feature allowlist above, then let core apply the site's allowed mimes.
    $id = media_handle_sideload( $file, 0 );
    if ( is_wp_error( $id ) ) {
        return $id;
    }
    $tmp = ''; // Core moved the file.
    return (int) $id;
} finally {
    if ( $tmp && file_exists( $tmp ) ) {
        wp_delete_file( $tmp );
    }
}

Do not trust remote Content-Type, Content-Disposition, or URL extension. Apply an exact host policy and the outbound HTTP skill as well.

Public versus private files

The normal uploads directory is designed for publicly addressable media. An attachment marked private does not automatically protect its underlying URL. For contracts, medical records, exports, or licensed downloads:

  • store outside the web root or behind server-level deny rules;
  • authorize every download with object ownership/capability checks;
  • stream through a controller or issue a short-lived server/object-store URL;
  • set Content-Disposition, a fixed safe Content-Type, nosniff, and cache policy appropriate to the data;
  • never rely only on .htaccess because the site may run Nginx or another server that ignores it.

Failure cleanup and lifecycle

  • Check every array|WP_Error|bool result.
  • If post-processing fails after attachment creation, delete the attachment with wp_delete_attachment( $id, true ) unless a retry workflow owns it.
  • Remove temporary files, partial derivatives, staging directories, and queue payloads on both exceptions and normal errors.
  • Apply site/multisite quotas via wp_max_upload_size() plus a narrower feature limit; browser MAX_FILE_SIZE is UX only, not a security boundary.
  • Store attachment IDs rather than URLs and render through attachment helpers.

Tests

Cover empty/partial/oversized files, double extensions, mismatched real MIME, uppercase extensions, polyglots appropriate to supported formats, SVG/HTML, archive traversal/bombs, duplicate names, cross-extension derived-name collisions (photo.webp already exists before photo.jpg/photo.png), EXIF orientations 2–8, conversion failure with source preservation, low-privilege users, nonce failure, post ownership, sideload timeout/redirect, cleanup failure, and multisite quota. Run tests with and without fileinfo, and with a user that has unfiltered_upload to ensure the feature allowlist still wins.

Critical rules

  • Capability and nonce checks happen before reading/processing the upload.
  • Use a narrow local extension/MIME allowlist and verify actual bytes.
  • Core type checks are not malware or active-content sanitization.
  • Never extract untrusted archives directly into a public/plugin directory.
  • Public uploads are not private storage.
  • Clean temporary files and partial attachments on every failure path.

Cross-references

  • Use wp-security-audit for handler authorization and traversal review.
  • Use wp-filesystem-api for non-upload filesystem transports.
  • Use wp-http-api-client for remote URLs and downloads.
  • Use wp-privacy-personal-data for personal documents and retention.

Core references

  • wp-admin/includes/file.php: _wp_handle_upload(), wp_handle_upload(), download_url(), and unzip_file().
  • wp-admin/includes/media.php: media_handle_upload() and sideload handling.
  • wp-includes/functions.php: wp_check_filetype_and_ext() and allowed mimes.

References

Gives 0 of the 12 instructions most security skills give in ~2.6k tokens

Counted across 648 of the 828 authors here whose files we hold, read 2026-08-07

  • parameterize all database queriesin 68 of 648, across 51 files
  • hash passwords using bcrypt scrypt or argon2in 49 of 648, across 36 files
  • apply rate limiting to authentication endpointsin 48 of 648, across 24 files
  • Configure security headersin 35 of 648, across 19 files
  • validate all inputsin 32 of 648, across 24 files
  • validate all external input at the system boundaryin 29 of 648, across 19 files
  • run containers as a non-root userin 28 of 648, across 15 files
  • use httponly secure samesite cookies for sessionsin 26 of 648, across 15 files
  • run dependency audits before every releasein 21 of 648, across 10 files
  • encode output to prevent cross-site scriptingin 21 of 648, across 11 files
  • copy dependencies before source codein 20 of 648, across 9 files
  • store secrets in environment variablesin 20 of 648, across 18 files

Said here and by no other author read

  • use core APIs for handling uploads
  • check capability and nonce before processing upload
  • pre-check bytes against a narrow feature allowlist
  • run wp_unique_filename on final target before saving
  • normalize EXIF orientation before resizing or saving
  • validate saved file before deleting the source

Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.