Claude Skill

wp-file-upload-security

Implement or audit secure WordPress file uploads and sideloads

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-wordpress_wp-file-upload-security-52f6020.zip · 5 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-file-upload-security
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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

Need API
Create a normal Media Library attachment media_handle_upload()
Store a local upload without an attachment post wp_handle_upload()
Sideload a remote file download_url() then media_handle_sideload()
Let a REST client create media Core /wp/v2/media when its contract fits
Store a genuinely private document Protected 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.

WordPress 7.1 also supports browser-side image processing and new REST flows for dimension validation, size-aware quality, and registering one sideloaded file for multiple sizes. This changes where bytes may be transformed, not the trust boundary: server-side capability, intent, MIME/dimension/resource checks, metadata validation, and cleanup remain mandatory. Use wp-client-side-media-processing for that protocol.

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.
  • Treat wp_get_attachment_metadata() as array|false. WordPress 7.1 rejects non-array stored/filtered metadata and normalizes a present non-array sizes value to an empty array.
  • wp_filesize() is non-negative in WordPress 7.1, but zero is ambiguous; do not use it alone to distinguish an empty file from a read/filter failure.

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-privacy-personal-data for personal documents and retention.
  • Use wp-client-side-media-processing for the WordPress 7.1 browser/REST multi-request upload protocol and its fallback behavior.

References

Files (wp-agent-skills)
  • agents
    • openai.yaml 223 B
      interface:
        display_name: "WordPress File Upload Security"
        short_description: "Build secure WordPress upload flows"
        default_prompt: "Use $wp-file-upload-security to implement or audit this WordPress file upload flow."
      
  • SKILL.md 12.9 KB
    ---
    name: wp-file-upload-security
    description: 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.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.0 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # 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
    
    | Need | API |
    |---|---|
    | Create a normal Media Library attachment | `media_handle_upload()` |
    | Store a local upload without an attachment post | `wp_handle_upload()` |
    | Sideload a remote file | `download_url()` then `media_handle_sideload()` |
    | Let a REST client create media | Core `/wp/v2/media` when its contract fits |
    | Store a genuinely private document | Protected 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.
    
    WordPress 7.1 also supports browser-side image processing and new REST flows
    for dimension validation, size-aware quality, and registering one sideloaded
    file for multiple sizes. This changes where bytes may be transformed, not the
    trust boundary: server-side capability, intent, MIME/dimension/resource checks,
    metadata validation, and cleanup remain mandatory. Use
    `wp-client-side-media-processing` for that protocol.
    
    ## 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`.
    
    ```php
    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.
    
    ```php
    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.
    - Treat `wp_get_attachment_metadata()` as `array|false`. WordPress 7.1 rejects
      non-array stored/filtered metadata and normalizes a present non-array `sizes`
      value to an empty array.
    - `wp_filesize()` is non-negative in WordPress 7.1, but zero is ambiguous; do
      not use it alone to distinguish an empty file from a read/filter failure.
    
    ## 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-privacy-personal-data`** for personal documents and retention.
    - Use **`wp-client-side-media-processing`** for the WordPress 7.1 browser/REST
      multi-request upload protocol and its fallback behavior.
    
    ## 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.
    
    - Official documentation: <https://developer.wordpress.org/reference/functions/media_handle_upload/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/wp_handle_upload/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/wp_check_filetype_and_ext/>
    - WordPress 7.1 client-side media processing: <https://make.wordpress.org/core/2026/07/22/client-side-media-processing-in-wordpress-7-1/>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related