Статья по безопасности 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',
                        ],
                    ]),
                ],
            ]);
    }
}

Уникальные имена файлов

Генерируйте уникальное имя (идентификатор + оригинальное имя), чтобы не перезаписывать существующие файлы.

Безопасное хранение

Храните загрузки вне публичного каталога или запретите прямой доступ на уровне веб-сервера.

См. File Upload Cheat Sheet.

Обход каталога (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:security

CORS

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: 5

cookie_samesite: lax или strict — ограничение отправки cookie с кросс-сайтовых запросов. lax допускает «безопасные» top-level навигации; strict — только тот же сайт.

cookie_samesite: lax  # или strict

cookie_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=prod

    APP_ENV=prod
  • Безопасная конфигурация PHP: PHP Configuration Cheat Sheet.

  • Корректный TLS на веб-сервере, принудительный HTTPS.

  • Заголовки безопасности.

  • Права на файлы и каталоги.

  • Резервное копирование БД и критичных файлов, план восстановления.

  • Сканирование зависимостей на известные уязвимости.

  • Мониторинг и отчёты об ошибках (например Blackfire.io).

Ссылки

© Перевод на русский язык. Оригинальные материалы: OWASP Cheat Sheet Series.
Этот проект использует материалы OWASP, распространяемые по лицензии CC BY-SA 4.0.