Symfony: tutorial 16: la seguridad

La seguridad no es una de las asignaturas difíciles en Symfony. Puede ser difícil en otras aplicaciones web hechas a medida, pero en Symfony, siguiendo unas pocas directrices, conseguiremos aplicaciones web muy seguras. Symfony nos provee de mecanismos fáciles de configurar, de métodos de programación para usar allí donde necesitemos estas capas de seguridad. Además de que hay componentes, hechos por expertos en seguridad, para nuestros proyectos.

Este es un post muy teórico, en donde dejo las pinceladas principales para luego ahondar más en cada tema. Es decir, usando en Symfony los componentes recomendados, y unas pocas directrices, conseguiremos aplicaciones web muy seguras sin ser necesariamente expertos en seguridad web.

¡Pero mucho ojo! Que la seguridad en la web, y de todo dispositivo conectado a Internet, depende de muchas más cosas, aparte de cómo esté programada la capa web.

Un poco de teoría sobre seguridad en la web

Para los no expertos en seguridad, nos es difícil averiguar cómo se pueden colar virus o hackers en nuestras webs. Para protegernos toca aprender cómo lo hacen, o por lo menos en parte. Los principales focos de problemas en las webs son:

  • La inyección SQL.
  • Los ataques cross site scripting XSS.
  • La autenticación y autorización.
  • Los problemas de rendimiento que desembocan en ataques de denegación de servicio DoS (Denial of Service).

Es decir, partimos de que tenemos una serie de elementos que pueden ser explotados. Ya sea por su configuración, su programación, o por su capacidad de rendimiento. Aparte de lo que nos toca como programadores, hay varios niveles a securizar aparte de la web en sí:

  • El Sistema Operativo puede tener vulnerabilidades conocidas.
  • Cada uno de los servicios a la escucha online puede ser explotado: servidor web, SSH, servidor de emails, DNS, bases de datos, etc.. cualquier servicio accesible remotamente, a la escucha.
  • Finalmente la web en sí, el software programado, directamente navegable, debe estar también protegido.
  • Y finalmente la parte humana de la aplicación, es lo que llaman el eslabón más débil.

El aspecto humano de la seguridad en las web es bastante incontrolable, y muchas veces es el origen de muchos ataques. Si una persona da su contraseña, o su ordenador personal no es seguro, la web puede estar ya comprometida. Como la persona atacada se trate de una persona con alto nivel de privilegios en la web, ya tendremos un agujero importante en la plataforma web.

Inyección SQL

La inyección SQL es un proceso por el cual, mediante peticiones HTTP, con SQL emebebido en dichas consultas, podemos acceder a datos sensibles de una página web. Aquí Symfony con Doctrine te ayuda mucho, pero en el momento en que se hagan búsquedas para mostrar datos, somos susceptibles de este ataque.

Si construimos consultas en DQL, o en SQL, mediante la concatenación de cadenas que el usuario escribe en la web.. ¡peligro!

El kit de la cuestión sobre esto, está en si usamos la entrada del usuario, la URL, o parámetros pasados por el navegador del usuario directamente para consultar la base de datos. Si hacemos consultas directamente con estos datos recibidos del usuario, corremos el peligro permitir la inyección SQL. Pueden llegar a construir consultas como listados de usuarios, de productos offline, ventas, etc.. Sacando información de la BD que no deberían de sacar.

Se recomienda usar Doctrine, mapeando los datos en entidades de Doctrine. Y hacer las consultas usando los llamados preparedStatement, parametrizando los datos de consulta que vienen del navegador visitante. Aquí también debemos de validar y sanitizar los datos de entrada pero Doctrine provee de algo de seguridad.

Cross Site Scripting XSS

Esta vulnerabilidad consiste en dejar que un usuario guarde Javascripts en nuestra aplicación web. Estos Javascripts luego se visualizan a muchos otros usuarios al visualizarse en el frontend. De esta manera, estos Javascripts pueden ejecutar todo tipo de tareas maliciosas en muchos navegadores de los usuarios como por ejemplo: pedir datos confidenciales haciéndose pasar por la web, captura de contraseñas, ver el comportamiento de los usuarios en tu web, y un largo etcétera.

Cómo protegerse:

  • Validar la entrada de usuarios a zonas donde se guarden datos con formularios. Es crítica la entrada de usuarios a zonas donde se configuren contenidos para cabeceras, pies de página, cualquier elemento de diseño de la web.
  • Validar la entrada de datos, puedes hacer comprobaciones de que no venga contenido con scripts, para esto puedes usar funciones como preg_match para encontrarlos de entrada.
  • Puedes limpiar los datos de entrada con funciones como strip_tags o preg_replace.
  • Finalmente al visualizar contenidos en el frontend, es muy peligroso permitir mostrarse los datos tal cual se guardaron. Symfony aquí te ayuda, pero si usas filtros como raw corres peligro de permitir HTML, CSS y Javascript peligroso en tu web.

Este es el ataque más peligroso, y habitual, en las aplicaciones web. Symfony por defecto no te protege de éste, pero sabiendo que existe podemos protegernos mejor.

Autenticación y autorización, el sistema de cortafuegos y proveedores de usuarios

Ahora viene lo mejor de Symfony para el tema de la autenticación y autorización de usuarios. Symfony nos provee de dos cosas en este aspecto bien diferenciadas.

Por un lado un sistema por el que definimos zonas de la aplicación que van a necesitar ciertos privilegios o categorización de los usuarios que los visitan. Y por otro lado proporciona unos mecanismos con los que identificamos y llevamos un seguimiento seguro de las sesiones de cada usuario.

De nuevo, para evitar aburrirte, voy a tratar de ir al grano con ejemplos funcionales. Primero que todo, para la versión 4 de Symfony tenemos que incluir el bundle de seguridad:

composer require symfony/security-bundle

Lo siguiente es hacer una entidad de Doctrine con todos los datos de usuario que queramos. Podemos usar el comando:

php bin/console make:entity

..y modificarlo. O mejor aún usamos el maker de seguridad de la forma:

php bin/console make:user

..siguiendo las instrucciones tendremos generados los ficheros necesarios. He nombrado a la entidad de pruebas Administrator:

src/Entity/Administrator.php
src/Repository/AdministratorRepository.php

Lo siguiente es ir al fichero de configuración y configurarlo todo. Aquí un ejemplo del fichero config/packages/security.yml funcional:

security:
    encoders:
        #FOS\UserBundle\Model\UserInterface:    bcrypt
        App\Entity\Administrator:
            algorithm:                          bcrypt
            cost:                               12

    role_hierarchy:
        ROLE_ADMIN:                             ROLE_USER
        ROLE_SUPER_ADMIN:                       ROLE_ADMIN

    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        #fos_userbundle:
            #id:                                fos_user.user_provider.username
        main_provider:
            entity:
                class:                          App\Entity\Administrator
                property:                       username
    firewalls:
        dev:
            pattern:                            ^/(_(profiler|wdt)|css|images|js)/
            security:                           false

        main:
            remember_me:
                secret:                         '%kernel.secret%'
                lifetime:                       604800 # 1 week in seconds
                path:                           /
                # by default, the feature is enabled by checking a
                # checkbox in the login form (see below), uncomment the
                # following line to always enable it.
                always_remember_me:            true
            pattern:                            ^/
            security:                           true
            provider:                           main_provider
            anonymous:                          ~
            form_login:
                # submit the login form here
                check_path:                     login
                # the user is redirected here when they need to log in
                login_path:                     login
                # if true, forward the user to the login form instead of redirecting
                use_forward:                    false
                # login success redirecting options (read further below)
                always_use_default_target_path: true
                default_target_path:            default
                target_path_parameter:          _target_path
                use_referer:                    false
                # login failure redirecting options (read further below)
                failure_path:                   /
                #failure_forward:               false
                #failure_path_parameter:        _failure_path
                #failure_handler:               some.service.id
                #success_handler:               some.service.id

                # field names for the username and password fields
                username_parameter:             _username
                password_parameter:             _password

                # csrf token options
                #csrf_parameter:                _csrf_token
                #csrf_token_id:                 authenticate
                #csrf_token_generator:          my.csrf_token_generator.id
                csrf_token_generator:           security.csrf.token_manager

                # by default, the login form *must* be a POST, not a GET
                post_only:                      true
                remember_me:                    true

                # by default, a session must exist before submitting an authentication request
                # if false, then Request::hasPreviousSession is not called during authentication
                require_previous_session:       true
            logout:
                path:                           logout
                target:                         default

    # Easy way to control access for large sections of your site
    # Note:                                     Only the *first* access control that matches will be used
    access_control:

        - { path: ^/public/, role:              IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/login$, role:               IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admins/edit/, role:         [ROLE_USER] }
        - { path: ^/admins/, role:              [ROLE_SUPER_ADMIN] }
        - { path: ^/core_config/, role:         [ROLE_SUPER_ADMIN] }
        - { path: ^/formula/, role:             [ROLE_ADMIN] }
        - { path: ^/, role:                     [ROLE_USER] }

Mira que tenemos varias zonas de acceso público (/login y /public/), luego tenemos zonas con 3 niveles de seguridad, para usuarios, administradores y super-administradores.

En líneas generales tenemos además los cortafuegos, que se configuran de la forma que hay que decirle que proveedor de usuarios, su sistema de usuarios y otros datos de configuración. En los otros datos de configuración tenemos cosas como dónde se hace login, páginas por defecto, donde se envía al usuario tras hacer logout, y un largo etcétera..

El controlador de login:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

class AdminSecurityController extends Controller
{
    private $tokenManager;

    public function __construct(CsrfTokenManagerInterface $tokenManager = null)
    {
        $this->tokenManager = $tokenManager;
    }

    /**
     * @Route("/login", name="login")
     *
     * @param Request $request
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function login(Request $request, AuthenticationUtils $authenticationUtils)
    {
        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();

        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render('login.html.twig', array(
            'last_username' => $lastUsername,
            'error' => $error,
        ));
    }

    /**
     * @Route("/logout", name="logout")
     */
    public function logout()
    {
        throw new \RuntimeException('You must activate the logout in your security firewall configuration.');
    }
}

Y el formulario correspondiente a este controlador y configuraciones anteriores funcional, esto es la plantilla Twig del login:

<!DOCTYPE html>
<html lang="es">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link href="{{ asset('vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
        <link href="{{ asset('vendor/font-awesome/css/font-awesome.min.css') }}" rel="stylesheet" type="text/css">
        <script src="{{ asset('vendor/jquery/jquery.min.js') }}"></script>
        <script src="{{ asset('vendor/bootstrap/js/bootstrap.min.js') }}"></script>
        <script src="{{ asset('js/fontawesome.full.min.js') }}"></script>
    </head>
    <body>
        {% if error %}
            <div class="alert alert-primary">{{ error.message }}</div>
        {% endif %}

        <div class="container p-3 text-center rounded">
            <i class="fab fa-earlybirds" style="font-size: 1500%; margin-top: 50px;"></i>

            <form action="{{ path('login') }}" method="post" role="form" class="form mt-5">
                <div class="form-group">
                    <label for="username">Usuario</label>
                    <input type="text" id="username" name="_username" value="{{ last_username }}" class="form-control" autofocus="autofocus"/>
                </div>

                <div class="form-group">
                    <label for="password">Contraseña</label>
                    <input type="password" id="password" name="_password" class="form-control"/>
                </div>

                <input type="checkbox" id="remember_me" name="_remember_me" checked />
                <label for="remember_me">Recuérdame</label>

                <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
                {#
                    If you want to control the URL the user
                    is redirected to on success (more details below)
                    <input type="hidden" name="_target_path" value="/account" />
                    #}

                <button type="submit" class="btn btn-primary form-control">Entrar</button>
            </form>
            <p>Está usted en una zona de acceso restringido.</p>
        </div>
    </body>
</html>

Es muy curioso que ya Symfony te genera un sistema con el que incluye tokens de seguridad, de un sólo uso, para cada formulario. Un sistema de roles con los que categorizar y dar seguridad a los usuarios. En fin, mucho trabajo ya disponible que nos ahorramos.

Por ejemplo si queremos controlar en plantilla el acceso con cierto rol podemos hacer:

        {% if is_granted('ROLE_SUPER_ADMIN') %}
        <li class="nav-item" class="nav-item">
            <a href="{{ path('admin_list') }}">
            <i class="fas fa-users"></i> Administradores</a>
        </li>
        {% endif %}

..este código hará que sólo a los super-administradores se les muestre el enlace en navegación. Pero qué pasa si ya saben el link, entonces podemos comprobar esto en el controlador lanzando excepciones como la siguiente:

    $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');

Sólo me queda para esta sección, el código de la entidad Administrator, y decir que ésta entity se puede administrar igual que cualquier otra entidad de Doctrine. Se puede genera un CRUD, incorporarlo en la web en la zona de administración, etcétera:

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * Usuario.
 *
 * @ORM\Table(name="administrator")
 * @ORM\Entity()
 */
class Administrator implements UserInterface, \Serializable
{
    /**
     * @var int @ORM\Column( type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue
     */
    protected $id;

    /**
     * @var string @ORM\Column(name="username", type="string",
     *             length=128, nullable=false, unique=true)
     */
    private $username;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $password;

    /**
     * @ORM\Column(type="string", length=60, unique=true)
     */
    private $email;

    /**
     * @ORM\Column(name="is_active", type="boolean", nullable=true)
     */
    private $isActive = true;

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $name;

    /**
     * @ORM\Column(type="json", nullable=true)
     */
    private $roles = [];

    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    public function __construct()
    {
    }

    public function __toString()
    {
        return $this->name;
    }

    /**
     * Set email.
     *
     * @param string $email
     *
     * @return Admin
     */
    public function setEmail($email)
    {
        $this->email = $email;

        return $this;
    }

    /**
     * Get email.
     *
     * @return string
     */
    public function getEmail()
    {
        return $this->email;
    }

    /**
     * Set username.
     *
     * @param string $username
     *
     * @return Admin
     */
    public function setUsername($username)
    {
        $this->username = $username;

        return $this;
    }

    /**
     * Get username.
     *
     * @return string
     */
    public function getUsername()
    {
        return $this->username;
    }

    /**
     * Get id.
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * {@inheritdoc}
     */
    public function getPassword()
    {
        return $this->password;
    }

    /**
     * Set password.
     *
     * @param string $password
     *
     * @return Admin
     */
    public function setPassword($password)
    {
        $this->password = $password;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function eraseCredentials()
    {
    }

    /**
     * {@inheritdoc}
     */
    public function equals(UserInterface $user)
    {
        return $this->id === $user->getId();
    }

    /** @see \Serializable::serialize() */
    public function serialize()
    {
        return serialize(array(
            $this->id,
            $this->username,
            $this->password,
        ));
    }

    /** @see \Serializable::unserialize() */
    public function unserialize($serialized)
    {
        list(
            $this->id,
            $this->username,
            $this->password) = unserialize($serialized, ['allowed_classes' => false]);
    }

    /**
     * Set isActive.
     *
     * @param bool $isActive
     *
     * @return Usuario
     */
    public function setIsActive($isActive)
    {
        $this->isActive = $isActive;

        return $this;
    }

    /**
     * Get isActive.
     *
     * @return bool
     */
    public function getIsActive()
    {
        return $this->isActive;
    }

    public function getSalt()
    {
        // you *may* need a real salt depending on your encoder
        // see section on salt below
        return null;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(?string $name): self
    {
        $this->name = $name;

        return $this;
    }

    public function setRoles(?array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }
}

Dejo esto aquí, pero decir que hay para la seguridad hay mucho trabajo de programación. Seguir una estructura bien definida en Symfony te ayudará mucho. Muchas situaciones inseguras estarán contempladas por el bundle de seguridad de Symfony. Pero recapitulando, es importante ser conscientes de que aunque mucho nos ayuda Symfony, mucho queda en manos del programador..

Problemas en el rendimiento, ataques de denegación de servicio

En sí mismo no es un problema de seguridad, aunque seguramente la web se quede sin servicio, no debe de desembocar en fuga de datos, o corrupción de estos. El problema final a la seguridad de la web con este ataque DoS es que la web se nos puede quedar sin dar servicio, con las implicaciones que esto tenga.

Para protegernos de esto en nuestras webs podemos hacer:

  • Contruir despacio y con buena letra, refactorizando siempre que podamos, con el profiler de Symfony en mente, revisando número de consultas a la base de datos, tiempos de ejecución de plantillas, etc..
  • Aplicar test de extrés, o pruebas de carga, a la web. Es decir, llenar de usuarios ficticios la web concurrentemente. Por ejemplo, poner 1000, 10000 usuarios a la vez en la web haciendo peticiones de todo tipo y monitorizar los resultados.
  • Crawlear la web completa, inspeccionar a ver si hubiera algún problema que desemboque en errores 500, o secciones de la página excesivamente lentas.
  • Ten en mente que una vez montado el servidor, el 99% de los problemas de rendimiento son por software, la solución no estará en aumentar el hardware.

Finalmente queda tener en mente el revisar a nivel de servidores constantemente sus configuraciones: Apache, Nginx, PHP/PHP-FPM, GNULinux, Redis, Mariadb, etc..

Terminando

Con todo esto ya tenemos unas pinceladas para la seguridad. Espero que me disculpen los expertos, ya que normalmente estoy muy centrado en la programación y seguro que se escapa algún detalle importante.

Cualquier aporte siempre será bien recibido. Como siempre, me remito a la documentación oficial para completar los ejemplos y explicaciones de arriba:
https://symfony.com/doc/current/security.html

¡Un saludo!

Compartir..

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *