Implement or audit secure WordPress file uploads and sideloads
Scanned 9/5/2026
Install to Claude Code
npx -y skills add Lonsdale201/wp-agent-skills --skill wp-file-upload-security --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Wp File Upload Security?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/lonsdale201-wp-file-upload-security)More formats (shields.io, HTML) on the badges page.
---
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-http-api-client`** for remote URLs and downloads.
- 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/>
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!