File uploads

File uploads allow web applications to transfer files efficiently and reliably, while preserving file content and metadata in a standardized way that works across browsers and servers.

Issues

The native PHP $_FILES superglobal has several limitations and pitfalls that developers often run into:

  • Awkward structure: When handling multiple files, $_FILES becomes a deeply nested array that is difficult to iterate over and easy to misuse.
  • Upload errors are easy to overlook: Failing to check $_FILES['error'] can result in processing incomplete or failed uploads.
  • Populated without uploads: $_FILES may contain entries even when no file was uploaded, typically with the UPLOAD_ERR_NO_FILE error code, which can be misleading if not handled explicitly.

To address these issues, a dedicated file upload component was introduced.

Prepare

To submit a file using an HTML form, ensure the form uses the POST method and the correct encoding type:

<form action="/upload" method="POST" enctype="multipart/form-data">
  <label for="document">Choose a document:</label>
  <input type="file" id="document" name="document">
  <button type="submit">Upload</button>
</form>

Alternatively, files can be sent via JavaScript using FormData:

const fileInput = document.getElementById("document");
const file = fileInput.files[0];

const formData = new FormData();
formData.append("file", file);

fetch("/upload", {
    method: "POST",
    body: formData
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));

Accessing uploaded file

To access uploaded files, call the getFiles() method on the request service from within your controller. This returns an instance of Krystal\Http\FileTransfer\FileEntity.

public function uploadAction()
{
    // Get file instance from input named "document"
    $file = $this->request->getFiles('document');

    if ($file) {
        $file->getName();            // Original filename
        $file->getUniqueName();      // Unique filename (preserves extension)
        $file->getType();            // Guessed MIME type
        $file->getMimeType();        // Verified MIME type (server-side check)
        $file->getTmpName();         // Temporary file path
        $file->getError();           // Native PHP upload error code
        $file->getSize();            // File size in bytes
        $file->getHumanSize();       // Formatted size (e.g., "200.00 KB")
        $file->isDangerous();        // Security check (deny-list + MIME)
        $file->getRealExtension();   // Detected type by magic bytes
        $file->isContentAllowed([]); // Whitelist content check
        $file->isExecutable();       // Executable binary check
        // ...
    }

    // ...
}

If no file was uploaded, getFiles() returns an empty array.

Regardless of input name or nesting depth, this method always returns a normalized and predictable collection of uploaded files, unlike the native $_FILES superglobal.

Check if file is dangerous

Always call $file->isDangerous() before moving or processing any uploaded file.

Why & How

$file->isDangerous() protects against common malicious uploads by checking:

File extension against a deny-list (.php, .htaccess, etc.) Real MIME type using magic bytes (prevents disguised scripts)

$file = $this->request->getFiles('avatar');

// Basic
if ($file && $file->isDangerous()) {
    // Dangerous file type detected!
}

// With extra extensions to verify
if ($file->isDangerous(['docm', 'xlsm', 'jar'])) { 
    // Dangerous file type detected!
}

// Only check extension (skip MIME verification)
if ($file->isDangerous([], false)) {
    // Dangerous file type detected!
}

Detect the real file type

getExtension() reads the file name and can be spoofed — an attacker can rename exploit.php to photo.jpg. getRealExtension() inspects the file's actual content (magic bytes) and returns the real type, regardless of the name.

$file = $this->request->getFiles('image');

if ($file) {
    $claimed = $file->getExtension();        // e.g. "jpg" — from the name
    $real = $file->getRealExtension();       // e.g. "php" — from the content

    if ($claimed !== $real) {
        // Extension spoofing detected — reject the upload
    }
}

Returns null if the type cannot be detected (rare for well-formed files).

Supported types include:

  • Images: jpg, jpeg, png, gif, bmp, webp, tiff, ico, psd, svg, heic, avif
  • Documents: pdf, ps, rtf, doc, xls, ppt
  • Archives: zip, docx, xlsx, pptx, odt, ods, rar, 7z, gz, bz2, tar
  • Audio / Video: mp3, flac, ogg, wav, mp4, avi, mkv, flv
  • Web / Data: wasm, sqlite
  • Executables: elf, macho, exe, class

Enforce allowed content

isContentAllowed() is the whitelist counterpart to isDangerous(). It returns true only if the real content matches one of the allowed extensions. This is the recommended way to validate uploads for a specific field (e.g. only images allowed for an avatar).

$file = $this->request->getFiles('avatar');

if ($file) {
    $allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];

    if (!$file->isContentAllowed($allowed)) {
        // Content does not match any of the allowed types — reject
    }
}

jpeg and jpg are treated as equivalent — both refer to the same underlying format. Case is normalized, so 'JPG' and 'jpg' behave identically.

Because FileEntity reuses a single cached detector, calling isContentAllowed() after getRealExtension() or isExecutable() reads the file only once.

Check for executable binaries

isExecutable() returns true if the file is a recognized executable binary: ELF (Linux/BSD), Mach-O (macOS), PE (Windows .exe), or Java class. Use it as an additional layer on top of extension checks — it catches executables even when renamed to .jpg.

$file = $this->request->getFiles('document');

if ($file && $file->isExecutable()) {
    // Reject — the file is an executable binary
}

Recommended validation order

For any upload, combine the checks in this order:

$file = $this->request->getFiles('image');

if ($file) {
    // 1. Deny-list: block known-bad extensions and dangerous MIME types
    if ($file->isDangerous()) {
        throw new RuntimeException('Dangerous file type');
    }

    // 2. Executable binaries: block ELF / Mach-O / PE / Java class
    if ($file->isExecutable()) {
        throw new RuntimeException('Executable files are not allowed');
    }

    // 3. Whitelist: only allow known-safe image formats
    if (!$file->isContentAllowed(['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
        throw new RuntimeException('File content does not match allowed image types');
    }

    // 4. Extension vs. content: detect spoofing
    $claimed = $file->getExtension();
    $real = $file->getRealExtension();

    if ($real !== null && $claimed !== $real && !($claimed === 'jpeg' && $real === 'jpg')) {
        throw new RuntimeException('Extension spoofing detected');
    }

    // 5. Move the file
    if (!$file->move($uploadDir)) {
        throw new RuntimeException('Failed to save the file');
    }
}

Each layer answers a different question:

Method Question
isDangerous() Is this a known-bad extension or MIME type?
isExecutable() Is this an executable binary?
isContentAllowed() Does the content match what we expect?
getRealExtension() Does the name lie about the content?

No single check is enough. Combining all four gives strong defense in depth.

Additional security notes

  • SVG is text-based XML and can carry embedded JavaScript. isContentAllowed(['svg']) does not sanitize it. If you accept SVG uploads, sanitize them with a dedicated library (e.g. enshrined/svg-sanitize) before serving, or store them outside the web root.
  • Images should be additionally validated with getimagesize() — it parses the full structure and catches polyglot files (a valid image with embedded code).
  • ZIP-based documents (docx, xlsx, pptx, odt, ods) share the same ZIP signature. getRealExtension() returns zip for all of them; it cannot distinguish between them. To verify a specific document type, inspect the archive's internal structure.
  • MP4 family (mp4, m4a, m4v, 3gp) shares the same ftyp container. getRealExtension() returns mp4 for all of them.
  • Store uploads outside the web root or in a directory with execution disabled. Even a validated image should not be directly executable by the server.

Uploading a file

To persist uploaded files, use the dedicated uploader component:

<?php

namespace Site\Controller;

use Krystal\Http\FileTransfer\FileUploader;
use Krystal\Application\Controller;

final class User extends AbstractController
{
    public function upload()
    {
        $files = $this->request->getFiles('files'); // If parameter provided, only from that input data is exacracted

        if ($files) { // If there's a file
            $destination = $this->appConfig->getUploadsDir(); // e.g /data/uploads/

            $uploader = new FileUploader();
            $uploader->upload($destination, $files); // Returns boolean value

            // Other methods
            $uploader->getUploadedFiles(); // Returns array of uploaded files
            $uploader->getFailedFiles(); // Returns array of failed files
        }
    }
}

Moving a single file

Instead of using the full FileUploader service, you can also move an individual file directly from the entity. By default, it will use the secure name generated by getUniqueName().

public function uploadAction()
{
    $file = $this->request->getFiles('document');

    if ($file) {
        $path = '/path/to/your/uploads';

        // Moves the file and returns a boolean
        if ($file->move($path)) {
            echo "File saved as: " . $file->getUniqueName();
        }
    }
}

Custom naming

If you prefer to keep the original filename (not recommended for public uploads) or provide a custom one, you can pass it as the second argument:

// Saves the file with its original name
$file->move($path, $file->getName());

// Saves the file with a custom name
$file->move($path, 'profile_v1.png');

Complete upload example

Putting it all together — a controller action that accepts only image uploads:

public function uploadAction()
{
    $file = $this->request->getFiles('image');

    if (!$file) {
        throw new RuntimeException('No file was uploaded');
    }

    // Layer 1: deny-list
    if ($file->isDangerous()) {
        throw new RuntimeException('Dangerous file type');
    }

    // Layer 2: executables
    if ($file->isExecutable()) {
        throw new RuntimeException('Executable files are not allowed');
    }

    // Layer 3: whitelist by content
    if (!$file->isContentAllowed(['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
        throw new RuntimeException('Only JPG, PNG, GIF and WEBP images are allowed');
    }

    // Layer 4: extension vs. content
    $claimed = $file->getExtension();
    $real = $file->getRealExtension();

    if ($real !== null && $claimed !== $real && !($claimed === 'jpeg' && $real === 'jpg')) {
        throw new RuntimeException('Extension spoofing detected');
    }

    // Layer 5: move to a non-executable directory
    $destination = $this->appConfig->getUploadsDir();

    if (!$file->move($destination)) {
        throw new RuntimeException('Failed to save the file');
    }

    return $this->json([
        'saved' => $file->getUniqueName(),
    ]);
}