Home / Articles / Web Security
Web Security

File Upload Features Are More Than Just a Button: How to Make Them Safer in PHP and WordPress

The feature to upload photos, documents, or avatars is often considered simple, but it can be a gateway for malicious files to enter the server. Learn how to restrict file types, check their contents, rename files, and store them securely...

Fitur Upload File Bukan Sekadar Tombol: Cara Membuatnya Lebih Aman di PHP dan WordPress

The file upload feature seems straightforward: users select a file, press a button, and the server saves it. The problem is that the incoming files are not just "images" or "documents". They also carry a filename, size, MIME type, binary content, and potentially harmful payloads that the application must process.

If validation only checks for the .jpg extension or relies on the Content-Type from the browser, the protection is still thin. Files with seemingly safe names can contain different formats, be too large, or be intentionally crafted to exploit processing on the server. Therefore, upload security needs to be built in layers, rather than relying on a single check.

Why is file upload often a vulnerability?

The risks depend on what the application does after receiving the file. Files can be stored within the webroot and accessed directly via URL. If the server is misconfigured and allows certain files to be executed, an attacker may attempt to upload server scripts.

Another risk is not always server takeover. Large files can fill up storage space. Archives can expand many times when extracted. Documents or images processed by outdated libraries can trigger vulnerabilities in the parser. Publicly accessible files can also be used for phishing or storing malicious content.

OWASP File Upload Cheat Sheet recommends a defense-in-depth approach: restricting extensions, checking file types, renaming files, setting size limits, restricting user access, and choosing a secure storage location.

Start with an allowlist, not a denylist

If the feature only requires a profile picture, specify from the start the formats that are truly needed, such as JPEG, PNG, and WebP. Do not accept all file types and then try to block some harmful extensions. Denylists are almost always at risk of missing new variations or unconsidered formats.

Use an allowlist, which is a list of permitted types. For documents, it may only be PDF and DOCX. For internal attachments, it may not be necessary to accept ZIP archives at all, as archives are harder to check and can contain many types of files.

This validation must be done on the server. JavaScript checks in the browser only assist the user experience; those checks can be bypassed with custom requests.

Do not trust filenames and Content-Type

Filenames come from users. A name like report.pdf does not prove that its content is actually a PDF. Similarly, the Content-Type header can be spoofed by the client.

In PHP, finfo_file() can help read type information based on the file's content. However, MIME checks are not the only defense. Use a combination of allowed extension checks, maximum size, detected type, and specific validations according to the file format.

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

$allowed = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp'
];

if (!isset($allowed[$mime])) {
    throw new RuntimeException('File type not allowed');
}

This example is just a basic illustration. For images, additional processes such as opening and rewriting the image with the appropriate library can help remove unnecessary extra data. Keep image and document processing libraries updated as parsers can also have vulnerabilities.

Rename files before saving them

Do not use the original filename as the name on the server. That name may contain special characters, path traversal, double extensions, or overwrite existing files. Save files using a random name generated by the application, such as a UUID or the result of random_bytes().

The storage extension should also be determined from the validated type, not just copied from the original name. This way, a name like photo.php.jpg does not influence the storage decision.

$randomName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$destination = $uploadDir . DIRECTORY_SEPARATOR . $randomName;

if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $destination)) {
    throw new RuntimeException('File failed to save');
}

The move_uploaded_file() function ensures that the file source comes from the PHP HTTP upload process. However, this function does not automatically determine whether the file is safe. Type validation, size, user permissions, and destination location remain the application's responsibility.

Store outside the webroot if possible

The webroot is the directory that can be served directly by the web server. Storing uploaded files outside this location reduces the risk of files being accessed directly via URL or processed as code by the server.

If files need to be displayed to users, use an application endpoint that checks permissions first, then sends the file with the appropriate headers and names. Do not make the file path a direct input from users. Use an internal ID mapped to the file location on the server.

If storage within the webroot cannot be avoided, ensure that the directory does not allow script execution. Specific configurations depend on Apache, Nginx, hosting, and the types of files accepted, so testing needs to be done in an environment that resembles production.

Do not forget size limits and authorization

Limit file sizes at multiple layers: application validation, PHP configurations such as upload_max_filesize and post_max_size, web server, and storage services. Limits at the application level help provide clear messages; infrastructure limits help prevent large requests from overwhelming the server.

Uploads should also require appropriate authorization. Not all users need the ability to upload PDFs, change avatars, or send attachments to shared workspaces. Implement login checks, action permissions, and, where relevant, CSRF protection.

For systems that receive many documents, consider antivirus scanning or sandboxing before files are available to other users. This is not a substitute for basic validation but an additional layer to reduce risk.

What about WordPress?

WordPress already provides an upload flow that performs checks such as size, upload status, and file type. The documentation for the internal function _wp_handle_upload() shows that WordPress validates uploaded files and checks file types through related functions.

Problems often arise when plugins or themes override the standard flow, add extensions indiscriminately, or accept files through custom endpoints without checking user capabilities. Therefore, avoid copying raw upload code into plugins. Use the appropriate WordPress API, clearly restrict MIME types, and check current_user_can() before processing files.

Technical references can be found in the WordPress documentation on _wp_handle_upload(). If a plugin requests upload permissions for users with overly broad roles, it also needs to be reviewed.

Checklist to implement now

  • Determine the file types that are truly needed, then use an allowlist.
  • Validate on the server, not just through JavaScript.
  • Do not trust filenames and Content-Type headers from users.
  • Check size, type based on content, and if necessary, file signatures.
  • Rename files with random values generated by the application.
  • Store outside the webroot or disable script execution in the upload directory.
  • Limit who can upload and who can access the results.
  • Log upload activities and prepare a deletion process if files are indicated to be problematic.

In essence, file uploads are not just about transferring files from the browser to the server. They are a data entry point from external parties, so they need to be treated like public endpoints: restricted, checked, logged, and tested. With layered steps, the upload feature remains user-friendly without turning the server into a waiting ground for issues.

Sources & further reading

Explore also

– Rio Yotto @rioyotto