| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/client-portal-laravel/releases/20260626120707/app/Rules/ |
Upload File : |
<?php
declare(strict_types=1);
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\UploadedFile;
/**
* Validates that a file's content (magic bytes) matches its declared MIME type.
*
* This prevents attacks where a malicious file (e.g., PHP script) is renamed
* with a safe extension (e.g., .pdf) to bypass MIME type checks.
*/
class ValidFileContent implements ValidationRule
{
/**
* Magic byte signatures for supported file types.
*
* @var array<string, array<string>>
*/
private const SIGNATURES = [
'application/pdf' => ['%PDF'],
'image/png' => ["\x89PNG"],
'image/jpeg' => ["\xFF\xD8\xFF"],
'image/gif' => ['GIF87a', 'GIF89a'],
];
/**
* Map file extensions to expected MIME types for signature lookup.
*
* @var array<string, string>
*/
private const EXTENSION_MAP = [
'pdf' => 'application/pdf',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
];
/**
* Run the validation rule.
*
* Uses the client-declared MIME type (from the upload) to determine which
* magic bytes to expect. This catches spoofed files where a malicious file
* is renamed with a safe extension to bypass MIME type checks.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $value instanceof UploadedFile) {
return;
}
// Use client-declared MIME type to catch spoofing (where the attacker
// claims a file is PDF/image but the content is actually malicious)
$mimeType = $this->resolveExpectedMimeType($value);
// Only validate file types we have signatures for
if (! isset(self::SIGNATURES[$mimeType])) {
return;
}
$path = $value->getRealPath();
if (! $path || ! is_readable($path)) {
$fail('The :attribute could not be read for content validation.');
return;
}
// Read the first 8 bytes for magic byte comparison
$handle = $this->openFile($path);
if (! $handle) {
$fail('The :attribute could not be read for content validation.');
return;
}
$header = fread($handle, 8);
fclose($handle);
if ($header === false || $header === '') {
$fail('The :attribute appears to be empty.');
return;
}
$signatures = self::SIGNATURES[$mimeType];
$matches = false;
foreach ($signatures as $signature) {
if (str_starts_with($header, $signature)) {
$matches = true;
break;
}
}
if (! $matches) {
$fail('The :attribute content does not match its file type. The file may be corrupted or spoofed.');
}
}
/**
* Resolve the expected MIME type from the client-declared type or extension.
*
* Prefers the client MIME type, falls back to extension mapping.
*/
private function resolveExpectedMimeType(UploadedFile $file): ?string
{
$clientMime = $file->getClientMimeType();
if ($clientMime && isset(self::SIGNATURES[$clientMime])) {
return $clientMime;
}
// Fall back to extension-based lookup
$extension = strtolower($file->getClientOriginalExtension());
if (isset(self::EXTENSION_MAP[$extension])) {
return self::EXTENSION_MAP[$extension];
}
return null;
}
/**
* Open a file for reading. Extracted for testability.
*
* @return resource|false
*/
protected function openFile(string $path)
{
return fopen($path, 'rb');
}
}