CSRF and File Uploads
The CSRF token forms add for you, and how to accept a file upload, validate it, and store it outside the web root.
$builder->add('brochure', FileType::class, ['mapped' => false, 'constraints' => [new Assert\File(maxSize: '2M', extensions: ['pdf'])]]);Two things every form that writes data has to get right: proving the submission came from your own page, and handling uploaded files safely.
CSRF
Cross-site request forgery is an attack in which another site causes a visitor's browser to send a request to yours. The browser attaches its cookies automatically, so the request arrives looking authentic. The defence is a value the attacker's page cannot read: a CSRF token, rendered into a hidden field and checked on submit. Symfony's forms do this already. A form rendered through the Twig form functions gains a hidden _token field, and isValid() returns false when it is absent or wrong, with no code from you. Use csrf_token_id to scope a token to one form, and switch csrf_protection off only for a form that writes nothing, such as a GET search box. For a hand-written form with no form object, produce the value with csrf_token('some-id') in Twig and check it with $this->isCsrfTokenValid('some-id', $token): the same mechanism, by hand.
Uploads
FileType hands you an UploadedFile, not a string, so keep the field mapped => false and set the stored filename yourself. Validate with the File constraint, preferring extensions over mimeTypes so the extension and the real content must agree. Then move the file somewhere under var/, never under public/: anything in the web root is served as-is, which is how an uploaded script becomes code execution. Serve the stored files through a controller instead.
Example
<?php
// src/Controller/BrochureController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Product;
use App\Form\BrochureType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\SluggerInterface;
final class BrochureController extends AbstractController
{
#[Route('/products/{id}/brochure', name: 'app_product_brochure', methods: ['GET', 'POST'])]
public function upload(
Request $request,
Product $product,
EntityManagerInterface $em,
SluggerInterface $slugger,
// Outside public/, so the file is never served directly.
#[Autowire('%kernel.project_dir%/var/uploads/brochures')] string $uploadDir,
): Response {
$form = $this->createForm(BrochureType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile|null $file */
$file = $form->get('brochure')->getData();
if (null !== $file) {
// Never reuse the client's name: slug it, then make it unique.
$base = $slugger->slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$name = $base.'-'.uniqid().'.'.$file->guessExtension();
try {
$file->move($uploadDir, $name);
} catch (FileException) {
$this->addFlash('error', 'The brochure could not be stored.');
return $this->redirectToRoute('app_product_brochure', ['id' => $product->getId()]);
}
// Only the name goes in the database.
$product->setBrochureFilename($name);
$em->flush();
}
return $this->redirectToRoute('app_product_brochure', ['id' => $product->getId()]);
}
return $this->render('product/brochure.html.twig', ['form' => $form]);
}
}When to use it
- Accept a PDF brochure on a product page and store only its filename in the database.
- Reject an upload that is too large or is not really the file type its name claims.
- Keep uploaded files out of the web root so none of them can ever be executed.
- Guard a one-button delete action with the same token mechanism forms use.
More examples
The upload field and its constraint
Three options carry the whole design: mapped keeps the UploadedFile away from a string property, required makes the field optional on edit, and constraints validates what arrived. The File constraint rejects anything over maxSize before your code runs, but PHP's own upload_max_filesize and post_max_size cut in earlier still, and a request over post_max_size arrives with an empty body, so keep the server limits above the one you advertise here.
<?php
// src/Form/BrochureType.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
final class BrochureType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('brochure', FileType::class, [
'label' => 'Brochure (PDF)',
// The entity stores a filename string, not an UploadedFile.
'mapped' => false,
// Editing the product should not demand a re-upload.
'required' => false,
// An unmapped field cannot use attributes on the entity,
// so the constraint is declared here.
'constraints' => [
new Assert\File(
maxSize: '2M',
// extensions checks the name and the content agree;
// mimeTypes alone would not.
extensions: ['pdf'],
extensionsMessage: 'Please upload a PDF document.',
),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Product::class]);
}
}The same CSRF check, by hand
This is the longhand of what a form object does for you: a hidden field holding a token tied to a named id. The controller does the other half, reading the value with $request->getPayload()->get('token') and checking it with $this->isCsrfTokenValid('delete-product', $submittedToken) before deleting anything. Reach for it on single-button actions, where building a whole form type for one submit button is not worth it. The id must match on both sides, or the check fails every time.
{# templates/product/_delete.html.twig #}
{# A destructive action needs POST and a token, even without a form object. #}
<form action="{{ path('app_product_delete', {id: product.id}) }}" method="post">
{# The argument is the token id: an arbitrary string naming this action. #}
<input type="hidden" name="token" value="{{ csrf_token('delete-product') }}">
<button type="submit">Delete</button>
</form>CSRF configuration
A new Symfony application already has this on, so the usual reason to open the file is to read it rather than change it. Worth knowing: the field_name set here is the name your JavaScript must send if you post a form with fetch instead of letting the browser submit it. Disable form CSRF protection globally only in an application that has no browser-submitted forms at all, such as a pure JSON API; switching it off per form is the narrower tool.
# config/packages/framework.yaml
framework:
# The token manager itself.
csrf_protection: true
form:
csrf_protection:
# Every form gets a token unless it opts out.
enabled: true
# The hidden field's name; _token by default.
field_name: '_token'
Discussion