Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Mime Type Validation

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Unless the authors that can upload these files are super, super trusted, like, you invited them to your wedding and they babysit your dog when you're on vacation level of trusted... we need some validation. Right now, an author could upload literally any file type to the system.

No problem: find the controller. Hmm, there's no form here. In ArticleAdminController, we put the validation on the form. Then we could check $form->isValid() and any errors rendered automatically.

Manually Validating

But because we're not inside a form, we need to validate directly... which is totally fine! Add another argument: ValidatorInterface $validator. This is the service that the form system uses internally for validation.

... lines 1 - 13
use Symfony\Component\Validator\Validator\ValidatorInterface;
... line 15
class ArticleReferenceAdminController extends BaseController
{
... lines 18 - 21
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 24 - 50
}
}

Then, before we do anything with that uploaded file, say $violations = $validator->validate(). Pass this the object that you want to validate. For us, it's the $uploadedFile object itself. If we stopped here, it would read any validation annotations off of that class and apply those rules... which would be zero rules! This is a core class! There's no validation rules, and we can't just open up that file and add them. No worries: pass a second argument: the constraint to validate against.

... lines 1 - 15
class ArticleReferenceAdminController extends BaseController
{
... lines 18 - 21
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 24 - 26
$violations = $validator->validate(
$uploadedFile,
... lines 29 - 31
);
... lines 33 - 50
}
}

Remember: there are two main constraints for uploads: the Image constraint that we used before and the more generic File constraint, which we need here because the user can upload more than just images. Say new File() - the one from the Validator component.

... lines 1 - 12
use Symfony\Component\Validator\Constraints\File;
... lines 14 - 15
class ArticleReferenceAdminController extends BaseController
{
... lines 18 - 21
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 24 - 26
$violations = $validator->validate(
$uploadedFile,
new File([
... line 30
])
);
... lines 33 - 50
}
}

This constraint has two main options. The first is maxSize. Set it to 1k... just so we can see the error.

... lines 1 - 15
class ArticleReferenceAdminController extends BaseController
{
... lines 18 - 21
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 24 - 26
$violations = $validator->validate(
$uploadedFile,
new File([
'maxSize' => '1k'
])
);
... lines 33 - 50
}
}

This $violations variable is basically an array of errors... except it's not actually an array - it's an object that holds errors. To check if anything failed validation, we can say if $violations->count() is greater than 0. For now, let's just dd($violations) so we can see what it looks like.

... lines 1 - 15
class ArticleReferenceAdminController extends BaseController
{
... lines 18 - 21
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 24 - 26
$violations = $validator->validate(
... lines 28 - 31
);
if ($violations->count() > 0) {
dd($violations);
}
... lines 37 - 50
}
}

Cool! Move over, select the Best Practices PDF - that's definitely more than 1kb - and upload! Say hello to the ConstraintViolationList: a glorified array of ConstraintViolation error objects. And there's the message: the file is too large. If you want, you can customize that message by passing the maxSizeMessage option... cause it is kind of a nerdy message.

Displaying the Validation Errors

So, in theory, you can have multiple validation rules and multiple errors. To keep things simple, let's show the first error if there is one. Use $violation = $violations[0] to get it. The ConstraintViolationList class implements ArrayAccess, which is why we can use this syntax. Oh, and let's help out my editor by telling it that this is a ConstraintViolation object.

... lines 1 - 13
use Symfony\Component\Validator\ConstraintViolation;
... lines 15 - 16
class ArticleReferenceAdminController extends BaseController
{
... lines 19 - 22
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 25 - 34
if ($violations->count() > 0) {
/** @var ConstraintViolation $violation */
$violation = $violations[0];
... lines 38 - 42
}
... lines 44 - 57
}
}

And now... hmm... how should we show this error to the user? This controller will eventually turn into an AJAX, or API endpoint that communicates via JSON. But because this is still a normal form submit, the easiest option is to put the error into a flash message and display it on the next page. Say $this->addFlash(), pass it an "error" type, and then $violation->getMessage().

... lines 1 - 16
class ArticleReferenceAdminController extends BaseController
{
... lines 19 - 22
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 25 - 34
if ($violations->count() > 0) {
/** @var ConstraintViolation $violation */
$violation = $violations[0];
$this->addFlash('error', $violation->getMessage());
... lines 39 - 42
}
... lines 44 - 57
}
}

Finish by stealing the redirect code from the bottom to send us back to the edit page.

... lines 1 - 16
class ArticleReferenceAdminController extends BaseController
{
... lines 19 - 22
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 25 - 34
if ($violations->count() > 0) {
/** @var ConstraintViolation $violation */
$violation = $violations[0];
$this->addFlash('error', $violation->getMessage());
return $this->redirectToRoute('admin_article_edit', [
'id' => $article->getId(),
]);
}
... lines 44 - 57
}
}

To render that flash message, open templates/base.html.twig and scroll down... I'm looking for the flash message logic we added in our Symfony series. There it is! We're rendering success messages, but we don't have anything to render error messages. Copy this, paste, and loop over error. Make it look scary with alert-danger.

... line 1
<html lang="en">
... lines 3 - 15
<body>
... lines 17 - 73
{% for message in app.flashes('error') %}
<div class="alert alert-danger">
{{ message }}
</div>
{% endfor %}
... lines 79 - 102
</body>
</html>

Cool! Test it out - refresh! And... nice! It redirects and there is our error.

Validating the Mime Types

This is great... but what we really want to do is control the types of files that are uploaded. Change the max size to 5m and add a mimeTypes option set to an array.

Tip

To allow files larger than 2MB, you'll probably need to tweak the upload_max_filesize setting in your php.ini file. Then, don't forget to restart your web server!

... lines 1 - 16
class ArticleReferenceAdminController extends BaseController
{
... lines 19 - 22
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 25 - 27
$violations = $validator->validate(
$uploadedFile,
new File([
'maxSize' => '5M',
'mimeTypes' => [
... lines 33 - 39
]
])
);
... lines 43 - 66
}
}

Let's see... what do we want to allow? Well, probably any image is ok - so we can use image/* and definitely we should allow application/pdf.

... lines 1 - 16
class ArticleReferenceAdminController extends BaseController
{
... lines 19 - 22
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 25 - 27
$violations = $validator->validate(
$uploadedFile,
new File([
'maxSize' => '5M',
'mimeTypes' => [
'image/*',
'application/pdf',
... lines 35 - 39
]
])
);
... lines 43 - 66
}
}

But... what else? It's tricky: there are a lot of mime types out there. A nice way to cheat is to press Shift+Shift and look for a core class called MimeTypeExtensionGuesser.

This is a pretty neat class: it's what Symfony uses behind the scenes to "guess" the correct file extension based on the mime type of a file. It's useful right now because it has a huge list of mime types and their extensions. Check it out: search for 'doc'. There it is: application/msword. And if you keep digging for other things like docx or xls, you can get a pretty good list of stuff you might want to accept.

Close this file and go back to the option: I'll paste in a few mime types. This covers a lot your standard "document" stuff. Oh, I forgot one! Add application/vnd.ms-excel.

... lines 1 - 16
class ArticleReferenceAdminController extends BaseController
{
... lines 19 - 22
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 25 - 27
$violations = $validator->validate(
$uploadedFile,
new File([
'maxSize' => '5M',
'mimeTypes' => [
'image/*',
'application/pdf',
'application/msword',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain'
]
])
);
... lines 44 - 67
}
}

Let's try it out! Go back, select the Best Practices PDF, Upload and... no error! Try it again - but with this earth.zip file - that's a zip of two photos. Submit and... error! But wow is that a wordy error. You an change that message with the mimeTypesMessage option.

Requiring the File

Oh! There's one last case we need to validate for. Hit enter on the URL to refresh the form. Do nothing and hit upload. Ah!!! Whoops! Everything explodes inside UploaderHelper... because there is no uploaded file! The horror!

Back in the controller, the second argument to validate() can accept an array of validation constraints. Put the new File into an array. Then add: new NotBlank() with a custom message: please select a file to upload.

... lines 1 - 13
use Symfony\Component\Validator\Constraints\NotBlank;
... lines 15 - 17
class ArticleReferenceAdminController extends BaseController
{
... lines 20 - 23
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator)
{
... lines 26 - 28
$violations = $validator->validate(
$uploadedFile,
[
new NotBlank(),
new File([
... lines 34 - 43
])
]
);
... lines 47 - 70
}
}

Refresh one more time. The huge error is replaced by a much more pleasant validation message.

Next: the author can upload a file reference... but it is literally impossible for them to download it. How can we make these private files accessible, but still check security first?

Leave a comment!

10
Login or Register to join the conversation
Default user avatar
Default user avatar Issa Dicko | posted 3 years ago

this article has been very useful for me! thank you so much

1 Reply
JavierMendezK Avatar
JavierMendezK Avatar JavierMendezK | posted 1 year ago

How can I force a custom mime type for my file uploader? I am trying to upload .usdz files and the uploader converts them to .zip
If I use this:
'constraints' => [
new File([
'mimeTypes' => [
'model/vnd.usdz+zip',
'model/vnd.pixar.usd',
'model/usd',
'model/usdz'
],
'mimeTypesMessage' => 'Please upload a valid .usdz 3D file.',
])
]
in my FormType it will not let me upload the file, and if I do not use a constraint it will just convert it to .zip.
Thank you for the help.

Reply

Hey Javier Mendez!

Excellent question! The validator logic for the File validator is in this section - https://github.com/symfony/...

I can't remember for certain, but my guess is that you will end up on this line: https://github.com/symfony/...


$mime = MimeTypes::getDefault()->guessMimeType($path);

Internally, THAT class uses, out-of-the-box, 2 "guessers" to guess the extension: https://github.com/symfony/...

My guess is that one of those two is incorrectly guessing the mime type - it just probably isn't aware of the .usdz extension.

Fortunately, you can create your *own* mime type guesser - create a class, make it implement MimeTypeGuesserInterface, and fill in the logic. Symfony should automatically see this and start using it - you can add custom logic to recognize your .usdz files. But let me know if your new class *isn't* immediately used... there's one "path way" in the code that should be there in the core of Symfony to hook this all up, I'm just not seeing it. If your class isn't used, try autowiring a MimeTypesInterface argument into whatever controller or service you're working inside of. You don't even need to USE that class... but in case the path way *is* missing in Symfony, just autowiring this, I think, will activate it.

Cheers!

Reply
JavierMendezK Avatar
JavierMendezK Avatar JavierMendezK | weaverryan | posted 1 year ago

So why do I need to have my own guesser if MimeTypes.php already includes model/vnd.pixar.usd as a valid MimeType ?

I followed https://symfony.com/index.p... as your recommendation but I guess I am missing the "logic" in:
public function guessMimeType(string $path): ?string
{
// inspect the contents of the file stored in $path to guess its
// type and return a valid MIME type ... or null if unknown

return '...';
}
to implement support for my own extension.

Reply

Hey Javier Mendez !

> So why do I need to have my own guesser if MimeTypes.php already includes model/vnd.pixar.usd as a valid MimeType ?

Hmm, where are you seeing this? I'm not seeing it on this list for Symfony 5.4 (next release) or 5.3 - https://github.com/symfony/... - do you have model/vnd.pixar.usd on your list?

> but I guess I am missing the "logic" in:

This is a fair point :). I was hoping that you knew something about these files - like you could open up just the first line of the file using fopen and look for something that distinguishes these files :). But, if you really do have model/vnd.pixar.usd in your mime type list, then we should dig deeper and see *why* the type is being seen as a zip. I would put some debug code into the 2 core "guessers" - https://github.com/symfony/... and https://github.com/symfony/... to figure out which one of these is incorrectly determining the type. That might shed some light on the underlying problem.

Cheers!

Reply
JavierMendezK Avatar
JavierMendezK Avatar JavierMendezK | weaverryan | posted 1 year ago

The line is in the file "vendor/symfony/mime/MimeTypes.php" line 1482

Reply

Hey Javier Mendez!

Ah, cool! What version of symfony/mime are you using? I can't find any mention of model/vnd.pixar.usd in any version of Symfony that I'm checking... but I believe you that it's there.

Regardless, I would add some debugging code to those two classes I mentioned above to see which one is returning the incorrect result. That may give us a clue to the problem and the fix.

Cheers!

Reply
JavierMendezK Avatar
JavierMendezK Avatar JavierMendezK | weaverryan | posted 1 year ago

It is on the link you posted on line 1537:
https://github.com/symfony/...
and I am using 5.2.14

Reply

Ooooooh! I was looking for model/vnd.pixar.usd - for some reason THAT one had gotten stuck in my head.

The point remains though - I think you'll need to put some debugging code on those 2 mime type guesser classes to see what's going wrong. Or, if you want, you could make your custom guesser just look for the .usdz file extension. Just make sure that, if you do this, that file is used in a "safe" way - e.g. if it actually is a file that contains PHP code, don't eval() it ;). That's a joke - but you get the idea: you'll be validating the filename, but the contents could, in theory, be anthing.

Btw, part of the problem it's coming back as a .zip is that, from my reading, these USDZ files *are* zip files... just with a special structure inside. So... that's tricky!

Cheers!

Reply
JavierMendezK Avatar
JavierMendezK Avatar JavierMendezK | weaverryan | posted 1 year ago

Thanks.

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on Symfony 4 but works great in Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.87.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.9.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.0.3
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.4
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.2.3
        "symfony/console": "^4.0", // v4.2.3
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.2.3
        "symfony/framework-bundle": "^4.0", // v4.2.3
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.2.3
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.2.3
        "symfony/validator": "^4.0", // v4.2.3
        "symfony/web-server-bundle": "^4.0", // v4.2.3
        "symfony/yaml": "^4.0", // v4.2.3
        "twig/extensions": "^1.5" // v1.5.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
        "symfony/dotenv": "^4.0", // v4.2.3
        "symfony/maker-bundle": "^1.0", // v1.11.3
        "symfony/monolog-bundle": "^3.0", // v3.3.1
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.2.3
    }
}