Статья по безопасности Symfony (Symfony Cheat Sheet)
Введение
Статья даёт практические советы по безопасности при разработке на Symfony: типичные уязвимости и меры, чтобы приложение оставалось защищённым.
В Symfony уже есть встроенные механизмы безопасности, но разработчик должен понимать риски и правильно их использовать. Материал полезен и новичкам, и тем, кто хочет систематизировать практики: следование рекомендациям снижает риски для пользователей и данных.
Основные разделы
Межсайтовый скриптинг (XSS)
XSS — внедрение вредоносного JavaScript в выводимые данные. Если в переменной name лежит <script>alert('hello')</script>, а в HTML вывести Hello {{ name }}, скрипт выполнится при отображении.
По умолчанию Twig экранирует вывод: конструкция {{ }} преобразует опасные символы.
<p>Hello {{ name }}</p>
{# если name — '<script>alert('hello!')</script>', Twig выведет экранированный HTML #}Для доверенного HTML используйте фильтр raw — он отключает экранирование для этого фрагмента.
<p>{{ product.title|raw }}</p>
{# если product.title — 'Lorem <strong>Ipsum</strong>', вывод будет с тегами #}См. документацию Twig по escaper (блоки, отключение для шаблона).
Общие меры: Cross Site Scripting Prevention Cheat Sheet.
CSRF (межсайтовая подделка запроса)
Компонент Form по умолчанию добавляет CSRF-токены; Symfony проверяет их автоматически.
По умолчанию скрытое поле называется _token; параметры можно менять для каждой формы:
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PostForm extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// ...
'csrf_protection' => true,
'csrf_field_name' => '_csrf_token',
'csrf_token_id' => 'post_item',
]);
}
}Без Form можно генерировать и проверять токены вручную — установите пакет:
composer require symfony/security-csrfВключение/выключение в config/packages/framework.yaml:
framework:
csrf_protection: ~Пример формы в Twig с csrf_token():
<form action="{{ url('delete_post', { id: post.id }) }}" method="post">
<input type="hidden" name="token" value="{{ csrf_token('delete-post') }}">
<button type="submit">Delete post</button>
</form>В контроллере проверка (идентификатор должен совпадать с тем, что в csrf_token()):
use App\Entity\Post;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class ExampleController extends AbstractController
{
#[Route('/posts/{id}', methods: ['DELETE'], name: 'delete_post')]
public function delete(Post $post, Request $request): Response
{
$token = $request->request->get('token');
if ($this->isCsrfTokenValid('delete-post', $token)) {
// ...
}
// ...
}
}Дополнительно: Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet.
SQL-инъекции
SQL-инъекция возникает, когда запрос к БД собирается из недоверенного ввода. С Doctrine чаще используют параметризованные запросы, но небезопасный DQL всё ещё возможен.
Небезопасный DQL:
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ExampleController extends AbstractController {
public function getPost(Request $request, EntityManagerInterface $em): Response
{
$id = $request->query->get('id');
$dql = "SELECT p FROM App\Entity\Post p WHERE p.id = " . $id . ";";
$query = $em->createQuery($dql);
$post = $query->getSingleResult();
// ...
}
}Безопасные варианты:
- Метод репозитория:
$id = $request->query->get('id');
$post = $em->getRepository(Post::class)->findOneBy(['id' => $id]);- DQL с параметром:
$query = $em->createQuery("SELECT p FROM App\Entity\Post p WHERE p.id = :id");
$query->setParameter('id', $id);
$post = $query->getSingleResult();- Query Builder DBAL:
$qb = $em->createQueryBuilder();
$post = $qb->select('p')
->from('posts','p')
->where('id = :id')
->setParameter('id', $id)
->getQuery()
->getSingleResult();Документация Doctrine: doctrine-project.org. Общий контекст: SQL Injection Prevention Cheat Sheet.
Инъекция команд
Подробнее: Command Injection Defense Cheat Sheet.
Пример без экранирования ввода:
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Annotation\Route;
#[AsController]
class ExampleController
{
#[Route('/remove_file', methods: ['POST'])]
public function removeFile(Request $request): Response
{
$filename = $request->request->get('filename');
exec(sprintf('rm %s', $filename));
// ...
}
}Ввод вроде test.txt && rm -rf . опасен. Предпочтительнее unlink() или Filesystem::remove() вместо exec(). См. документацию PHP по файловым функциям.
Открытое перенаправление
Редирект на URL из непроверенного параметра ведёт к фишингу.
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Annotation\Route;
class ExampleController extends AbstractController
{
#[Route('/dynamic_redirect', methods: ['GET'])]
public function dynamicRedirect(#[MapQueryParameter] string $url): Response
{
return $this->redirect($url);
}
}Валидируйте и нормализуйте URL перед редиректом; не передавайте недоверенный ввод напрямую в redirect().
Уязвимости загрузки файлов
Проверяйте тип и размер на сервере, ограничивайте размер против DoS.
Тип и размер файла
Атрибуты PHP:
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints\File;
class UploadDto
{
public function __construct(
#[File(
maxSize: '1024k',
mimeTypes: [
'application/pdf',
'application/x-pdf',
],
)]
public readonly UploadedFile $file,
){}
}Через Form:
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\File;
class FileForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('file', FileType::class, [
'constraints' => [
new File([
'maxSize' => '1024k',
'mimeTypes' => [
'application/pdf',
'application/x-pdf',
],
]),
],
]);
}
}Уникальные имена файлов
Генерируйте уникальное имя (идентификатор + оригинальное имя), чтобы не перезаписывать существующие файлы.
Безопасное хранение
Храните загрузки вне публичного каталога или запретите прямой доступ на уровне веб-сервера.
Обход каталога (path traversal)
Доступ к файлам через ../ и вариации. Подробнее: OWASP Path Traversal.
Проверяйте, что реальный путь остаётся внутри каталога хранения, или используйте basename().
realpathи префикс каталога:
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Annotation\Route;
class ExampleController extends AbstractController
{
#[Route('/download', methods: ['GET'])]
public function download(#[MapQueryParameter] string $filename): Response
{
$storagePath = $this->getParameter('kernel.project_dir') . '/storage';
$filePath = $storagePath . '/' . $filename;
$realBase = realpath($storagePath);
$realPath = realpath($filePath);
if ($realPath === false || !str_starts_with($realPath, $realBase))
{
// обход каталога
}
// ...
}
}basename:
// ...
$storagePath = $this->getParameter('kernel.project_dir') . '/storage';
$filePath = $storagePath . '/' . basename($filename);
// ...Уязвимости зависимостей
Обновляйте Symfony и сторонние библиотеки.
composer updateСм. Symfony Security Checker (анализ composer.lock). Команда через Symfony CLI:
symfony check:securityCORS
CORS в браузере ограничивает запросы между доменами. В Symfony часто используют nelmio/cors-bundle:
composer require nelmio/cors-bundleПример конфигурации для префикса /api (после установки через Flex файл появится в config/packages):
# config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['*']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['*']
expose_headers: ['Link']
max_age: 3600
paths:
'^/api': ~ # ~ — наследовать defaultsНа продакшене сузьте allow_origin и методы под реальные нужды.
Заголовки безопасности
Имеет смысл выставлять, среди прочего:
- Strict-Transport-Security
- X-Frame-Options
- X-Content-Type-Options
- Content-Security-Policy
- X-Permitted-Cross-Domain-Policies
- Referrer-Policy
- Clear-Site-Data
- Cross-Origin-Embedder-Policy
- Cross-Origin-Opener-Policy
- Cross-Origin-Resource-Policy
- Cache-Control
См. OWASP Secure Headers. В Symfony — вручную, через ResponseEvent на каждый ответ или настройку Nginx/Apache.
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$response = new Response();
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');Сессии и cookie
Сессии по умолчанию настроены безопасно; детали — в config/packages/framework.yaml (framework.session).
Не задавайте cookie_secure в false (по умолчанию true). cookie_httponly: true — cookie недоступны из JavaScript.
cookie_httponly: trueКороткий TTL сессии: по рекомендациям OWASP — 2–5 минут для высокоценных приложений, 15–30 для менее критичных.
cookie_lifetime: 5cookie_samesite: lax или strict — ограничение отправки cookie с кросс-сайтовых запросов. lax допускает «безопасные» top-level навигации; strict — только тот же сайт.
cookie_samesite: lax # или strictcookie_secure: auto — cookie только по HTTPS там, где это уместно.
cookie_secure: autoДополнительно: Session Management Cheat Sheet, доклад о cookie.
В Symfony сессии управляются фреймворком, а не через session.auto_start = 1 в php.ini. Рекомендуется отключить session.auto_start, чтобы избежать конфликтов с механизмом Symfony.
Аутентификация
Symfony Security: провайдеры, файрволы, access control. Настройки в config/packages/security.yaml.
-
Провайдеры — откуда загружается пользователь (БД, LDAP и т.д.). Пример Entity User Provider:
providers: app_user_provider: entity: class: App\Entity\User property: email -
Файрволы — правила для частей приложения:
firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false admin: lazy: true provider: app_user_provider pattern: ^/admin custom_authenticator: App\Security\AdminAuthenticator logout: path: app_logout target: app_login main: lazy: true provider: app_user_provider -
Access control — кто куда может зайти:
access_control: - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/login, roles: PUBLIC_ACCESS }
Раскрытие информации в ошибках
По умолчанию подробные ошибки — только в dev; в prod показывается обобщённая страница, детали пишутся в лог.
Общие рекомендации: Error Handling Cheat Sheet.
Чувствительные данные
Конфигурацию и ключи API храните в переменных окружения. Symfony поддерживает secrets — значения шифруются и лежат в config/secrets/....
Генерация ключей (приватный ключ не коммитить):
bin/console secrets:generate-keysЗадать секрет:
bin/console secret:set API_KEYВ коде секреты читаются как env. Важно: если одно и то же имя задано и в env, и в secrets, побеждает значение из env.
Подробнее: Symfony Secrets.
Краткий чеклист
-
В продакшене не включайте отладку:
APP_ENV=prodAPP_ENV=prod -
Безопасная конфигурация PHP: PHP Configuration Cheat Sheet.
-
Корректный TLS на веб-сервере, принудительный HTTPS.
-
Заголовки безопасности.
-
Права на файлы и каталоги.
-
Резервное копирование БД и критичных файлов, план восстановления.
-
Сканирование зависимостей на известные уязвимости.
-
Мониторинг и отчёты об ошибках (например Blackfire.io).
Ссылки
© Перевод на русский язык. Оригинальные материалы: OWASP Cheat Sheet Series.
Этот проект использует материалы OWASP, распространяемые по лицензии CC BY-SA 4.0.