Home / Articles / Web Security
Web Security

File Upload in PHP and WordPress: Don't Let Forms Become Entry Points

The upload feature may seem simple, but files uploaded to the server can pose risks of code execution, storage waste, and even website takeover. Here’s how to build a more secure file upload in PHP and WordPress.

Upload File di PHP dan WordPress: Jangan Biarkan Form Menjadi Pintu Masuk

The upload feature is often considered a small task: users select a file, press a button, and the application saves it. The problem is, the server does not know if a file named photo.jpg is actually an image, whether its size is reasonable, or if its name is attempting to inject a directory path.

If validation is weak, the upload feature can become a pathway for uploading malicious files, filling up server storage, overwriting other files, or even executing code on the server. OWASP lists unrestricted file upload as a serious risk because its impact depends on how files are stored and processed by the application.

The main principle is simple: do not treat files from users as trusted files. Check who is uploading, what type of file is needed, how large it is, what its content is, and where the file is stored.

Why checking the extension alone is not enough?

Checks like pathinfo($filename, PATHINFO_EXTENSION) are useful, but they are not a complete defense. The file name comes from the user and can be crafted in many variations, such as double extensions, special characters, or names attempting to escape the upload directory.

The Content-Type header from the browser should also not be treated as the sole source of truth. Its value can be spoofed. Therefore, the application needs to compare multiple signals: allowed extensions, MIME types detected from the file content, file size, and the application's business rules.

For example, if the website only accepts profile pictures, there may be no reason to allow PDF, ZIP, SVG, or script-based files. The fewer types of files accepted, the smaller the attack surface.

Layers of checks to implement

1. Limit users and business needs

Define who is allowed to upload files. The upload form for administrators is certainly different from the attachment form accessible to the public. Implement authentication, authorization, and CSRF protection on the upload endpoint.

After that, create a narrow permission list. For avatars, only allow image formats that are actually used. For documents, perhaps only PDF or DOCX. Do not create rules based solely on “blocking dangerous extensions,” as this approach can easily miss other extension variations.

2. Limit file size and number

File size needs to be limited in more than one place: application validation, PHP configuration such as upload_max_filesize and post_max_size, and limits on the web server or proxy if available.

Also limit the number of files in a single request and the number of uploads per user. Without restrictions, attackers do not need to execute code to create problems; they can simply exhaust disk capacity or force the server to process very large files.

3. Detect file content, not just its name

PHP provides finfo_file() to read file type information from its content. This is better than trusting the value from the browser, but it is still not an absolute guarantee. For images, perform additional checks by attempting to read or process the image using the appropriate library.

Validation should also be appropriate to the needs. Files that will be displayed back in the browser need to be considered for risks related to content sniffing, XSS, and active formats like SVG. If the feature does not require SVG, it is safer not to allow it.

4. Create a new file name

Do not save files using the user's original name as the final name on the server. That name may contain unexpected characters, conflict with other files, or have traversal patterns like ../.

Use a random name generated by the application, then save the validated extension. The original name can be stored as metadata if it needs to be displayed to the user.

<?php
$allowed = ['image/jpeg', 'image/png'];
$maxSize = 2 * 1024 * 1024;

if ($_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
    throw new RuntimeException('Upload failed.');
}

if ($_FILES['avatar']['size'] > $maxSize) {
    throw new RuntimeException('File size too large.');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime  = $finfo->file($_FILES['avatar']['tmp_name']);

if (!in_array($mime, $allowed, true)) {
    throw new RuntimeException('File type not allowed.');
}

$extension = $mime === 'image/png' ? 'png' : 'jpg';
$filename  = bin2hex(random_bytes(16)) . '.' . $extension;
$target    = __DIR__ . '/private-uploads/' . $filename;

if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $target)) {
    throw new RuntimeException('File could not be saved.');
}
?>

This example does not cover all production needs, but it demonstrates an important pattern: check upload status, size, file type, use a random name, and then move the file with move_uploaded_file(). This function ensures the file source comes from the PHP HTTP upload mechanism, but the security of the storage destination remains the application's responsibility.

Where should files be stored?

The safest option is to store files outside of directories that can execute code. If possible, store them outside the web root and serve files through an endpoint that checks access rights first.

For files that must be public, ensure the upload directory does not allow script execution. Do not rely solely on renaming files. Web server configuration still needs to prevent uploaded files from being treated as code.

Files should also not be trusted directly for processing by other components. PDFs, images, archives, and Office documents can carry different risks. In systems with higher security needs, consider antivirus scanning or file analysis before files are made available to other users.

Special notes for WordPress

WordPress already provides an upload flow like wp_handle_upload() that performs several checks, including upload status, size, and file type. WordPress also provides the filter wp_handle_upload_prefilter to check or modify file data before it is moved.

However, plugins or themes can still open new, weaker pathways. Avoid rewriting the upload process if WordPress's built-in functions are sufficient. If you must create your own endpoint, use nonce checks, verify user capabilities with current_user_can(), limit file types, and do not change the allowed MIME list without clear justification.

Also, be cautious with plugins that accept ZIP, CSV, or import files. Archives can contain many files, unsafe directory paths, or extraction sizes that are much larger than the ZIP size. Do not extract archives directly without checking their contents and target directories.

Checklist to implement now

  • Define a list of extensions and MIME types that are truly necessary.
  • Reject files that exceed size limits, quantity, or user quotas.
  • Do not trust file names and Content-Type from the browser.
  • Detect file types from their content and perform specific checks for images or archives.
  • Use random file names generated by the application.
  • Store files outside the web root if they do not need to be public.
  • Disable script execution in the upload directory.
  • Ensure the upload endpoint has authentication, authorization, and CSRF protection.
  • Log who uploaded files, when, their types, and whether the checks were successful.
  • Test uploads with strange names, double extensions, empty files, oversized files, and incorrect file types.

What does this mean for us?

Upload security is not about finding one magical PHP function. It’s about gradually reducing trust: limit who can upload, accept only the files needed, validate their content and size, save with a new name, and place files in locations that are not easily executable.

For small websites, these basic steps already provide significant improvements. For applications that handle sensitive documents or public uploads, add file scanning, quotas, logging, and quarantine processes before files are published. Most importantly, treat every uploaded file as untrusted input until all checks are complete.

Sources & further reading

Explore also

– Rio Yotto @rioyotto