Skip to content
Ende Sun
Back to blog

DevOps & security

Secure image uploads in a Laravel and Filament admin panel

4 min read

Secure image uploads in a Laravel and Filament admin panel

Uploading an image from an admin panel feels like the most harmless feature there is. It is also one of the easiest ways to let something into your server that should never be there. When I added image support to the blog on this site (a cover per article and images inside the Markdown body), I treated it as a security feature first and a content feature second. These are the layers I use, all of them small, none of them sufficient on its own.

1. Accept only the formats you actually need

The first decision is what counts as an image. Filament's FileUpload::image() accepts anything with an image/* MIME type, and that includes SVG. For a blog, I need photographs and screenshots, so the whitelist is explicit:

public const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

FileUpload::make("{$column}_upload")
    ->image()
    ->acceptedFileTypes(self::ACCEPTED_TYPES)
    ->disk('public')
    ->directory($directory)
    ->visibility('public')
    ->maxSize($maxSizeKb);

Why no SVG? An SVG is not a bitmap; it is an XML document, and it can contain <script> elements and event handlers. If someone opens an uploaded SVG directly from your domain, that script runs in your origin, with access to whatever the page can reach. Sanitising SVGs is possible, but for a portfolio blog the simplest safe answer is not to accept them at all. Illustrations I draw myself ship with the code, not through the upload form.

The same rule applies to images uploaded inside the Markdown editor. Filament lets you configure its attachments separately, so they need the same whitelist and size limit, or the editor becomes a side door:

MarkdownEditor::make($name)
    ->fileAttachmentsDisk('public')
    ->fileAttachmentsDirectory('images/blog/content')
    ->fileAttachmentsAcceptedFileTypes(ImageUploadFields::ACCEPTED_TYPES)
    ->fileAttachmentsMaxSize(4096);

I moved the whitelist into the shared helper used by every upload field in the panel (projects, skills, contact links and blog), so tightening it once tightens it everywhere.

2. Limit the size

A size limit is a guard against both accidents and abuse: an uncompressed 40 MB photo slows down every page that shows it, and unbounded uploads fill a disk. Covers and inline images accept up to 4 MB; smaller assets such as skill icons keep a lower limit. Validation runs on the server, not just in the browser widget.

3. Never trust the original filename

Filament stores uploads with Laravel's hashName(), which generates a random name and derives the extension from the detected content type rather than from what the user typed. That removes two classes of problems at once: path tricks hidden in a filename, and files named like photo.png.php that hope a misconfigured server will execute them. It also means a URL to an uploaded file reveals nothing about the original.

The page classes then store the full public URL in the model, so the views just print a URL and never build paths from user input:

protected function storeUploadedImageUrl(array $data, string $column): array
{
    $upload = Arr::pull($data, "{$column}_upload");

    if (filled($upload)) {
        $data[$column] = asset('storage/'.$upload);
    }

    return $data;
}

4. Make the upload folder unable to run code

Even with all the checks above, I assume one day something unexpected will land in the uploads folder. So the folder itself must refuse to execute anything. With Apache, a .htaccess inside the public storage directory does it:

<FilesMatch "\.(php\d?|phtml|phar|pht|phps|cgi|pl|py|sh|asp|aspx|jsp)$">
    Require all denied
</FilesMatch>

<IfModule mod_mime.c>
    RemoveHandler .php .php3 .php4 .php5 .php7 .php8 .phtml .phar
    RemoveType .php .php3 .php4 .php5 .php7 .php8 .phtml .phar
</IfModule>

This is defence in depth: validation decides what may be stored, and the web server decides that, whatever is stored there, it is only ever served as a static file. If your server is Nginx, the equivalent is a location block for the storage path that never passes requests to PHP.

5. Render user content without letting HTML through

Images inside articles arrive as Markdown, for example ![Architecture](/storage/images/blog/content/x.png). Rendering Markdown is where a second class of problems hides: Markdown allows raw HTML by default, and links can carry javascript: URLs. The renderer is configured so neither survives:

$html = Str::markdown($markdown, [
    'html_input' => 'strip',
    'allow_unsafe_links' => false,
]);

With html_input set to strip, an <img src=x onerror=...> typed into the editor disappears instead of being output. With allow_unsafe_links disabled, an image or link pointing to javascript: is neutralised. Markdown image syntax still produces a normal <img> element, so authors lose nothing they actually need.

6. Prove it with tests

None of these layers is worth much if a future refactor can silently remove it. The admin tests upload real files through the Filament form and assert both the happy path and the rejections:

public static function forbiddenCoverFiles(): array
{
    return [
        'svg with script' => ['evil.svg', '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'],
        'php' => ['1.php', '<?php system($_GET["c"]);'],
    ];
}

#[DataProvider('forbiddenCoverFiles')]
public function test_blog_post_cover_rejects_svg_and_scripts(string $name, string $content): void
{
    Livewire::test(CreateBlogPost::class)
        ->fillForm([/* ... */ 'cover_image_upload' => UploadedFile::fake()->createWithContent($name, $content)])
        ->call('create')
        ->assertHasFormErrors(['cover_image_upload']);

    $this->assertEmpty(Storage::disk('public')->allFiles('images/blog/covers'));
}

The valid case checks that a PNG is stored under a random name (the original filename must not appear in the stored URL) and that the file exists on the public disk. A separate test renders an article containing a Markdown image, a raw <img onerror> and a javascript: image, and asserts that only the first one reaches the HTML.

The checklist

  • Explicit MIME whitelist; no SVG unless you sanitise it.
  • The same rules for every upload path, including editor attachments.
  • Server-side size limits.
  • Random, content-derived filenames.
  • An upload directory that cannot execute code.
  • Markdown rendered with raw HTML stripped and unsafe links disabled.
  • Tests for the rejections, not just for the happy path.

Each item is a few lines of code. Together they turn "upload an image" from a quiet risk into a feature I do not have to think about every time I write a post.