Secure File Upload

Secure file upload handling in Canvas uses a layered validation pipeline — extension checks, MIME type verification, content inspection, and optional virus scanning — before files reach your controller. Validated files are moved to secure storage outside the web root with randomized filenames and strict permissions.

explanation

File Upload Risks Explained

The Risk: File upload forms are one of the most dangerous surfaces in a web application. Without proper validation, attackers can upload PHP files disguised as images, exploit path traversal in filenames, exhaust disk space with oversized files, or plant content that executes when another user requests it.

Why Simple Extension Checks Are Not Enough: An attacker can rename shell.php to shell.jpg and bypass naive checks entirely. Effective protection requires verifying the actual file content, not just the name the client provided.

How Canvas Secure Upload Protection Works

Canvas runs every uploaded file through a validation pipeline before your controller sees it. Unlike a short-circuit pipeline, all checks run for every file — you get a complete list of every problem, not just the first one found. If any check fails, the file is never written to permanent storage.

  1. The PHP upload error code is checked first, catching partial uploads and missing temp directories. If this fails, no further checks run for that file
  2. Canvas validates the file size against the configured maximum
  3. Canvas checks the client-supplied extension against the allowed extensions whitelist
  4. Canvas checks the server-detected MIME type against the allowed MIME types whitelist
  5. Canvas cross-checks the extension and MIME type against each other to catch renamed files
  6. Canvas inspects the filename for directory traversal characters and dangerous compound extensions like shell.php.jpg
  7. For image files, getimagesize() verifies the file is a real image whose content matches its declared type
  8. For image files, Canvas checks pixel dimensions against the configured maximum
  9. If virus scanning is enabled and ClamAV is available, the file is scanned. If ClamAV cannot be reached, a warning is recorded and the upload proceeds
  10. The file is moved from the PHP temporary directory to permanent storage with a randomized filename and 0644 permissions

Results for every file are placed in the request attributes under uploaded_files. Validation is all-or-nothing at the batch level: if any file fails, nothing is written to permanent storage and every record in uploaded_files will have success=false. Use throwOnFailure=true to throw a UploadException on any failure instead, letting Canvas's exception handler deal with the response.

Basic Usage

Add @InterceptWith(SecureUploadAspect::class) to any controller method that handles file uploads. The aspect runs the full validation pipeline and stores files before your method executes. How you handle the outcome depends on whether you want to inspect results yourself or have failures handled automatically.

Standard Mode

Your controller always executes. All files are validated first; if every file passes, they are all moved to permanent storage. If any file fails, nothing is stored — all records in uploaded_files will have success=false. Records for files that passed validation will have an empty errors array; only the failing files carry error messages:

/**
 * @Route("/media/upload", methods={"POST"})
 * @InterceptWith(SecureUploadAspect::class)
 */
public function upload(Request $request) {
    $uploadedFiles = $request->attributes->get('uploaded_files');

    foreach ($uploadedFiles['avatar'] as $file) {
        if (!$file['success']) {
            // Validation errors are in $file['errors']
            continue;
        }

        $this->mediaService->register($file);
    }

    return $this->redirect('/media/library');
}

Three request attributes report the outcome at the batch level:

  • upload_successfultrue only if every file passed all checks and was stored
  • upload_batch_error — non-null when the entire batch was rejected before processing (e.g. too many files); null when failures are per-file
  • upload_warnings — aggregated warnings across all files (e.g. "Virus scan skipped: ClamAV not found")

Exception Mode

Add throwOnFailure=true to throw an UploadException on any validation failure instead of writing results to request attributes and continuing. Your controller method is never called when validation fails — Canvas's exception handler takes over:

/**
 * @Route("/media/upload", methods={"POST"})
 * @InterceptWith(SecureUploadAspect::class, throwOnFailure=true)
 */
public function upload(Request $request) {
    // Only reached when all files passed validation
    foreach ($request->attributes->get('uploaded_files')['avatar'] as $file) {
        $this->mediaService->register($file);
    }

    return $this->redirect('/media/library');
}

The thrown UploadException carries the full per-file result set and the batch-level error so your exception handler can surface granular error information without re-running validation:

use Quellabs\Canvas\Security\Exceptions\UploadException;

// In your exception handler:
if ($e instanceof UploadException) {
    $batchError = $e->getBatchError();       // string|null — batch-level rejection reason
    $processedFiles = $e->getProcessedFiles(); // per-field, per-file result arrays
}

Note that request attributes are still populated before the exception is thrown, so the same information is also available via $request->attributes if your handler has access to the request.

When to use exception mode:

  • API endpoints or simple upload forms where a generic error response is acceptable
  • Situations where you want to guarantee the controller never runs on failure
  • When you have a central exception handler that already formats error responses consistently

Security Measures

  • Storage outside web root: The default path storage/uploads/ is outside the publicly accessible web root, so stored files cannot be requested directly through the browser. This is the primary execution prevention layer and works on all web servers.
  • Script execution prevention (Apache only): Canvas writes an .htaccess file into each storage directory that removes PHP and other script handler associations and explicitly denies access to script file extensions. This only works on Apache with AllowOverride enabled — it provides no protection on Nginx, Caddy, Swoole, RoadRunner, or any other server. Do not rely on it as your sole protection.
  • File permissions: Stored files are set to 0644 (owner read/write, others read-only), which prevents execution at the filesystem level on most server configurations. On filesystems where chmod is not supported, a warning is recorded in upload_warnings and the upload still succeeds.
  • Content verification for images: PHP's getimagesize() inspects the actual binary content of the file, not its extension or MIME type header. A file that does not parse as a valid image is rejected even if its extension and MIME type appear legitimate. Non-image formats are not subject to this check.
  • MIME type matching: Extension-to-MIME matching accounts for real-world platform variation. JPEG files may be reported as image/jpeg or image/pjpeg; DOCX files may be detected as application/zip since the format is a ZIP container. The MIME map accommodates these legitimate variations while still rejecting content that does not match the declared extension.
  • Compound extension detection: Filenames like shell.php.jpg are caught by scanning every dot-separated segment of the filename for dangerous extensions, not just the final one.
  • Directory traversal prevention: Filenames containing .., /, or \ are rejected before any path is constructed.
  • Randomized filenames: Stored filenames are 32-character cryptographically random hex strings by default. This prevents collisions, makes stored files non-guessable, and ensures the extension is always lowercase regardless of what the client sent.

Configuration Parameters

Parameter Type Default Description
uploadPath string "storage/uploads" Base directory for stored files, relative to application root
allowedExtensions array jpg, jpeg, png, gif, pdf, doc, docx Whitelist of permitted file extensions (case-insensitive)
allowedMimeTypes array JPEG, PNG, GIF, PDF, Word Whitelist of permitted server-detected MIME types
maxFileSize int 5242880 (5MB) Maximum file size in bytes per file
maxFiles int 5 Maximum total number of files per request. Exceeding this rejects the entire batch before any file is processed
virusScan bool false Scan files with ClamAV before storage. If ClamAV is unavailable, a warning is recorded and the upload proceeds
maxImageWidth int 2048 Maximum pixel width for image files
maxImageHeight int 2048 Maximum pixel height for image files
randomizeFilenames bool true Generate cryptographically random storage filenames. The stored extension is always lowercase regardless of this setting
directoryStructure string "Y/m/d" PHP date() format string for subdirectory structure. Empty string disables subdirectories
throwOnFailure bool false Throw an UploadException on any validation failure instead of writing results to request attributes and continuing to the controller. The exception carries the full per-file result set

File Record Fields

Each entry in uploaded_files is an associative array. Fields marked success only are absent on failed records; all other fields are always present:

Field Type Description
success bool true if the file passed all validation and was stored successfully
errors string[] All validation error messages for this file. Empty on success
original_name string The filename as supplied by the client
filename string The actual filename on disk (randomized or sanitized, always lowercase extension). Success only
path string Full absolute path to the stored file. Success only
relative_path string Path relative to the application root. Success only
size int File size in bytes. Success only
mime_type string Server-detected MIME type. Success only
extension string Lowercased file extension. Success only
uploaded_at string Timestamp of successful storage in Y-m-d H:i:s format. Success only