Cyber Security

Secure File Uploads for Small PHP Applications — Keep Names, Types, Storage, and Delivery Under Control

Secure File Uploads for Small PHP Applications — Keep Names, Types, Storage, and Delivery Under Control

A file upload form looks like a small feature: receive a file, move it into a directory, and save its name. The difficult question begins one step later. Which parts of that file does the application actually know, and which parts are merely claims made by an untrusted client?

The safest answer is not to find one perfect test. It is to design a narrow path in which every decision stays under application control: who may upload, how large the request may be, which formats have a real purpose, how a stored object is named, where it lives, and how it can be retrieved. This article examines that path for a small PHP application. It does not claim that a short code sample can make arbitrary files safe.

An upload crosses more than one trust boundary

An uploaded object arrives with bytes and metadata. In PHP, $_FILES can include the original name, a temporary path, a size, an error code, and a media type. However, the PHP upload documentation explicitly says that the MIME type reported by the browser is not checked by PHP and should not be taken for granted. The submitted name and full path are client input too.

This distinction matters because a file can cause harm in several different ways. A server might execute it, an image or document parser might contain a vulnerability, active content might attack a reader, a very large object might exhaust storage, or a familiar filename might overwrite an existing object. CWE-434 describes the central weakness as allowing a dangerous file type that the receiving environment then processes.

The file itself is therefore only one part of the problem. The dangerous combination is content plus interpretation plus permission. A PHP file outside a public tree and never passed to an interpreter has a different risk from the same bytes placed under a URL where the web server executes them. Good upload design controls that context rather than trying to recognize every possible malicious byte sequence.

Start with the smallest useful contract

Before writing validation code, define what the feature actually needs. “Upload a file” is too broad. “An authenticated editor may upload one JPEG or PNG image up to 5 MiB for an article thumbnail” is a contract that can be enforced and reviewed.

A useful contract answers at least these questions:

  • Who may create, replace, read, and delete an object?
  • Which exact formats are required by the feature?
  • What are the maximum request, file, pixel, and storage sizes?
  • Will the server parse, resize, extract, or otherwise transform it?
  • Must the object be public, private, temporary, or retained?
  • What should happen when validation, storage, or later processing fails?

An allowlist follows from this contract. If a feature only needs JPEG and PNG, accepting SVG, HTML, PDF, ZIP, and every unknown binary type adds attack surface without adding value. The OWASP File Upload Cheat Sheet recommends allowing only extensions required by the business function. A denylist such as “reject .php” has to anticipate every dangerous type and every way the serving environment may interpret a name.

Reject transport failures before inspecting content

Upload handling should first establish whether PHP received a complete upload. PHP exposes status through the error field and the UPLOAD_ERR_* constants. Only UPLOAD_ERR_OK represents a successful transfer. A partial upload, missing temporary directory, or write failure is not a file to “try anyway”; it is a failed request.

Size limits should exist at several layers. A hidden MAX_FILE_SIZE form field may improve the user experience, but PHP's documentation warns that a client can change it. Server settings such as post_max_size and upload_max_filesize provide an earlier boundary, while application code still needs a feature-specific limit. The web server or reverse proxy may have its own request-body limit as well.

These limits do not replace one another. An infrastructure limit rejects obviously oversized traffic early; an application limit expresses what this particular feature accepts. If the application later decompresses or decodes content, the compressed byte count is not necessarily the relevant resource limit. Pixel dimensions, expanded archive size, processing time, and total storage quota may matter too.

Treat names and types as evidence, not truth

The original filename can be useful for display or audit records, but it should not decide the storage path. Removing directory components with basename() addresses only part of the problem. Names can collide, contain awkward characters, hide multiple extensions, or acquire special meaning in another tool. An application-generated identifier keeps the filesystem decision independent of user input.

Type checking also needs more than one signal. The browser-declared Content-Type is easy to spoof. PHP's Fileinfo extension can inspect a temporary file and return a MIME classification through finfo_file(). That is more useful than trusting the request header, but it is still classification, not a certificate that the content is harmless.

A practical policy can compare a small extension allowlist with a small content-derived MIME allowlist and reject mismatches. For an image workflow, decoding and rewriting the image with a maintained library may add another control. Even that does not prove universal safety: the decoder is software, metadata may have privacy implications, and resource limits still apply. Layered checks reduce uncertainty; they do not turn hostile input into trusted input by declaration.

Keep storage names and delivery under application control

OWASP and CWE-434 both recommend storing uploads outside the web document root when possible. This breaks the simplest route from “the server accepted these bytes” to “the web server will interpret these bytes at a public URL.” A separate host or object store can create a stronger boundary, but a small application can still gain a meaningful separation by using a non-public directory.

Private storage means downloads need a handler. That extra step is useful: the application can map an opaque ID to a storage name, check authorization, choose a safe response Content-Type, set Content-Disposition, and decide whether inline rendering is appropriate. The original name can be supplied as carefully encoded download metadata without becoming a filesystem path.

Upload permission and read permission are separate. A user allowed to submit a private document is not automatically allowed to retrieve every other user's document. Likewise, an editor allowed to upload a public thumbnail should not necessarily be able to replace any arbitrary media record. Authentication answers who the user is; authorization must still answer what that user may do to this specific object. State-changing upload requests also need the application's normal CSRF protection.

A deliberately narrow PHP example

The following example accepts one JPEG or PNG image, limits it to 5 MiB, derives its MIME type from the temporary file, creates a random storage name, and moves it to a directory outside the web root. The directory must already exist and be writable by the PHP worker. The example omits authentication, authorization, CSRF validation, database persistence, image decoding, quotas, and cleanup because those controls depend on the surrounding application.

<?php
declare(strict_types=1);

const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const UPLOAD_DIRECTORY = '/srv/example-app/uploads';

if (!isset($_FILES['image']) || !is_array($_FILES['image'])) {
    throw new RuntimeException('Missing upload.');
}

$upload = $_FILES['image'];

if (($upload['error'] ?? null) !== UPLOAD_ERR_OK) {
    throw new RuntimeException('The upload did not complete successfully.');
}

$temporaryPath = $upload['tmp_name'] ?? '';
$actualSize = is_string($temporaryPath) ? filesize($temporaryPath) : false;

if ($actualSize === false || $actualSize < 1 || $actualSize > MAX_UPLOAD_BYTES) {
    throw new RuntimeException('The file size is not allowed.');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($temporaryPath);
$allowedTypes = [
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
];

if (!is_string($mimeType) || !isset($allowedTypes[$mimeType])) {
    throw new RuntimeException('The file type is not allowed.');
}

$storageName = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mimeType];
$destination = UPLOAD_DIRECTORY . DIRECTORY_SEPARATOR . $storageName;

if (!move_uploaded_file($temporaryPath, $destination)) {
    throw new RuntimeException('The uploaded file could not be stored.');
}

// Persist the opaque storage name and validated MIME type, not a user path.

The official documentation says that move_uploaded_file() checks whether its source is a valid PHP HTTP POST upload. That is a useful provenance check, not content validation. It also warns that an existing destination is overwritten. A random 128-bit name makes an accidental collision extremely unlikely, but systems that require an absolute no-overwrite guarantee need storage semantics that create the destination exclusively rather than relying on probability.

The example also does not trust the client-reported size or type. It measures the temporary file and uses Fileinfo. For a real image-only feature, the next step may be to decode the image, enforce dimension and memory limits, discard unnecessary metadata, and write a newly encoded derivative. Whether that is appropriate depends on the required formats and the chosen image library.

Validation is only the beginning of the lifecycle

A file that passes admission checks can still create operational problems later. Parsers and converters need updates, timeouts, memory limits, and isolation appropriate to their risk. Archive extraction needs limits on expanded content and paths. Malware scanning may add useful evidence, but a clean result is not proof that a file is benign. Sending private uploads to a third-party scanning service can itself disclose data.

Storage also needs lifecycle rules. Failed database writes should not leave permanent orphan files. Replaced objects should eventually be deleted. Logs should record an object identifier, actor, result, and rejection reason without dumping private content or unsafe names into an interface. Backups and replicas must preserve the same access expectations as primary storage. Public download endpoints may need rate limits because small requests can trigger large responses.

Least privilege is the final boundary rather than a substitute for validation. The PHP worker should have only the filesystem access it needs. An upload directory should not become an executable script directory. A processing worker can be separated from the public application when the formats or transformations justify it. These controls limit impact when an earlier assumption proves wrong.

A review checklist

  • The feature defines exact users, formats, limits, transformations, visibility, and retention.
  • Infrastructure and application limits reject oversized or incomplete requests.
  • Server code checks UPLOAD_ERR_OK before processing the temporary file.
  • The original name and browser-declared MIME type never determine trust or a storage path.
  • A small extension and content-type allowlist follows the feature's actual needs.
  • The application generates an opaque storage name and handles collision policy deliberately.
  • Uploads are outside the web root or served from a boundary that cannot execute them.
  • Upload, replace, read, and delete actions each have authorization checks.
  • CSRF, quotas, parsing limits, cleanup, logging, backups, and incident removal are covered.
  • The team knows what each check cannot establish.

Conclusion

A secure upload pipeline is less like checking an attachment at a door and more like maintaining custody of an unknown package. The label is not the contents, passing one inspection does not authorize every later use, and the storage room should not double as an execution environment.

For a small PHP application, the most valuable move is usually to narrow the contract: accept fewer types, impose explicit limits, generate names, store outside the public tree, and mediate access. MIME detection, image rewriting, scanning, and sandboxing can add layers where the threat model justifies them. None deserves to be called a universal “safe file” test. The honest goal is controlled handling with bounded consequences when one layer fails.

References