File upload security
When to use this skill
Use this skill whenever code touches user-provided files or paths:
- Handling
$_FILESfrom a form (front-end or admin). - Saving uploads, avatars, imports, attachments, CSV/JSON imports.
- Building a filesystem path from user input (download handlers,
include,readfile). - Serving or deleting files based on a request parameter.
Arbitrary file upload is one of the highest-severity WordPress bugs — an uploaded .php
in a web-served directory is remote code execution.
Core principles (and why they matter)
- Use
wp_handle_upload(), not manualmove_uploaded_file(). Core validates the upload, applies the site's MIME rules, sanitizes the filename, and places the file in the uploads directory with a unique name. - Allowlist types; never blocklist. Decide which extensions/MIME types you accept and
reject everything else. Blocklists miss variants (
.phtml,.php5,.pht, double extensions). - Verify the real type, not the claimed one. The browser-supplied MIME and the
extension can lie.
wp_check_filetype_and_ext()checks extension vs. actual content. - Never trust the client filename for a path. Use
sanitize_file_name()and let core assign the final name; never concatenate$_FILES[...]['name']into a path. - Block executables from web-served upload dirs. Don't allow
.php/.phtml/.htaccessuploads; harden the uploads directory (seewp-hardening-best-practices). - Prevent path traversal. Validate any path built from input with
realpath()and confirm it stays within an allowed base directory; reject..sequences.
Step-by-step implementation
- Verify nonce + capability (
upload_filesor stricter) before processing. - Require
wp_handle_upload()witharray( 'test_form' => false )(or set the action). - Validate the result with
wp_check_filetype_and_ext()against an explicit allowlist. - Reject anything not on the allowlist; surface a clear error.
- For path operations, resolve with
realpath()and assert the prefix matches an allowed base; neverinclude/readfileraw input. - Store the returned URL/path; escape on output.
Supporting references
| Reference | Load when |
|---|---|
| File upload security checklist | Before final verification of the file upload security controls. |
| Secure file upload handler | Implementing the nonce-to-capability-to-validated-upload flow and optional media-library attachment. |
Common AI mistakes / anti-patterns
Mistake 1 — move_uploaded_file() straight from $_FILES
// ❌ Insecure: no type/extension validation, attacker-controlled filename → RCE.
$name = $_FILES['file']['name'];
move_uploaded_file( $_FILES['file']['tmp_name'], WP_CONTENT_DIR . '/uploads/' . $name );
// ✅ Secure: let core validate, sanitize, and place the file.
if ( ! current_user_can( 'upload_files' ) ) {
wp_die( esc_html__( 'Forbidden', 'my-plugin' ), 403 );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
$upload = wp_handle_upload( $_FILES['file'], array( 'test_form' => false ) );
if ( isset( $upload['error'] ) ) {
wp_die( esc_html( $upload['error'] ) );
}
$file_url = $upload['url'];
Mistake 2 — Trusting the client-provided MIME type
// ❌ Insecure: $_FILES['file']['type'] is set by the browser and forgeable.
if ( 'image/png' === $_FILES['file']['type'] ) {
save_it();
}
// ✅ Secure: check actual extension/type and allowlist.
$check = wp_check_filetype_and_ext( $_FILES['file']['tmp_name'], $_FILES['file']['name'] );
$allowed = array( 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg' );
if ( empty( $check['ext'] ) || ! isset( $allowed[ $check['ext'] ] ) ) {
wp_die( esc_html__( 'File type not allowed.', 'my-plugin' ), 400 );
}
Mistake 3 — Blocklisting dangerous extensions
// ❌ Insecure: misses .phtml, .php5, .pht, .phar, case variants, double extensions.
$ext = pathinfo( $name, PATHINFO_EXTENSION );
if ( 'php' === $ext ) {
wp_die( 'No PHP files' );
}
// ✅ Secure: allowlist exactly what you accept; reject everything else.
$allowed_ext = array( 'png', 'jpg', 'jpeg', 'gif', 'pdf' );
$ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
if ( ! in_array( $ext, $allowed_ext, true ) ) {
wp_die( esc_html__( 'File type not allowed.', 'my-plugin' ), 400 );
}
Mistake 4 — Path traversal in a download/read handler
// ❌ Insecure: ../../wp-config.php is reachable.
$file = $_GET['file'];
readfile( '/var/www/uploads/' . $file );
// ✅ Secure: sanitize, resolve, and confine to the base directory.
$base = realpath( wp_upload_dir()['basedir'] );
$request = sanitize_file_name( wp_unslash( $_GET['file'] ?? '' ) );
$target = realpath( $base . '/' . $request );
if ( false === $target || strpos( $target, $base . DIRECTORY_SEPARATOR ) !== 0 ) {
wp_die( esc_html__( 'Invalid file.', 'my-plugin' ), 400 );
}
readfile( $target );
Mistake 5 — Widening allowed MIME types globally and forgetting it
// ❌ Risky: opens SVG (script-bearing) site-wide with no sanitization.
add_filter( 'upload_mimes', function ( $m ) {
$m['svg'] = 'image/svg+xml';
return $m;
} );
// ✅ Safer: only if truly needed, sanitize SVGs and restrict to high-trust roles.
// Prefer not allowing SVG. If required, sanitize markup server-side and gate by capability.
add_filter( 'upload_mimes', function ( $m ) {
if ( current_user_can( 'manage_options' ) ) {
$m['svg'] = 'image/svg+xml'; // plus server-side SVG sanitization before storage
}
return $m;
} );
Mistake 6 — Trusting extension alone and missing double extensions
// ❌ Insecure: pathinfo can be fooled by file.php.jpg or .phtml variants.
$ext = pathinfo( $name, PATHINFO_EXTENSION );
if ( 'jpg' === $ext ) {
accept_upload();
}
// ✅ Secure: use wp_check_filetype_and_ext() which inspects real MIME type and extension.
$check = wp_check_filetype_and_ext( $tmp_name, $name, $allowed );
if ( empty( $check['ext'] ) || empty( $check['type'] ) ) {
wp_die( esc_html__( 'Invalid file type.', 'my-plugin' ), 400 );
}
Mistake 7 — Serving uploaded files without path containment
// ❌ Insecure: download handler may escape the uploads directory.
readfile( wp_upload_dir()['basedir'] . '/' . $_GET['file'] );
// ✅ Secure: resolve and confine to the uploads base directory.
$base = realpath( wp_upload_dir()['basedir'] );
$request = sanitize_file_name( wp_unslash( $_GET['file'] ?? '' ) );
$target = realpath( $base . '/' . $request );
if ( false === $target || strpos( $target, $base . DIRECTORY_SEPARATOR ) !== 0 ) {
wp_die( esc_html__( 'Invalid file.', 'my-plugin' ), 400 );
}
readfile( $target );
Related: see the filesystem-security skill for post-upload path handling and deletion.
Correct code examples
A complete secure upload handler (nonce + capability + wp_handle_upload +
wp_check_filetype_and_ext allowlist + safe path handling) is in
references/secure-file-upload.php.
Checklist
- Upload handler checks nonce and the
upload_filescapability first. - Uses
wp_handle_upload()(not rawmove_uploaded_file). - Validates with
wp_check_filetype_and_ext()against an explicit allowlist. - Accepts via allowlist; never relies on a blocklist of extensions.
- Does not trust the client-supplied MIME type (
$_FILES[...]['type']). - Filenames pass through
sanitize_file_name()/ are assigned by core. - Any path built from input is resolved with
realpath()and confined to a base dir. - Executable types are not accepted into web-served directories.
- SVG and other script-bearing types are sanitized or disallowed.